chore(store): App Store Connect API client and listing push script
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:
marcuspaico
2026-09-07 16:56:15 +10:00
parent b65832bbd1
commit 2d74b7d152
3 changed files with 203 additions and 1 deletions

152
store/asc-fill.js Normal file
View 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
View 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); });
}

View File

@@ -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`
- **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
- **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):
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
- **Category**: Health & Fitness (secondary: Medical)