#!/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); });