apple submit --build <n> (first submission, the resubmit steps; plan table with --json under serve); desktop owner actions to send for review: owner.apple_submit, owner.apple_resubmit, owner.apple_iap_submit, owner.play_production, and the price gate owner actions (owner.prices_play_products / _play_subscriptions / _apple_subscriptions; owner.prices_import passes --allow-price-changes after the dialog); OWNER_ALLOWED_ARGS ties each forbidden flag to its one action, dry runs never carry owner flags, MCP still has no submit or production release; submit dialog text in the host; system.tools androidSdk, project.inspect playRelease, declarations dataDeletion

This commit is contained in:
CRTkafa 2026-10-12 12:07:55 +08:00
parent 2e7dd44ae6
commit 33eb24a3dc
17 changed files with 335 additions and 49 deletions

View file

@ -13,6 +13,9 @@
// the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff
// (right after a build is attached), with heartbeat lines and a 10-minute limit.
// Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP.
// `storebound apple submit --build <n>` (cli/apple/submit.ts) runs the same steps for a first submission: the version
// is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected).
// Under `serve` (the desktop's owner action) the plan table is printed next to the --json result.
import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
@ -23,13 +26,19 @@ import { ascClient, type J } from './asc';
import { readSubmitState, submitPlanned } from './iap-submit';
import { openSubmissions } from './submission';
const TAG = 'resubmit';
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
* one, or one already Ready for Review in a draft). */
export const SUBMITTABLE_APP = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', ...REJECTED_APP]);
export async function run(ctx: Ctx): Promise<void> {
export async function run(ctx: Ctx): Promise<void> { await submitVersion(ctx, 'resubmit'); }
/** apple resubmit (after a rejection) and apple submit (a version to App Review), with a build picked by number. */
export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Promise<void> {
const TAG = mode;
const { config, root } = ctx.loaded;
const a = ctx.args;
const number = a.get('build');
if (!number || !/^\d+(\.\d+)*$/.test(number)) throw new Error('usage: apple resubmit --build <n> [--version x.y.z] [--dry] [--yes]: the build number you just uploaded (never "the latest")');
if (!number || !/^\d+(\.\d+)*$/.test(number)) throw new Error(`usage: apple ${mode} --build <n> [--version x.y.z] [--dry] [--yes]: the build number you just uploaded (never "the latest")`);
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
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)];
@ -41,8 +50,11 @@ export async function run(ctx: Ctx): Promise<void> {
const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId)));
const want = a.get('version');
const v: J | undefined = want ? versions.find((x: J) => x.attributes.versionString === want)
: versions.find((x: J) => held.has(x.id)) ?? versions.find((x: J) => REJECTED_APP.has(stateOf(x)));
if (!v) throw new Error(want ? `no App Store version ${want} on iOS` : 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
: versions.find((x: J) => held.has(x.id)) ?? versions.find((x: J) => (mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)));
if (!v) throw new Error(want ? `no App Store version ${want} on iOS`
: mode === 'submit' ? 'no App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first'
: 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
const versionString = String(v.attributes.versionString);
if (!a.has('json')) log(TAG, `app version ${versionString}: ${stateOf(v)}`);
@ -66,6 +78,8 @@ export async function run(ctx: Ctx): Promise<void> {
const dry = a.has('dry') || !a.has('yes');
if (a.has('json')) {
printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
// the desktop's owner dialog shows the output text: the plan table goes with the result there
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
if (blockers.length) process.exitCode = 1;
if (dry || blockers.length || state.choice?.action === 'busy') return;
} else {
@ -74,9 +88,9 @@ export async function run(ctx: Ctx): Promise<void> {
for (const x of blockers) warn(TAG, x);
}
if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
if (state.choice?.action === 'busy') { done(TAG, `nothing to resubmit: ${state.choice.why}`); return; }
if (state.choice?.action === 'busy') { done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; }
if (dry) {
done(TAG, a.has('dry') ? 'dry run: nothing sent' : 'dry run (no --yes): nothing sent. Resubmitting is the owner\'s step: after the owner\'s yes, run it again with --yes');
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`);
return;
}

12
cli/apple/submit.ts Normal file
View file

@ -0,0 +1,12 @@
// Send an App Store version to App Review with a build picked by its number (the owner's step).
// storebound apple submit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]
// The version is --version, else the one an open review submission holds, else the editable one (Prepare for
// Submission, or rejected). Build n (VALID) is attached, the open review submission is reused (a draft or one back
// from App Review; a new draft only when none is open), the in-app purchases and subscriptions of the config that are
// ready go in with the version, then it is submitted (retried while Apple says "not ready yet"). The same steps as
// `apple resubmit` (cli/apple/resubmit.ts). Without --yes: a dry run with the plan table. Never in MCP or flows; the
// desktop offers it as an owner action (native dialog, host-minted token).
import type { Ctx } from '../lib/ctx';
import { submitVersion } from './resubmit';
export async function run(ctx: Ctx): Promise<void> { await submitVersion(ctx, 'submit'); }

View file

@ -61,6 +61,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes)'],
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes)'],
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],

View file

@ -181,6 +181,14 @@
"version",
"wait-review"
],
"apple submit": [
"build",
"new-version",
"no-submit",
"resolve-iaps",
"version",
"with-version"
],
"apple subscriptions": [
"all",
"allow-price-changes",
@ -999,6 +1007,9 @@
"storebound apple status --cancel-submission <id> [--yes]",
"storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]"
],
"apple submit": [
"storebound apple submit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]"
],
"apple subscriptions": [
"storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]"
],

View file

@ -8,7 +8,7 @@
// storebound inspect --render <file> --json one render (relative to the renders folder) as a data: URL
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { platformsOf, profileOf, sellsOn } from './lib/config';
import { platformsOf, playCategoryOf, profileOf, sellsOn } from './lib/config';
import type { Ctx } from './lib/ctx';
import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations';
@ -42,6 +42,7 @@ export function summarize(ctx: Ctx): ProjectSummary {
icon: findIcon(config, root),
variants: Object.keys(config.shots?.variants ?? {}),
playReports: !!config.play?.reportsBucket,
playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null,
};
}

View file

@ -6,7 +6,7 @@
// config does not prove is an "ask the owner" item, never a guess (AGENTS.md rule 5). Pure.
import { parseCsv } from './csv';
import { type AccountSignUp, type DataSafety, type PlayPurpose, profileOf, type ShipConfig } from './config';
import { CATEGORIES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety';
import { CATEGORIES, DATA_DELETION_RESPONSE, dataDeletionResponse, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety';
import type { DeclarationsResult, DeclAsk } from './desktop-protocol';
import { NOTES_MAX_BYTES, reviewNotes } from './review-details';
@ -112,6 +112,16 @@ export interface DeclarationInputs {
uploaded: { csv: string; at: string } | null;
}
/** The general data deletion question (PSL_SUPPORT_DATA_DELETION_BY_USER) as the CSV answers it, where the answer
* comes from and the "Delete data URL" it sends. Pure. */
export function deletionAnswer(ds: DataSafety, deletionUrl?: string): { dataDeletion: 'yes' | 'no' | 'auto90' | null; dataDeletionSource: string | null; dataDeletionUrl: string | null } {
const r = dataDeletionResponse(ds, deletionUrl ? { deletionUrl } : undefined);
const dataDeletion = (Object.entries(DATA_DELETION_RESPONSE).find(([, v]) => v === r)?.[0] ?? null) as 'yes' | 'no' | 'auto90' | null;
const dataDeletionSource = ds.dataDeletion ? 'dataSafety.dataDeletion' : deletionUrl ? 'accounts.deletionUrl' : ds.deletionRequest !== undefined ? 'dataSafety.deletionRequest'
: ds.partialDeletion === true || ds.partialDeletion === 'auto90' ? 'dataSafety.partialDeletion' : null;
return { dataDeletion, dataDeletionSource, dataDeletionUrl: dataDeletion === 'yes' ? ds.partialDeletionUrl ?? deletionUrl ?? null : null };
}
export function declarations(config: ShipConfig, inp: DeclarationInputs): DeclarationsResult {
const ask: DeclAsk[] = [];
const play = !!config.play, apple = !!config.apple;
@ -128,6 +138,7 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
const types = Object.entries(ds.data).map(([id, u]) => ({ id, category: categoryOf(id), shared: !!u.shared, ephemeral: !!u.ephemeral, optional: !!u.optional, collect: [...u.collect], share: [...(u.shared ? u.share ?? [] : [])] }));
dataSafety = {
collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, partialDeletion: ds.partialDeletion ?? null, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],
...deletionAnswer(ds, config.accounts?.deletionUrl),
types, extra: ds.extra?.length ?? 0, csv, rows: csv ? csv.trim().split('\n').length - 1 : 0,
uploaded: inp.uploaded && csv ? { at: inp.uploaded.at, changes: dataSafetyChanges(inp.uploaded.csv, csv) } : null,
};

View file

@ -100,6 +100,9 @@ export interface ProjectSummary {
variants?: string[];
/** play.reportsBucket is set (Play statistics for analytics snapshots). */
playReports?: boolean;
/** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number
* of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */
playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null;
}
// ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ----
@ -271,6 +274,12 @@ export interface DeclarationsResult {
collectsData: boolean; encryptedInTransit: boolean; accountCreation: string[];
/** dataSafety.partialDeletion (PSL_SUPPORT_DATA_DELETION_BY_USER; asked once there are accounts); null: not answered. */
partialDeletion: boolean | 'auto90' | null;
/** PSL_SUPPORT_DATA_DELETION_BY_USER ("can users request that their data is deleted?") as the CSV answers it
* (null: not answered), where the answer comes from (dataSafety.dataDeletion, accounts.deletionUrl, ...) and the
* Delete data URL it sends. Optional: older sidecars do not send them. */
dataDeletion?: 'yes' | 'no' | 'auto90' | null;
dataDeletionSource?: string | null;
dataDeletionUrl?: string | null;
types: { id: string; category: string; shared: boolean; ephemeral: boolean; optional: boolean; collect: string[]; share: string[] }[];
/** Extra CSV rows of questions storebound does not model. */
extra: number;

View file

@ -8,7 +8,10 @@
// - structured results (`data`, the command's --json output), progress notifications and cancellation,
// - writes that are dry runs unless the host sends a confirm token (lib/confirm-token.ts) it minted after the owner
// confirmed a native dialog showing that dry run.
// Nothing here submits for review, releases or publishes: those methods do not exist (tests/serve.test.ts).
// Submitting for review, resubmitting and sending a Play production release exist only as owner actions (kind
// "owner", OWNER_SUBMIT): a dry run unless the host's confirm token comes with it, minted after the owner pressed the
// button of a native dialog that showed that dry run. Nothing else submits, releases or publishes, and the MCP server
// (`storebound mcp`, the AI tools) has none of them (tests/serve.test.ts).
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@ -21,7 +24,7 @@ import {
} from './lib/desktop-protocol';
import { emptyStatus, fillAppStatus, lastLine } from './lib/app-status';
import { RENDER_FILE_RE } from './lib/project-files';
import { detectTools } from './lib/tools';
import { androidSdk, detectTools } from './lib/tools';
import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner';
import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools';
@ -41,7 +44,7 @@ export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/;
/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has
* the owner actions for those; workspace and release-train reads: the window has portfolio.status for those; the raw API reader api_get). */
export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release', 'workspace_list', 'workspace_status', 'release_status', 'release_plan', 'api_get']);
/** Words no desktop method name may contain. */
/** Words no desktop method name may contain, owner actions aside (their names start with "owner."). */
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
/** Read tools whose command prints a structured result with --json. */
@ -52,7 +55,7 @@ export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_sugge
/** Console forms `storebound console fill` knows (cli/console/fill.ts). */
export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'];
/** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */
export const JSON_WRITES = new Set(['console_fill']);
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit']);
/** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */
const TOKEN_ANSWERS = new Set(['console_fill']);
@ -104,12 +107,26 @@ export const DESKTOP_ONLY: ToolDef[] = [
props: { store: { type: 'string', enum: ['apple', 'play'] }, with_automatic: { type: 'boolean', default: false } },
args: i => [...opt('store', i.store), ...(i.with_automatic === true ? ['--with-automatic'] : [])] },
];
const BUILD_NO = { type: 'string', pattern: '^\\d{1,9}(\\.\\d{1,9}){0,3}$', description: 'The build number (CFBundleVersion) the owner picked from the uploaded builds; never "the latest"' };
const VERSION = { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'App Store version (default: the one an open submission holds, else the editable one)' };
const IDS = { type: 'string', pattern: '^[A-Za-z0-9._]{1,100}(,[A-Za-z0-9._]{1,100}){0,49}$', description: 'Comma-separated product ids' };
/** `--yes` (the owner's step of apple submit / resubmit) and `--allow-price-changes` only in the real run, after the dialog. */
const real = (i: Record<string, unknown>, flag: string): string[] => (i.dry_run === false ? [flag] : []);
/** Catalog writes whose price changes of existing products are the owner's (lib/price-gate.ts): one owner action each. */
const PRICE_SYNCS: [name: string, command: string[], what: string][] = [
['owner_prices_play_products', ['play', 'products'], 'Google Play in-app products'], ['owner_prices_play_subscriptions', ['play', 'subscriptions'], 'Google Play subscriptions'],
['owner_prices_apple_subscriptions', ['apple', 'subscriptions'], 'App Store subscriptions'],
];
/**
* Owner actions (desktop only, kind "owner"): changes the owner decides because users see them at once. Each is a dry
* run unless the host's confirm token comes with it, minted after the owner pressed the button of a native dialog that
* Owner actions (desktop only, kind "owner"): the public or irreversible steps the owner decides. Each is a dry run
* unless the host's confirm token comes with it, minted after the owner pressed the button of a native dialog that
* showed that dry run; only then the command's own confirmation is answered (STOREBOUND_OWNER_CONFIRMED for that one
* child). Never: submitting for review, releasing an approved version (--manual-release), cancelling a submission,
* external TestFlight or Beta App Review, removing testers. Those stay in the consoles or the terminal.
* child) and the owner-only flags (`--yes` of apple submit / resubmit, `--allow-price-changes`) are added. Submitting
* for review (App Store version, its in-app purchases, a resubmit after a rejection) and the Play production release
* are here (OWNER_SUBMIT), with builds picked by number. Never: releasing an approved version (--manual-release),
* cancelling a submission, external TestFlight or Beta App Review, removing testers, Play's "Send changes for review"
* (no API: a console plan step). Those stay in the consoles or the terminal.
*/
export const OWNER_TOOLS: ToolDef[] = [
{ name: 'owner_play_rollout', title: 'Change the Play staged rollout', command: ['play', 'rollout'], write: true, network: true,
@ -134,18 +151,50 @@ export const OWNER_TOOLS: ToolDef[] = [
{ name: 'owner_prices_import', title: 'Apply the price sheet', command: ['prices', 'import'], write: true, network: true,
description: 'Writes the prices of the price sheet (CSV) that differ from the stores: App Store price schedules and Google Play regional prices. Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.',
props: { csv: { type: 'string', pattern: PRICE_SHEET_RE.source, description: 'The price sheet: a .csv path (default: prices.csv of the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] },
only: { type: 'string', pattern: '^[A-Za-z0-9._]{1,100}(,[A-Za-z0-9._]{1,100}){0,49}$', description: 'Comma-separated product ids' } },
args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only)] },
only: IDS },
args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...real(i, '--allow-price-changes')] },
{ name: 'owner_ppo_start', title: 'Start the Product Page Optimization test', command: ['apple', 'ppo', 'start'], write: true, network: true,
description: 'Starts an App Store Product Page Optimization test: the share of App Store visitors it names sees a treatment (treatments not yet approved go to App Review with the next version first).',
props: { experiment: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,64}$', description: 'Test name (default: the one editable test)' } }, args: i => opt('experiment', i.experiment) },
{ name: 'owner_ppo_stop', title: 'Stop the Product Page Optimization test', command: ['apple', 'ppo', 'stop'], write: true, network: true,
description: 'Stops a running App Store Product Page Optimization test. A stopped test cannot be restarted; the results stay in App Store Connect.',
props: { experiment: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,64}$', description: 'Test name (default: the running test)' } }, args: i => opt('experiment', i.experiment) },
...PRICE_SYNCS.map(([name, command, what]): ToolDef => ({ name, title: `Apply the price changes of existing ${what}`, command, write: true, network: true,
description: `Runs the ${what} sync with --allow-price-changes: everything a normal sync writes plus the price changes of existing (live) products, which a normal sync holds back for the owner. Users see the new prices as soon as the store applies them.`,
props: { only: IDS }, args: i => [...opt('only', i.only), ...real(i, '--allow-price-changes')] })),
{ name: 'owner_apple_submit', title: 'Submit for App Review', command: ['apple', 'submit'], write: true, network: true,
description: 'Sends an App Store version to App Review with the build the owner picked by its number: the build is attached, the open review submission reused (a new draft only when none is open), the in-app purchases and subscriptions of the config that are ready go with it, then it is submitted (retried while Apple says "not ready yet"). After approval it goes live as the version\'s release setting says.',
props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Mark REJECTED in-app purchase / subscription items of a submission back from App Review as resolved' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
{ name: 'owner_apple_resubmit', title: 'Resubmit to App Review', command: ['apple', 'resubmit'], write: true, network: true,
description: 'After a rejection: attaches the build the owner picked by its number to the rejected version, reuses the submission back from App Review (UNRESOLVED_ISSUES; missing items added), marks its REJECTED app version item resolved (in-app purchase items too with resolve_iaps) and resubmits it.',
props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Also mark REJECTED in-app purchase / subscription items resolved (after fixing what App Review asked for)' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
{ name: 'owner_apple_iap_submit', title: 'Submit in-app purchases for review', command: ['apple', 'iap-submit'], write: true, network: true,
description: 'Sends the in-app purchases and subscriptions of the config that are ready to App Review in the open review submission (reused, never a second draft). The first of a type needs an app version in the same submission (with_version: the editable one goes too, and submitting submits the app).',
props: { with_version: { type: 'boolean', default: false }, resolve_iaps: { type: 'boolean', default: false }, only: IDS },
args: i => [...(i.with_version === true ? ['--with-version'] : []), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...opt('only', i.only)] },
{ name: 'owner_play_production', title: 'Send a release to Google Play production', command: ['play', 'release'], write: true, network: true,
description: 'Puts the versionCode the owner picked (already on Play, by number) on the production track with the What\'s new of every language: completed (everyone), inProgress (a staged rollout share) or draft (a never-published app: Play takes only drafts; Send changes for review stays a Play Console step). With managed publishing off, Play publishes it by itself once Google approves it.',
props: { version_code: { type: 'string', pattern: '^\\d{1,10}$', description: 'The versionCode the owner picked; never "the latest"' }, status: { type: 'string', enum: ['completed', 'inProgress', 'draft'], default: 'completed' },
percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent for inProgress' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f-][^\\x00-\\x1f]{0,49}$', description: 'Release name (default: Play\'s)' } },
required: ['version_code'],
args: i => ['--version-code', String(i.version_code), '--production', String(i.status ?? 'completed'), ...(i.status === 'inProgress' && i.percent !== undefined ? ['--fraction', String(Number(i.percent) / 100)] : []), ...opt('name', i.name)] },
];
/** Flags no owner action may ever produce (`--csv` only for the price sheet of owner.prices_import). */
export const OWNER_FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--submit-review', '--remove', '--invite', '--production', '--csv', 'submit', 'publish', 'distribute'];
const OWNER_ALLOWED_ARGS: Record<string, string[]> = { owner_prices_import: ['--csv'] };
/** Owner actions that send something to review or production: allowed only as owner actions (host token), never in
* the MCP server (AI tools) or any other desktop method. */
export const OWNER_SUBMIT = new Set(['owner_apple_submit', 'owner_apple_resubmit', 'owner_apple_iap_submit', 'owner_play_production']);
/** Flags no owner action may ever produce, except where OWNER_ALLOWED_ARGS names it for one action. */
export const OWNER_FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--submit-review', '--remove', '--invite', '--production', '--csv', '--allow-price-changes', '--prune', 'submit', 'publish', 'distribute'];
/** The one owner action each of these flags belongs to (`--csv`: the price sheet; `--yes` / `submit`: apple submit /
* resubmit, whose real run needs --yes; `--production`: the Play production release; `--allow-price-changes`: the
* price owner actions). */
export const OWNER_ALLOWED_ARGS: Record<string, string[]> = {
owner_prices_import: ['--csv', '--allow-price-changes'], ...Object.fromEntries(PRICE_SYNCS.map(([name]) => [name, ['--allow-price-changes']])),
owner_apple_submit: ['submit', '--yes'], owner_apple_resubmit: ['--yes'], owner_play_production: ['--production'],
};
/** Flags only a real run (after the dialog, with the host's token) may carry; a dry run never does. */
export const OWNER_REAL_ONLY = ['--yes', '--allow-price-changes'];
/** Desktop-only methods whose `project` is a folder without a config yet (the host picks it in a native dialog). */
export const NEW_PROJECT = new Set(['project_init', 'import_fastlane']);
@ -171,7 +220,7 @@ const SYSTEM: MethodDescriptor[] = [
args: obj({ secret: { type: 'string' }, client: { type: 'object' } }, ['secret']) },
{ method: 'system.version', title: 'Version', description: 'storebound version, platform and whether this is the compiled sidecar.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) },
{ method: 'system.methods', title: 'Methods', description: 'Every method of this profile with its argument schema.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) },
{ method: 'system.tools', title: 'Local tools', description: 'Optional local tools with versions and where they come from (ffmpeg, ffprobe, Chrome, Xcode / simctl, adb, git); screenshot, video and capture features need them.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) },
{ method: 'system.tools', title: 'Local tools', description: 'Optional local tools with versions and where they come from (ffmpeg, ffprobe, Chrome, Xcode / simctl, adb, git); screenshot, video and capture features need them. Also the Android SDK as play build finds it.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) },
{ method: 'project.inspect', title: 'Inspect a project', description: 'Offline summary of a folder\'s storebound.config.ts (name, store ids, languages, products).', kind: 'read', network: false, destructive: false, json: true,
args: obj({ project: { type: 'string', description: 'Absolute folder path' } }, ['project']) },
{ method: 'apps.list', title: 'Apps on a store account', description: 'Every app one store account can see (App Store Connect team: GET /v1/apps with platforms and the newest version; Google Play: Reporting API apps:search over every developer account that invited the service account), optional 128 px icons. Credentials of exactly one account in params.credentials. Read-only.', kind: 'account', network: true, destructive: false, json: true,
@ -384,6 +433,7 @@ export class Session {
if (OWNER.has(t.name)) {
const allowed = OWNER_ALLOWED_ARGS[t.name] ?? [];
if (argv.some(a => OWNER_FORBIDDEN_ARGS.includes(a) && !allowed.includes(a))) throw new RpcError(ERR.invalidParams, `${method}: not an owner action from here`);
if (dryRun && argv.some(a => OWNER_REAL_ONLY.includes(a))) throw new RpcError(ERR.invalidParams, `${method}: owner flags only after the owner's dialog`);
// the verified token is the owner's answer to the command's own question; nothing else can set it
if (!dryRun) env.STOREBOUND_OWNER_CONFIRMED = '1';
} else if (TOKEN_ANSWERS.has(t.name) && !dryRun) env.STOREBOUND_OWNER_CONFIRMED = '1';
@ -463,9 +513,10 @@ export class Session {
export const STORE_KEY_RE = /^store:[A-Za-z0-9:;._-]{1,400}$/;
/** Optional local tools with versions; app-managed copies (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME) included. */
/** Optional local tools with versions; app-managed copies (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME) included; the
* Android SDK as `play build` finds it without a project (ANDROID_HOME, ANDROID_SDK_ROOT, the default folder). */
export function localTools(): Json {
return { tools: detectTools() };
return { tools: detectTools(), androidSdk: androidSdk() };
}
export async function serveStdio(): Promise<void> {