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:
parent
2e7dd44ae6
commit
33eb24a3dc
17 changed files with 335 additions and 49 deletions
83
cli/serve.ts
83
cli/serve.ts
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue