86 lines
6.1 KiB
TypeScript
86 lines
6.1 KiB
TypeScript
// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions.
|
|
// storebound apple status [--json]
|
|
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
|
|
// storebound apple status --cancel-submission <id> [--yes]
|
|
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
|
|
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
|
|
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { log, printJson } from '../lib/log';
|
|
import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
|
import { type Asc, ascClient, type J } from './asc';
|
|
|
|
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
|
const want = ctx.args.get('version');
|
|
const deadline = Date.now() + parseDuration(ctx.args.str('timeout', '48h'));
|
|
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
|
let delay: number | null = null;
|
|
let last = '';
|
|
for (;;) {
|
|
const vs = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
|
|
const v = want ? vs.find(x => x.attributes.versionString === want) : vs.find(x => classifyReviewState(state(x)) === 'pending') ?? vs[0];
|
|
if (!v) { log('apple', `no version ${want ?? ''} found`); process.exit(2); }
|
|
const st = state(v);
|
|
const outcome = classifyReviewState(st);
|
|
if (st !== last) { log('apple', `${v.attributes.versionString}: ${st}`); last = st; }
|
|
if (outcome === 'approved') { log('apple', 'approved (release it yourself, or it goes out automatically if the release type says so)'); process.exit(WAIT_EXIT.approved); }
|
|
if (outcome === 'rejected') { log('apple', 'rejected: read the message in App Store Connect > App Review, then the review-rejections skill'); process.exit(WAIT_EXIT.rejected); }
|
|
if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); }
|
|
delay = nextPollDelay(delay);
|
|
if (Date.now() + delay > deadline) { log('apple', 'timeout, still in review'); process.exit(WAIT_EXIT.timeout); }
|
|
log('apple', `next check in ${Math.round(delay / 1000)}s`);
|
|
await Bun.sleep(delay);
|
|
}
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
|
|
if (ctx.args.has('wait-review')) await waitReview(ctx, c, appId);
|
|
|
|
const cancel = ctx.args.get('cancel-submission');
|
|
if (cancel) {
|
|
if (!(await confirm(ctx, `Cancel review submission ${cancel}? (the version goes back to "Prepare for Submission")`))) return;
|
|
await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } });
|
|
log('apple', `review submission ${cancel} canceled; it takes a minute to settle`);
|
|
return;
|
|
}
|
|
|
|
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
|
|
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?limit=10&include=build`);
|
|
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
|
|
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
|
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
|
|
if (ctx.args.has('json')) {
|
|
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
|
|
const phased: Record<string, unknown> = {};
|
|
for (const v of versions.slice(0, 3)) {
|
|
const st = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? '');
|
|
if (!/READY_FOR_SALE|READY_FOR_DISTRIBUTION|PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED/.test(st)) continue;
|
|
phased[v.id] = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data ?? null;
|
|
}
|
|
// builds attached to a version but older than the five newest (so the train shows their number too)
|
|
const known = new Set((builds.data as { id: string }[]).map(b => b.id));
|
|
const missing = [...new Set(versions.map(v => v.relationships?.build?.data?.id).filter((id): id is string => !!id && !known.has(id)))];
|
|
const attachedBuilds = missing.length ? ((await c.asc('GET', `/v1/builds?filter[id]=${missing.join(',')}&limit=${missing.length}`)).data ?? []) : [];
|
|
// build id -> marketing version (CFBundleShortVersionString): a TestFlight-only version is a train at "testing"
|
|
const buildVersions: Record<string, string> = {};
|
|
const pre = new Map(((builds.included ?? []) as { id: string; type?: string; attributes?: { version?: string } }[]).map(p => [p.id, p.attributes?.version] as const));
|
|
for (const b of builds.data as { id: string; relationships?: { preReleaseVersion?: { data?: { id?: string } } } }[]) {
|
|
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
|
|
if (v) buildVersions[b.id] = v;
|
|
}
|
|
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased });
|
|
return;
|
|
}
|
|
|
|
console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`);
|
|
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
|
|
const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version]));
|
|
for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`);
|
|
for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`);
|
|
for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`);
|
|
}
|