chore(store): App Store Connect API client and listing push script
Some checks failed
Build & Release APK / build (push) Has been cancelled
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
This commit is contained in:
152
store/asc-fill.js
Normal file
152
store/asc-fill.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Push the App Store listing from store/listing.md into App Store Connect.
|
||||||
|
// Idempotent: re-running overwrites text fields and replaces the screenshot set.
|
||||||
|
//
|
||||||
|
// node store/asc-fill.js
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { api, APP_ID } = require("./asc");
|
||||||
|
|
||||||
|
const NAME = "Him: Kegel Exercises for Men";
|
||||||
|
const SUBTITLE = "Pelvic Floor Trainer & Timer";
|
||||||
|
const PRIVACY_URL = "https://rehbock.xyz/kegel/privacy.html";
|
||||||
|
const SUPPORT_URL = "https://rehbock.xyz/kegel/privacy.html";
|
||||||
|
const KEYWORDS = "kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided";
|
||||||
|
const PROMO = "Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.";
|
||||||
|
const DESCRIPTION = `A free pelvic floor trainer for men. No account, no ads, no subscription, no data collection. Everything runs on your phone.
|
||||||
|
|
||||||
|
Each session is a guided, four-block routine driven by a step timer with haptic cues so you can train with the screen off or your eyes closed:
|
||||||
|
|
||||||
|
• Holds — 8 slow squeeze-and-hold reps. The core of the workout. Holds start at 5 seconds and automatically grow by 1 second every 3 completed sessions, up to 30 seconds.
|
||||||
|
• Pushes — 5 reverse kegels (gently bear down) to balance tension and relaxation.
|
||||||
|
• Quick squeezes — 10 one-second burst contractions.
|
||||||
|
• Quick pushes — 10 one-second burst releases.
|
||||||
|
|
||||||
|
Progressive overload is built in. Finish sessions and the holds get longer on their own. Want to go faster or slower? Adjust hold time manually in Settings.
|
||||||
|
|
||||||
|
Stay consistent with a day streak, your best streak, and a two-week activity strip on the home screen.
|
||||||
|
|
||||||
|
Why kegels?
|
||||||
|
Pelvic floor exercises strengthen the muscles that support the bladder and bowel and are widely recommended for bladder control, post-prostate-surgery recovery, and general pelvic health. Like any muscle, they respond to regular, progressively harder training.
|
||||||
|
|
||||||
|
What this app is not
|
||||||
|
It is a timer and progression tracker, not a medical device, and gives no medical advice. If you have a medical condition or recent surgery, talk to a clinician first.
|
||||||
|
|
||||||
|
Privacy
|
||||||
|
Zero network access. Your session count, difficulty and history are stored only on your device. Delete the app and the data is gone.`;
|
||||||
|
|
||||||
|
const SHOTS_DIR = path.join(__dirname, "screenshots", "out", "ios");
|
||||||
|
const DISPLAY_TYPE = "APP_IPHONE_67"; // 6.9" / 6.7" iPhones (1320×2868 accepted)
|
||||||
|
|
||||||
|
async function first(p) {
|
||||||
|
const r = await api("GET", p);
|
||||||
|
return r.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const appInfo = await first(`/v1/apps/${APP_ID}/appInfos`);
|
||||||
|
const infoLoc = await first(`/v1/appInfos/${appInfo.id}/appInfoLocalizations`);
|
||||||
|
const version = await first(`/v1/apps/${APP_ID}/appStoreVersions?filter[platform]=IOS`);
|
||||||
|
const verLoc = await first(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations`);
|
||||||
|
console.log(`appInfo ${appInfo.id} / version ${version.attributes.versionString} (${version.attributes.appStoreState})`);
|
||||||
|
|
||||||
|
// 1. Name, subtitle, privacy policy
|
||||||
|
await api("PATCH", `/v1/appInfoLocalizations/${infoLoc.id}`, {
|
||||||
|
data: { type: "appInfoLocalizations", id: infoLoc.id, attributes: { name: NAME, subtitle: SUBTITLE, privacyPolicyUrl: PRIVACY_URL } },
|
||||||
|
});
|
||||||
|
console.log("✔ name / subtitle / privacy URL");
|
||||||
|
|
||||||
|
// 2. Description, keywords, promo, support URL
|
||||||
|
await api("PATCH", `/v1/appStoreVersionLocalizations/${verLoc.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "appStoreVersionLocalizations",
|
||||||
|
id: verLoc.id,
|
||||||
|
attributes: { description: DESCRIPTION, keywords: KEYWORDS, promotionalText: PROMO, supportUrl: SUPPORT_URL },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ description / keywords / promo / support URL");
|
||||||
|
|
||||||
|
// 3. Categories
|
||||||
|
await api("PATCH", `/v1/appInfos/${appInfo.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "appInfos",
|
||||||
|
id: appInfo.id,
|
||||||
|
relationships: {
|
||||||
|
primaryCategory: { data: { type: "appCategories", id: "HEALTH_AND_FITNESS" } },
|
||||||
|
secondaryCategory: { data: { type: "appCategories", id: "MEDICAL" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ categories");
|
||||||
|
|
||||||
|
// 4. Age rating: fitness timer, health topic yes, mild medical info, nothing else
|
||||||
|
const none = "NONE";
|
||||||
|
await api("PATCH", `/v1/ageRatingDeclarations/${appInfo.id}`, {
|
||||||
|
data: {
|
||||||
|
type: "ageRatingDeclarations",
|
||||||
|
id: appInfo.id,
|
||||||
|
attributes: {
|
||||||
|
alcoholTobaccoOrDrugUseOrReferences: none,
|
||||||
|
contests: none,
|
||||||
|
gamblingSimulated: none,
|
||||||
|
gunsOrOtherWeapons: none,
|
||||||
|
horrorOrFearThemes: none,
|
||||||
|
matureOrSuggestiveThemes: none,
|
||||||
|
medicalOrTreatmentInformation: "INFREQUENT_OR_MILD",
|
||||||
|
profanityOrCrudeHumor: none,
|
||||||
|
sexualContentGraphicAndNudity: none,
|
||||||
|
sexualContentOrNudity: none,
|
||||||
|
violenceCartoonOrFantasy: none,
|
||||||
|
violenceRealistic: none,
|
||||||
|
violenceRealisticProlongedGraphicOrSadistic: none,
|
||||||
|
advertising: false,
|
||||||
|
gambling: false,
|
||||||
|
healthOrWellnessTopics: true,
|
||||||
|
lootBox: false,
|
||||||
|
messagingAndChat: false,
|
||||||
|
parentalControls: false,
|
||||||
|
socialMedia: false,
|
||||||
|
unrestrictedWebAccess: false,
|
||||||
|
userGeneratedContent: false,
|
||||||
|
ageAssurance: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("✔ age rating declaration");
|
||||||
|
|
||||||
|
// 5. Screenshots: replace the iPhone 6.9" set
|
||||||
|
const sets = await api("GET", `/v1/appStoreVersionLocalizations/${verLoc.id}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}`);
|
||||||
|
for (const s of sets.data) await api("DELETE", `/v1/appScreenshotSets/${s.id}`);
|
||||||
|
const set = await api("POST", "/v1/appScreenshotSets", {
|
||||||
|
data: {
|
||||||
|
type: "appScreenshotSets",
|
||||||
|
attributes: { screenshotDisplayType: DISPLAY_TYPE },
|
||||||
|
relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: verLoc.id } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const files = fs.readdirSync(SHOTS_DIR).filter((f) => /^\d+\.png$/.test(f)).sort((a, b) => parseInt(a) - parseInt(b));
|
||||||
|
for (const f of files) {
|
||||||
|
const buf = fs.readFileSync(path.join(SHOTS_DIR, f));
|
||||||
|
const shot = await api("POST", "/v1/appScreenshots", {
|
||||||
|
data: {
|
||||||
|
type: "appScreenshots",
|
||||||
|
attributes: { fileName: f, fileSize: buf.length },
|
||||||
|
relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: set.data.id } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const op of shot.data.attributes.uploadOperations) {
|
||||||
|
const headers = Object.fromEntries(op.requestHeaders.map((h) => [h.name, h.value]));
|
||||||
|
const res = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
|
||||||
|
if (!res.ok) throw new Error(`upload chunk failed ${res.status} for ${f}`);
|
||||||
|
}
|
||||||
|
await api("PATCH", `/v1/appScreenshots/${shot.data.id}`, {
|
||||||
|
data: { type: "appScreenshots", id: shot.data.id, attributes: { uploaded: true, sourceFileChecksum: crypto.createHash("md5").update(buf).digest("hex") } },
|
||||||
|
});
|
||||||
|
console.log(`✔ screenshot ${f}`);
|
||||||
|
}
|
||||||
|
console.log("done");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
50
store/asc.js
Normal file
50
store/asc.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Minimal App Store Connect API client (ES256 JWT via node:crypto, no deps).
|
||||||
|
//
|
||||||
|
// node store/asc.js get /v1/apps/6809350427
|
||||||
|
// node store/asc.js patch /v1/appInfos/ID '{"data":{...}}'
|
||||||
|
//
|
||||||
|
// Reads the key from eas.json submit.production.ios (ascApiKeyPath/Id/IssuerId).
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const eas = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "eas.json"), "utf8"));
|
||||||
|
const cfg = eas.submit.production.ios;
|
||||||
|
const APP_ID = cfg.ascAppId;
|
||||||
|
const BASE = "https://api.appstoreconnect.apple.com";
|
||||||
|
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = b64url(JSON.stringify({ alg: "ES256", kid: cfg.ascApiKeyId, typ: "JWT" }));
|
||||||
|
const payload = b64url(JSON.stringify({ iss: cfg.ascApiKeyIssuerId, iat: now, exp: now + 19 * 60, aud: "appstoreconnect-v1" }));
|
||||||
|
const key = fs.readFileSync(cfg.ascApiKeyPath, "utf8");
|
||||||
|
const sig = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" });
|
||||||
|
return `${header}.${payload}.${b64url(sig)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, p, body, extraHeaders = {}) {
|
||||||
|
const res = await fetch(p.startsWith("http") ? p : BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token()}`, "Content-Type": "application/json", ...extraHeaders },
|
||||||
|
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
||||||
|
if (!res.ok) throw new Error(`${method} ${p} -> ${res.status}\n${JSON.stringify(json, null, 2)}`);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { api, APP_ID, token };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const [, , method, p, body] = process.argv;
|
||||||
|
if (!method || !p) { console.error("usage: asc.js <get|post|patch|delete> <path> [json]"); process.exit(1); }
|
||||||
|
api(method.toUpperCase(), p, body).then((r) => console.log(JSON.stringify(r, null, 2))).catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ the next tier. Never repeat a title word in the Apple keyword field.
|
|||||||
- Fallback if taken: `Him: Kegel Exercises for Men` is 28 chars; if Apple rejects, drop to `Him: Kegel Exercises`
|
- Fallback if taken: `Him: Kegel Exercises for Men` is 28 chars; if Apple rejects, drop to `Him: Kegel Exercises`
|
||||||
- **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
|
- **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
|
||||||
- **Keywords** (100 max, comma-separated, no spaces):
|
- **Keywords** (100 max, comma-separated, no spaces):
|
||||||
`kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,routine,guided`
|
`kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided`
|
||||||
- **Promotional text** (170 max):
|
- **Promotional text** (170 max):
|
||||||
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
|
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
|
||||||
- **Category**: Health & Fitness (secondary: Medical)
|
- **Category**: Health & Fitness (secondary: Medical)
|
||||||
|
|||||||
Reference in New Issue
Block a user