- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
101 lines
7.5 KiB
TypeScript
101 lines
7.5 KiB
TypeScript
// Upload the App Review screenshot of every in-app purchase (idempotent: a screenshot whose MD5 matches and that
|
|
// Apple processed is kept) and print each product's state with what is still missing. Nothing is submitted.
|
|
// storebound apple iap-review [--dry]
|
|
// Screenshot per product: products[].reviewScreenshot, else apple.reviewShots/<product id>.(png|jpg).
|
|
// Size: an App Store screenshot size (iPhone or iPad, e.g. 1320 x 2868 or 1290 x 2796); Apple processes the upload
|
|
// after it is committed and fails other sizes with IMAGE_INCORRECT_DIMENSIONS, so the size is checked before the
|
|
// upload and the delivery state is read back after it (an upload is only "done" when Apple says COMPLETE).
|
|
// Config products missing from the store's list (it lags a minute after `apple iap` creates them) are named with a
|
|
// warning and the exit code is 1, so nothing is skipped silently (docs/pitfalls.md). --dry ends with the plan table.
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import { sellsOn } from '../lib/config';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { need } from '../lib/ctx';
|
|
import { dryPlan, type PlanRow } from '../lib/dry';
|
|
import { done, log, warn } from '../lib/log';
|
|
import { reportMissing } from '../lib/store-lag';
|
|
import { IMAGE_SPECS, imageInfo } from '../shots/spec';
|
|
import { ascClient, md5 } from './asc';
|
|
import * as rt from '../lib/rt';
|
|
|
|
/** Every iPhone / iPad screenshot size App Store Connect accepts (portrait and landscape). */
|
|
export const REVIEW_SHOT_SIZES: [number, number][] = Object.values(IMAGE_SPECS)
|
|
.filter(s => s.store === 'apple' && /^APP_(IPHONE|IPAD)/.test(s.type))
|
|
.flatMap(s => s.sizes.flatMap(([w, h]) => [[w, h], [h, w]] as [number, number][]));
|
|
|
|
/** null when the image can be used as an App Review screenshot, else why not. Pure. */
|
|
export function reviewShotProblem(buf: Uint8Array): string | null {
|
|
const i = imageInfo(buf);
|
|
if (i.format === 'unknown') return 'not a PNG or JPEG';
|
|
if (!REVIEW_SHOT_SIZES.some(([w, h]) => w === i.w && h === i.h))
|
|
return `${i.w} x ${i.h} is not an App Store screenshot size (use e.g. 1320 x 2868 or 1290 x 2796 for iPhone, 2064 x 2752 for iPad; Apple fails other sizes with IMAGE_INCORRECT_DIMENSIONS)`;
|
|
return null;
|
|
}
|
|
|
|
type Shot = { id: string; attributes: { sourceFileChecksum?: string; fileName?: string; assetDeliveryState?: { state?: string; errors?: { code?: string; description?: string }[] } } };
|
|
const stateOf = (s: Shot | undefined): string => s?.attributes.assetDeliveryState?.state ?? 'NONE';
|
|
const errorsOf = (s: Shot | undefined): string => (s?.attributes.assetDeliveryState?.errors ?? []).map(e => e.code ?? e.description).filter(Boolean).join(', ');
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root, resolve } = ctx.loaded;
|
|
const dry = ctx.args.has('dry');
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const defs = new Map((config.products ?? []).map(p => [p.id, p]));
|
|
const shotOf = (id: string) => c.maybe(`/v2/inAppPurchases/${id}/appStoreReviewScreenshot`).then(r => r?.data as Shot | undefined);
|
|
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
|
const want = (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id);
|
|
const missing = reportMissing('apple', 'products', want, iaps.map(p => p.attributes.productId as string), { fail: true });
|
|
const rows: PlanRow[] = missing.map(id => ({ item: id, action: 'skip', detail: 'not in the store\'s list yet (run apple iap first, or re-run in a minute)' }));
|
|
let problems = 0;
|
|
for (const p of iaps) {
|
|
const sku: string = p.attributes.productId;
|
|
const def = defs.get(sku);
|
|
const file = def?.reviewScreenshot ? resolve(def.reviewScreenshot)
|
|
: ['png', 'jpg', 'jpeg'].map(e => resolve(join(config.apple?.reviewShots ?? 'store/iap-review', `${sku}.${e}`))).find(existsSync);
|
|
if (!file || !existsSync(file)) { warn('apple', `${sku}: no review screenshot (set products[].reviewScreenshot)`); rows.push({ item: sku, action: 'skip', detail: 'no review screenshot' }); problems++; continue; }
|
|
const buf = readFileSync(file);
|
|
const bad = reviewShotProblem(buf);
|
|
const cur = await shotOf(p.id);
|
|
const curState = stateOf(cur);
|
|
if (bad) {
|
|
warn('apple', `${sku}: ${relative(root, file)}: ${bad}${cur ? `; App Store Connect has ${curState}${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : ''}`);
|
|
rows.push({ item: sku, action: 'skip', detail: bad }); problems++; continue;
|
|
}
|
|
if (cur && cur.attributes.sourceFileChecksum === md5(buf) && curState !== 'FAILED') { log('apple', `${sku}: screenshot up to date (${curState})`); rows.push({ item: sku, action: 'keep' }); continue; }
|
|
const why = !cur ? 'none in App Store Connect' : curState === 'FAILED' ? `the one there FAILED${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : 'a different file is there';
|
|
if (dry) { log('apple', `${sku}: would upload ${relative(root, file)} (${why})`); rows.push({ item: sku, action: cur ? 'replace' : 'upload', detail: `${relative(root, file)} (${why})` }); continue; }
|
|
if (cur) await c.asc('DELETE', `/v1/inAppPurchaseAppStoreReviewScreenshots/${cur.id}`);
|
|
await c.uploadAsset('inAppPurchaseAppStoreReviewScreenshots', { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: p.id } } }, file);
|
|
// Apple processes the asset after the commit: read the state back (COMPLETE, or FAILED with the reason)
|
|
let after: Shot | undefined, state = 'UPLOAD_COMPLETE';
|
|
for (let i = 0; i < 10; i++) {
|
|
await rt.sleep(i ? 3000 : 1500);
|
|
after = await shotOf(p.id); state = stateOf(after);
|
|
if (state === 'COMPLETE' || state === 'FAILED') break;
|
|
}
|
|
if (state === 'FAILED') { warn('apple', `${sku}: Apple rejected the screenshot: ${errorsOf(after) || 'FAILED'}`); problems++; }
|
|
else log('apple', `${sku}: screenshot uploaded (${state})`);
|
|
}
|
|
// what keeps a product in MISSING_METADATA (Apple's rule: a localization, a price, availability and the screenshot)
|
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
|
const state: string = p.attributes.state;
|
|
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${state}`); continue; }
|
|
const lack: string[] = [];
|
|
const locs = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`);
|
|
if (!locs?.data?.length) lack.push('a localization (name + description)');
|
|
const price = await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`);
|
|
if (!price?.data) lack.push('a price');
|
|
const avail = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseAvailability`);
|
|
if (!avail?.data) lack.push('availability (territories)');
|
|
const shot = await shotOf(p.id);
|
|
if (!shot) lack.push('the review screenshot');
|
|
else if (stateOf(shot) === 'FAILED') lack.push(`a working review screenshot (FAILED: ${errorsOf(shot) || 'unknown'})`);
|
|
else if (stateOf(shot) !== 'COMPLETE') lack.push(`Apple to finish processing the screenshot (${stateOf(shot)}; check again in a minute)`);
|
|
log('apple', `${p.attributes.productId}: MISSING_METADATA${lack.length ? `: needs ${lack.join(', ')}` : ' (all parts present: Apple may still be updating the state)'}`);
|
|
}
|
|
if (dry) console.log(dryPlan('apple iap-review', rows));
|
|
if (problems) process.exitCode = 1;
|
|
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)');
|
|
}
|