212 lines
15 KiB
TypeScript
212 lines
15 KiB
TypeScript
// Send in-app purchases and subscriptions to App Review through a review submission (the owner's action).
|
|
// storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]
|
|
// Default: every product and subscription of the config sold on the App Store. The plan (cli/lib/review-submit.ts)
|
|
// says which version of each goes in, which items are first of their type (those need an app version in the same
|
|
// submission: --with-version adds the editable one, and then submitting submits the app too), and what is left out.
|
|
// --new-version: approved or rejected products get a new draft version first (POST inAppPurchaseVersions /
|
|
// subscriptionVersions) so changed metadata can go to review. A DEVELOPER_REJECTED version (its submission was
|
|
// canceled) needs none: it goes into the submission again as it is (seen live, 2026-10-12). --no-submit: only fill the draft submission; the owner
|
|
// presses "Submit for Review" in App Store Connect. Without --no-submit the last step asks first (never under MCP).
|
|
// The submission is the app's open one when there is one (chooseSubmission): a draft, or one back from App Review with
|
|
// UNRESOLVED_ISSUES, which gets only the missing items and is resubmitted; while one waits for or is in review nothing
|
|
// is sent. A new draft is created only when none is open (storebound apple resubmit uses the same steps).
|
|
// --resolve-iaps: REJECTED in-app purchase / subscription items of a submission back from App Review are marked
|
|
// resolved before the submit (Apple refuses the submit with "not ready yet" while one is left). A REJECTED app version
|
|
// item is left to apple resubmit --build <n>, which attaches the fixed build first.
|
|
import { sellsOn } from '../lib/config';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { allSubscriptions } from '../lib/subscriptions';
|
|
import { addAction, bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, NEW_VERSION_FROM, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit';
|
|
import { readJournal } from '../lib/upsert';
|
|
import { type Asc, ascClient, type J } from './asc';
|
|
import { describeReq, execute } from './jsonapi';
|
|
import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmissions, OtherSubmissionError, resolveRejected, submitWithRetry } from './submission';
|
|
|
|
const TAG = 'iap-submit';
|
|
|
|
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string; platform?: string } = {}): Promise<SubmitState> {
|
|
const platform = o.platform ?? 'IOS';
|
|
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
|
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
|
|
const iaps: PIap[] = [];
|
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
|
// every product counts for "first of its type"; versions are only read for the products we submit
|
|
const versions = want.includes(p.attributes.productId) ? versionsOf(await c.all(`/v2/inAppPurchases/${p.id}/versions?limit=200`)) : [];
|
|
iaps.push({ id: p.id, productId: p.attributes.productId, type: p.attributes.inAppPurchaseType, state: p.attributes.state, versions });
|
|
}
|
|
const subs: PSub[] = [];
|
|
const groups: SubmitState['groups'] = [];
|
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
|
const members = await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`);
|
|
const wanted = members.some((s: J) => want.includes(s.attributes.productId));
|
|
groups.push({ id: g.id, name: g.attributes.referenceName, versions: wanted ? versionsOf(await c.all(`/v1/subscriptionGroups/${g.id}/versions?limit=200`)) : [] });
|
|
for (const s of members) subs.push({ id: s.id, productId: s.attributes.productId, groupId: g.id, state: s.attributes.state,
|
|
versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
|
|
}
|
|
// every open review submission: a draft or one back from App Review is reused, never a second one next to it
|
|
const open = await openSubmissions(c, appId, o.also, platform);
|
|
const choice = chooseSubmission(open, { appVersionId: o.appVersionId ?? appVersions.find((v: { state: string }) => /REJECTED|INVALID_BINARY/.test(v.state))?.id });
|
|
const draft: SubmitState['draft'] = choice.action === 'reuse' ? { id: choice.sub.id, state: choice.sub.state, itemVersionIds: choice.sub.items.map(i => i.versionId) } : null;
|
|
return { appVersions, iaps, subs, groups, draft, open, choice };
|
|
}
|
|
|
|
/** The requests a run would send: none when nothing is left to submit (seen live: every product already in review),
|
|
* only the submit when a submission back from App Review is resubmitted as it is. */
|
|
export function planRequests<R>(prepare: R[], plan: { items: unknown[]; submit: R }, resubmit = false): R[] {
|
|
return plan.items.length || resubmit ? [...prepare, plan.submit] : [];
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const dry = a.has('dry');
|
|
const only = a.list('only');
|
|
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)]
|
|
.filter(id => !only.length || only.includes(id));
|
|
for (const id of only) if (!ids.includes(id)) throw new Error(`--only ${id}: not a product or subscription of the config sold on the App Store`);
|
|
if (!ids.length) throw new Error('no in-app purchases or subscriptions in the config for the App Store');
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
let state = await readSubmitState(c, appId, ids);
|
|
|
|
if (a.has('new-version')) {
|
|
const reqs = [
|
|
...state.iaps.filter(p => ids.includes(p.productId) && NEW_VERSION_FROM.has(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('iap', p.id, p.productId)),
|
|
...state.subs.filter(p => ids.includes(p.productId) && NEW_VERSION_FROM.has(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('sub', p.id, p.productId)),
|
|
];
|
|
if (reqs.length) {
|
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
|
if (!dry) state = await readSubmitState(c, appId, ids);
|
|
}
|
|
}
|
|
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version'), resolve: { version: false, iaps: a.has('resolve-iaps') } });
|
|
}
|
|
|
|
/** Lines that say which submission is used and what else is open next to it. */
|
|
export function choiceLines(state: SubmitState): string[] {
|
|
const ch = state.choice;
|
|
if (!ch) return [];
|
|
const out = [ch.why];
|
|
if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : `: ${EMPTY_DRAFT}`}`);
|
|
return out;
|
|
}
|
|
|
|
export interface SubmitOpts {
|
|
appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean;
|
|
/** the app version that must be in the submission (resubmit) */
|
|
appVersionId?: string;
|
|
/** the App Store platform of the version and the submission (default iOS) */
|
|
platform?: string;
|
|
/** the caller printed its own plan (resubmit) */
|
|
quiet?: boolean;
|
|
/** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */
|
|
resolve?: ResolveScope;
|
|
/** second pass after a 409 named another submission */
|
|
retried?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Plans and sends a review submission: reuses the open one (chooseSubmission), adds only the missing items, then
|
|
* submits (PATCH submitted: true, retried while Apple says "not ready yet"). A draft is created only when no open
|
|
* submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is left as is (an empty draft is not cancellable),
|
|
* and the submission the 409 named is used instead. REJECTED items in `o.resolve` are marked resolved before the
|
|
* submit. Shared by iap-submit and resubmit.
|
|
*/
|
|
export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<void> {
|
|
const { root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const dry = a.has('dry');
|
|
const { state, tag } = o;
|
|
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId, platform: o.platform });
|
|
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
|
|
const submitting = willSubmit(state, plan, o.appVersionId);
|
|
const busy = state.choice?.action === 'busy';
|
|
if (a.has('json') && !o.quiet) {
|
|
printJson({ dryRun: dry, items: plan.items, skipped: plan.skipped, firstOfType: plan.firstOfType, hasApprovedVersion: plan.hasApprovedVersion,
|
|
needsAppVersion: plan.needsAppVersion, blockers: plan.blockers, draft: state.draft?.id ?? null, submission: state.choice ?? null,
|
|
requests: busy ? [] : planRequests(prepare, plan, submitting).map(r => describeReq(r)) });
|
|
if (dry || plan.blockers.length || busy) { if (plan.blockers.length) process.exitCode = 1; return; }
|
|
} else if (!o.quiet) {
|
|
for (const l of choiceLines(state)) log(tag, l);
|
|
for (const it of plan.items) log(tag, `${addAction(it)}: ${it.label}`);
|
|
for (const sk of plan.skipped) log(tag, `skip ${sk.id}: ${sk.why}`);
|
|
if (plan.firstOfType.length) log(tag, `first ${plan.firstOfType.join(' / ')} of the app: needs an app version in the same submission`);
|
|
for (const b of plan.blockers) warn(tag, b);
|
|
}
|
|
if (!a.has('json') && state.choice?.action === 'reuse') {
|
|
const marked = new Set(toResolve(state.choice.sub.items, o.resolve));
|
|
for (const it of state.choice.sub.items.filter(i => i.state === 'REJECTED')) {
|
|
if (marked.has(it)) log(tag, `${labelOf(state, it.versionId, it.kind)}: REJECTED, marked resolved before the submit`);
|
|
else warn(tag, `${labelOf(state, it.versionId, it.kind)} is REJECTED in ${state.choice.sub.id}: Apple takes no submit while it is not resolved; ${resolveHint(it.kind)}`);
|
|
}
|
|
}
|
|
if (busy) { done(tag, `nothing to submit: ${state.choice!.why}`); return; }
|
|
if (plan.blockers.length) { process.exitCode = 1; done(tag, 'nothing sent: fix the blockers above'); return; }
|
|
if (!submitting) { done(tag, 'nothing to submit'); return; }
|
|
const resubmit = state.draft?.state === 'UNRESOLVED_ISSUES';
|
|
const target = state.draft ? `${resubmit ? 'review submission' : 'draft'} ${state.draft.id}` : 'a new draft submission';
|
|
if (dry) {
|
|
await execute(c.asc, planRequests(prepare, plan, submitting), { dry, tag });
|
|
done(tag, plan.items.length ? `dry run: ${plan.items.length} item(s) would go into ${target}, then it is submitted` : `dry run: ${target} would be ${resubmit ? 'resubmitted' : 'submitted'} as it is`);
|
|
return;
|
|
}
|
|
const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`;
|
|
if (plan.items.length && !(await confirm(ctx, `Add ${what} to ${target}?`))) return;
|
|
// the open submission is reused, or a draft is created once, only now that none is open; the plan left out items
|
|
// already in it, and an item the store says is in there already (409) is not added twice
|
|
let sid = state.draft?.id;
|
|
let created = false;
|
|
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag, o.platform));
|
|
try {
|
|
for (const r of prepare) {
|
|
if (r.path === '/v1/reviewSubmissions') continue;
|
|
if (r.path === '/v1/reviewSubmissionItems') {
|
|
const rels = (r.body as J).data.relationships as Record<string, { data: { id: string } }>;
|
|
const kind = Object.keys(rels).find(k => k !== 'reviewSubmission')!;
|
|
if (await addItem(c, sid, kind, rels[kind]!.data.id, r.note ?? kind, tag, { checked: true })) log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
|
continue;
|
|
}
|
|
await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body);
|
|
log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
|
}
|
|
} catch (e) {
|
|
if (!(e instanceof OtherSubmissionError)) throw e;
|
|
warn(tag, e.message.split('\n')[0]!);
|
|
if (created) await leaveUnusedDraft(c, sid, tag);
|
|
if (o.retried || e.otherId === '?') throw e;
|
|
// use the submission the 409 named: read the state again with it and go once more
|
|
log(tag, `using review submission ${e.otherId} instead`);
|
|
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId, platform: o.platform });
|
|
for (const l of choiceLines(again)) log(tag, l);
|
|
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
|
|
return;
|
|
}
|
|
cleanStrayDrafts(root, o.appId, state, tag);
|
|
// a REJECTED item blocks every submit ("not ready yet") until it is marked resolved; the console's Resubmit too
|
|
const label = (it: { kind: string; versionId: string }): string => labelOf(state, it.versionId, it.kind);
|
|
const verb = resubmit ? 'Resubmit' : 'Submit';
|
|
if (a.has('no-submit')) {
|
|
if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label);
|
|
done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`);
|
|
return;
|
|
}
|
|
if (!(await confirm(ctx, `${verb} review submission ${sid} (${plan.items.length ? what : 'as it is'}) to App Review now?${plan.appVersion || resubmit ? ' This submits the app version too.' : ''}`))) {
|
|
done(tag, `${sid} kept; submit it in App Store Connect or re-run`); return;
|
|
}
|
|
if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label);
|
|
await submitWithRetry(c, sid, tag, { resolve: o.resolve, label });
|
|
done(tag, `review submission ${sid} ${resubmit ? 'resubmitted' : 'submitted'} (storebound apple status shows its state)`);
|
|
}
|
|
|
|
/** Drafts next to a reused submission. An empty one is left as is: App Store Connect answers 409 "Resource is not in
|
|
* cancellable state" for it (seen live) and it does no harm; one with items is named with the cancel command. */
|
|
function cleanStrayDrafts(root: string, appId: string, state: SubmitState, tag: string): void {
|
|
if (state.choice?.action !== 'reuse') return;
|
|
const mine = readJournal(root)[draftJournalKey(appId)]?.id;
|
|
for (const d of state.choice.strayDrafts) {
|
|
if (!d.items.length) { log(tag, `draft ${d.id}${d.id === mine ? ' (created by storebound)' : ''}: ${EMPTY_DRAFT}; left as it is`); continue; }
|
|
warn(tag, `draft ${d.id} (${d.items.length} item(s)) is still open next to ${state.choice.sub.id}; cancel it if it is not needed: storebound apple status --cancel-submission ${d.id}`);
|
|
}
|
|
}
|