From 33eb24a3dc1786825a8abdfb838e798f7e7e3948 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 12:07:55 +0800 Subject: [PATCH] apple submit --build (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 --- README.md | 10 ++- apps/desktop/src-tauri/src/commands.rs | 19 +++++- apps/desktop/src-tauri/src/policy.rs | 8 ++- apps/desktop/src-tauri/tests/d3_writes.rs | 26 ++++++- cli/apple/resubmit.ts | 28 ++++++-- cli/apple/submit.ts | 12 ++++ cli/commands.ts | 1 + cli/data/flags.json | 11 +++ cli/inspect.ts | 3 +- cli/lib/declarations.ts | 13 +++- cli/lib/desktop-protocol.ts | 9 +++ cli/serve.ts | 83 ++++++++++++++++++----- docs/idempotency.md | 2 +- skills/appstore-first-release/SKILL.md | 8 ++- tests/desktop-d3.test.ts | 69 +++++++++++++++++-- tests/resubmit.test.ts | 57 ++++++++++++++++ tests/serve.test.ts | 25 +++++-- 17 files changed, 335 insertions(+), 49 deletions(-) create mode 100644 cli/apple/submit.ts diff --git a/README.md b/README.md index fc7aa17..1864a85 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame | | `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots | | `apple iap-submit [--with-version] [--resolve-iaps] [--no-submit]` | API | IAP / subscription / group versions into a review submission (the first of a type together with the app version); reuses the open submission (a draft, or one back from review with UNRESOLVED_ISSUES; `--resolve-iaps` marks its REJECTED IAP items resolved), never a second one; asks before adding and before submitting | +| `apple submit --build [--version x.y.z]` | API | a version to App Review: build n (by number, VALID) attached, the open review submission reused (a new draft only when none is open), the ready IAPs / subscriptions with it, submitted with the same retry as resubmit; plan table without `--yes` (the owner's step; the desktop's Submit for review) | | `apple resubmit --build [--resolve-iaps]` | API | after a rejection: attaches build n to the rejected version, reuses the UNRESOLVED_ISSUES submission, adds missing items, marks the REJECTED version item resolved (IAP items with `--resolve-iaps`) and resubmits (a REJECTED item left stops it at once; retried while Apple says "not ready yet" only when none is left); plan table without `--yes` | | `apple offer-codes [--codes]` | API | offer code campaigns for one-time purchases; custom and one-time-use codes only with `--codes` (asks; codes saved to `.storebound/offer-codes/`) | | `apple win-back` | API | win-back offers for lapsed subscribers (asks: they go live on their start date) | @@ -885,8 +886,13 @@ methods (`check`, `validate`, `apple.status`, `play.listing`, ... plus `project. many project folders), structured results (the commands' `--json` output), progress notifications and cancellation. The first request is `session.hello` with a per-session secret only the host knows. Write methods are dry runs unless the call carries a single-use confirm token (HMAC over method, arguments and expiry) that the host mints after the -owner confirmed a native dialog showing that dry run. There is no method that submits, releases or publishes; Play -testing releases and phased release changes stay out of the desktop profile too. Types: `cli/lib/desktop-protocol.ts`. +owner confirmed a native dialog showing that dry run. Sending for review is there only as **owner actions** +(`owner.apple_submit`, `owner.apple_resubmit`, `owner.apple_iap_submit`, `owner.play_production`, next to the rollout, +phased release, price and reply owner actions): the window shows the plan (dry run) with the build or versionCode the +owner picked by number, then a native dialog, and only the host's token after that dialog runs it. Nothing publishes, +releases an approved version or cancels a submission, and the MCP server (AI tools) has none of the owner actions. +Play's "Send changes for review" has no API: the Releases tab shows its console plan and a deep link. Types: +`cli/lib/desktop-protocol.ts`. `bun tools/build-sidecars.ts` compiles the CLI for macOS, Linux and Windows (`bun build --compile`). Design, safety model and the Dependencies screen (tool sources and licenses): [docs/desktop.md](docs/desktop.md). diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 865e198..7626260 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -236,7 +236,7 @@ pub fn tilde(text: &str, home: Option<&str>) -> String { /// Human summary shown above the dry run in the confirm dialog. pub fn dialog_summary(title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String { let mut s = format!("{title} · {project}\n\n{description}\n\n"); - s.push_str(if network { "This changes your store records (not public until you submit or release in the console)." } else { "This changes local files only." }); + s.push_str(if network { "This changes your store records (not public until you submit or release it)." } else { "This changes local files only." }); if destructive { s.push_str("\nIt replaces what is there now."); } @@ -248,6 +248,21 @@ pub fn owner_summary(title: &str, description: &str, project: &str) -> String { format!("{title} · {project}\n\n{description}\n\nThis is your decision: it changes what users get on the store right away. storebound does it only because you press the button below.") } +/// Summary of an owner action that sends the app to review or production (policy::SUBMIT_METHODS): what goes out and +/// that it may go live by itself once approved. +pub fn submit_summary(title: &str, description: &str, project: &str) -> String { + format!("{title} · {project}\n\n{description}\n\nThis is your decision: it sends the app to the store's review. Once approved it can go live without another click (App Store: the version's release setting; Google Play: with managed publishing off). storebound does it only because you press the button below.") +} + +/// The dialog text above the dry run of one write or owner action. +pub fn summary_for(method: &str, kind: Kind, title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String { + match kind { + Kind::Owner if policy::SUBMIT_METHODS.contains(&method) => submit_summary(title, description, project), + Kind::Owner => owner_summary(title, description, project), + _ => dialog_summary(title, description, project, network, destructive), + } +} + /// Dialog title and the confirm button of a write: owner actions name the action on the button. pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) { if kind == Kind::Owner { @@ -340,7 +355,7 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St let (title, text, ok) = if method == policy::CONSOLE_FILL { console_fill_dialog(&proj.name, &dry["data"]) } else { - let summary = if kind == Kind::Owner { owner_summary(&d.title, &d.description, &proj.name) } else { dialog_summary(&d.title, &d.description, &proj.name, d.network, d.destructive) }; + let summary = summary_for(&method, kind, &d.title, &d.description, &proj.name, d.network, d.destructive); let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default())); let (title, ok) = dialog_labels(kind, &d.title); (title, text, ok) diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 9b7d839..26db2e7 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -53,7 +53,8 @@ pub enum Kind { Session, /// Account-scoped store reads (apps.list, account.check): the host runs them with one account's key. Account, - /// Owner actions (rollout share, phased release, sending one approved reply): like writes, a dry run from the + /// Owner actions (rollout share, phased release, sending one approved reply, submit for review, the Play production + /// release, price changes): like writes, a dry run from the /// window; the real run only through `write_with_confirm`, whose dialog says that users see it at once. Owner, } @@ -106,6 +107,11 @@ pub fn sanitize_js_call(method: &str, kind: Option, params: &Value) -> Res Ok(Value::Object(out)) } +/// Owner actions that send the app to review or to production (cli/serve.ts OWNER_SUBMIT): App Store submit, resubmit +/// after a rejection, in-app purchases to App Review, the Play production release. Like every owner action they run +/// for real only through `write_with_confirm`; their dialog says that the app goes to review and may go live by itself. +pub const SUBMIT_METHODS: &[&str] = &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]; + /// Write methods whose project is a new folder without a config (the host picks it in a native dialog and runs them /// in its own commands: `project_init`, `project_import_fastlane`); `write_with_confirm` never runs them. pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"]; diff --git a/apps/desktop/src-tauri/tests/d3_writes.rs b/apps/desktop/src-tauri/tests/d3_writes.rs index f71b8ef..8eb9ee0 100644 --- a/apps/desktop/src-tauri/tests/d3_writes.rs +++ b/apps/desktop/src-tauri/tests/d3_writes.rs @@ -2,9 +2,9 @@ //! after a dialog that names the action on its button and says users see it at once. Tokens are bound to exactly the //! method and the { project, args } sent (the same vector as the TypeScript side checks). use serde_json::json; -use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary}; +use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary, summary_for}; use storebound_desktop::confirm::{canonical, mac, mint}; -use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE}; +use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE, SUBMIT_METHODS}; #[test] fn owner_actions_are_dry_runs_from_the_window() { @@ -29,6 +29,25 @@ fn owner_dialogs_name_the_action() { assert!(dialog_summary("Push", "d", "A", true, false).contains("not public until you submit")); } +#[test] +fn submit_for_review_is_an_owner_action_with_its_own_dialog() { + assert_eq!(SUBMIT_METHODS, &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]); + for m in SUBMIT_METHODS { + // a dry run from the window, whatever it sends; the real run only through write_with_confirm + let p = sanitize_js_call(m, Some(Kind::Owner), &json!({ "args": { "build": "52" } })).unwrap(); + assert_eq!(p["dryRun"], json!(true)); + for bad in [json!({ "dryRun": false }), json!({ "confirmToken": "sbt1.x" }), json!({ "args": {}, "project": "/x" })] { + assert!(sanitize_js_call(m, Some(Kind::Owner), &bad).is_err(), "{m} {bad}"); + } + let s = summary_for(m, Kind::Owner, "Submit for App Review", "Sends the version.", "Prism Puddle", true, false); + assert!(s.contains("Prism Puddle") && s.contains("store's review") && s.contains("go live without another click") && s.contains("press the button"), "{s}"); + } + // other owner actions keep their summary, writes theirs + assert!(summary_for("owner.play_rollout", Kind::Owner, "T", "d", "A", true, false).contains("right away")); + assert!(summary_for("play.listing", Kind::Write, "T", "d", "A", true, false).contains("not public until you submit")); + assert_eq!(dialog_labels(Kind::Owner, "Submit for App Review").1, "Yes: Submit for App Review"); +} + #[test] fn tokens_bind_method_project_and_args_of_each_write() { let secret = [9u8; 32]; @@ -41,6 +60,9 @@ fn tokens_bind_method_project_and_args_of_each_write() { ("owner.play_rollout", json!({ "action": "percent", "percent": 50 })), ("owner.ios_phased", json!({ "action": "resume", "version": "2.3.0" })), ("owner.reply_send", json!({ "file": "play-r-p1.md" })), + ("owner.apple_submit", json!({ "build": "52", "version": "2.4.0" })), + ("owner.apple_resubmit", json!({ "build": "53", "resolve_iaps": true })), + ("owner.play_production", json!({ "version_code": "24", "status": "inProgress", "percent": 10 })), ]; for (method, args) in writes { let bound = json!({ "project": "/apps/prism", "args": args }); diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index 2bd36d8..dcf634e 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -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 ` (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 { +export async function run(ctx: Ctx): Promise { 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 { + 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 [--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 [--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 { 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 { 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 { 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; } diff --git a/cli/apple/submit.ts b/cli/apple/submit.ts new file mode 100644 index 0000000..2ecc5af --- /dev/null +++ b/cli/apple/submit.ts @@ -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 [--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 { await submitVersion(ctx, 'submit'); } diff --git a/cli/commands.ts b/cli/commands.ts index a93d94b..e4c861a 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -61,6 +61,7 @@ export const COMMANDS: Record Promise, 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 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 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)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index ede0d71..bc7f982 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [--yes]", "storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]" ], + "apple submit": [ + "storebound apple submit --build [--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]" ], diff --git a/cli/inspect.ts b/cli/inspect.ts index e6f96d2..ec1386f 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -8,7 +8,7 @@ // storebound inspect --render --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, }; } diff --git a/cli/lib/declarations.ts b/cli/lib/declarations.ts index df9d903..954156d 100644 --- a/cli/lib/declarations.ts +++ b/cli/lib/declarations.ts @@ -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, }; diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 51eef67..57491ea 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -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; diff --git a/cli/serve.ts b/cli/serve.ts index b099c3d..18225ba 100644 --- a/cli/serve.ts +++ b/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, 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 = { 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 = { + 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 { diff --git a/docs/idempotency.md b/docs/idempotency.md index 3fdf299..b9d0bef 100644 --- a/docs/idempotency.md +++ b/docs/idempotency.md @@ -55,7 +55,7 @@ first. No write path sends the same values again on a second run. | `apple cpp create` | page name; locale | page: findOrCreate + journal; locales from a fresh read, 409 = exists | ok T | | `apple cpp upload` / `apple ppo upload` / `apple screenshots` | display type; position + MD5 | set: findOrCreate (unique, 409 = found); screenshots: matching prefix kept, rest replaced in order | ok T (twice, crash on the 3rd file) | | `apple cpp submit` / `apple events submit` / `apple iap-submit` | the app's draft submission; version id per item | draft reused or created once (journal); an item in the draft is not added again | ok T (iap-submit) (owner: submit asks) | -| `apple iap-submit` / `apple resubmit` | the app's open review submission (draft or UNRESOLVED_ISSUES); version id per item | the open submission reused, never a second one; only missing items added; a draft the run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) left as is (an empty draft is not cancellable, harmless, reused later); REJECTED items in scope marked resolved (`resolved: true` is idempotent); a re-run after the submit sends nothing | ok T (resubmit) (owner: --yes) | +| `apple iap-submit` / `apple resubmit` / `apple submit` | the app's open review submission (draft or UNRESOLVED_ISSUES); version id per item | the open submission reused, never a second one; only missing items added; a draft the run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) left as is (an empty draft is not cancellable, harmless, reused later); REJECTED items in scope marked resolved (`resolved: true` is idempotent); a re-run after the submit sends nothing | ok T (resubmit) (owner: --yes) | | `apple events create` | reference name; locale | event: findOrCreate + journal; localizations planned from a fresh read | ok T | | `apple events upload` | asset type per locale (size + delivered) | kept when the same size is delivered, else replaced | ok | | `apple ppo create` | test name (open tests only); treatment name; locale | findOrCreate + journal for test and treatments | ok T | diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index 7fbec85..d909264 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -73,9 +73,11 @@ answers the compliance facts no file proves, all in one batch at the start. 12. **UI · App Privacy.** `store-declarations` skill (nutrition labels; you fill and save them), then the owner presses **Publish**. 13. **Preflight.** `storebound validate --apple` until it shows no blocker (`store-preflight`). -14. **Submit.** The owner's yes for `storebound apple iap-submit --with-version --yes` (version and IAPs in one - review submission; step 11), or Version page > Add for Review > Submit for Review when there are no IAPs - (owner). Release type MANUAL means the owner presses "Release" after approval. +14. **Submit.** `storebound apple submit --build ` (dry run: the plan table with the build you uploaded, by its + number, the version and the ready IAPs in one review submission), then the owner's yes for the same command with + `--yes`; with IAPs `apple iap-submit --with-version --yes` does the same without attaching a build (step 11). In + the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL + means the owner presses "Release" after approval. 15. **Verify + wait.** `storebound apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review submission. `storebound apple status --wait-review` polls until approved (exit 0) or rejected (exit 1, `review-rejections`). Approved: the owner releases (`release-management`). diff --git a/tests/desktop-d3.test.ts b/tests/desktop-d3.test.ts index a148698..013323f 100644 --- a/tests/desktop-d3.test.ts +++ b/tests/desktop-d3.test.ts @@ -12,7 +12,7 @@ import { parseDraft } from '../cli/lib/reviews'; import { setReplyText } from '../cli/lib/project-files'; import { childEnv, type RunOptions, type Running } from '../cli/lib/runner'; import { TOOLS } from '../cli/mcp-tools'; -import { describeMethods, methodName, OWNER_FORBIDDEN_ARGS, OWNER_TOOLS, Session } from '../cli/serve'; +import { describeMethods, methodName, OWNER_ALLOWED_ARGS, OWNER_FORBIDDEN_ARGS, OWNER_REAL_ONLY, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve'; import { toArgv } from '../cli/mcp-tools'; type J = Record; @@ -45,6 +45,12 @@ const D3_WRITES: [string, J][] = [ ['play.data_safety', {}], ['apple.appinfo', {}], ['apple.review_details', {}], ['apple.encryption', {}], ['apple.ppo_create', { variants: 'b', traffic: 30 }], ['apple.ppo_upload', { variant: 'b', experiment: 'storebound b' }], ['owner.ppo_start', { experiment: 'storebound b' }], ['owner.ppo_stop', {}], ['analytics.snapshot', {}], ['analytics.setup', {}], + // submit for review, resubmit, IAPs, the Play production release, price changes of existing products (owner actions) + ['owner.apple_submit', { build: '52' }], ['owner.apple_submit', { build: '52', version: '2.4.0', resolve_iaps: true }], ['owner.apple_resubmit', { build: '53' }], + ['owner.apple_resubmit', { build: '53', resolve_iaps: true }], ['owner.apple_iap_submit', { with_version: true }], ['owner.apple_iap_submit', { only: 'coins_100' }], + ['owner.play_production', { version_code: '24' }], ['owner.play_production', { version_code: '24', status: 'inProgress', percent: 10 }], ['owner.play_production', { version_code: '24', status: 'draft' }], + ['owner.prices_play_products', {}], ['owner.prices_play_subscriptions', {}], ['owner.prices_apple_subscriptions', { only: 'pro_monthly' }], + ['apple.testflight_groups', {}], ]; describe('D3 writes: token binding for every method the window offers', () => { @@ -87,19 +93,28 @@ describe('D3 writes: token binding for every method the window offers', () => { describe('owner actions', () => { test('exactly these, desktop only, kind owner, never a submit / release request / cancel / external TestFlight flag', () => { - expect(OWNER_TOOLS.map(t => methodName(t.name)).sort()).toEqual(['owner.ios_phased', 'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_import', 'owner.reply_send', 'owner.testflight_testers']); + expect(OWNER_TOOLS.map(t => methodName(t.name)).sort()).toEqual(['owner.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.ios_phased', 'owner.play_production', + 'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_apple_subscriptions', 'owner.prices_import', 'owner.prices_play_products', 'owner.prices_play_subscriptions', + 'owner.reply_send', 'owner.testflight_testers']); + for (const n of OWNER_SUBMIT) expect(OWNER_TOOLS.some(t => t.name === n)).toBe(true); for (const t of OWNER_TOOLS) expect(TOOLS.some(x => x.name === t.name)).toBe(false); const ds = describeMethods().filter(m => m.method.startsWith('owner.')); for (const d of ds) expect(d.kind).toBe('owner'); const samples: J[] = [ { action: 'percent', percent: 5 }, { action: 'halt' }, { action: 'resume', percent: 10 }, { action: 'complete', track: 'beta' }, { action: 'on' }, { action: 'pause', version: '1.2.3' }, { action: 'resume' }, { action: 'complete' }, { file: 'apple-123.md' }, {}, { groups: 'Studio team,Friends' }, + { build: '7' }, { build: '7', resolve_iaps: true }, { with_version: true }, { version_code: '9', status: 'inProgress', percent: 5 }, { only: 'coins_100' }, ]; - for (const t of OWNER_TOOLS) for (const s of samples) { + for (const t of OWNER_TOOLS) for (const s of samples) for (const dry of [true, false]) { let argv: string[]; - try { argv = toArgv(t, s).argv; } catch { continue; } - for (const a of argv) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a); + try { argv = toArgv(t, { ...s, dry_run: dry }).argv; } catch { continue; } + // a forbidden flag only in the one owner action it belongs to, and the owner-only ones never in a dry run + const allowed = OWNER_ALLOWED_ARGS[t.name] ?? []; + for (const a of argv) if (!allowed.includes(a)) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a); + if (dry) for (const a of OWNER_REAL_ONLY) expect(argv).not.toContain(a); } + // every allowed flag belongs to exactly the actions named, and the submit ones are OWNER_SUBMIT + expect(Object.keys(OWNER_ALLOWED_ARGS).filter(n => OWNER_ALLOWED_ARGS[n]!.some(a => ['--yes', 'submit', '--production'].includes(a))).sort()).toEqual(['owner_apple_resubmit', 'owner_apple_submit', 'owner_play_production']); }); test('argv: rollout share in percent -> fraction, halt / resume / complete, track; phased actions; one reply', () => { const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; @@ -124,10 +139,52 @@ describe('owner actions', () => { }); }); + +describe('submit for review from the desktop: owner actions only, build by number', () => { + const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; + test('argv: dry run first; --yes / --allow-price-changes only in the real run', () => { + expect(toArgv(t('owner_apple_submit'), { build: '52' }).argv).toEqual(['apple', 'submit', '--build', '52', '--dry']); + expect(toArgv(t('owner_apple_submit'), { build: '52', version: '2.4.0', dry_run: false }).argv).toEqual(['apple', 'submit', '--build', '52', '--version', '2.4.0', '--yes']); + expect(toArgv(t('owner_apple_resubmit'), { build: '53', resolve_iaps: true }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--resolve-iaps', '--dry']); + expect(toArgv(t('owner_apple_resubmit'), { build: '53', dry_run: false }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--yes']); + expect(toArgv(t('owner_apple_iap_submit'), { with_version: true, dry_run: false }).argv).toEqual(['apple', 'iap-submit', '--with-version']); + expect(toArgv(t('owner_play_production'), { version_code: '24', status: 'inProgress', percent: 10, dry_run: false }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'inProgress', '--fraction', '0.1']); + expect(toArgv(t('owner_play_production'), { version_code: '24' }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'completed', '--dry']); + expect(toArgv(t('owner_prices_play_subscriptions'), { dry_run: false }).argv).toEqual(['play', 'subscriptions', '--allow-price-changes']); + expect(toArgv(t('owner_prices_play_products'), { only: 'coins_100', dry_run: false }).argv).toEqual(['play', 'products', '--only', 'coins_100', '--allow-price-changes']); + expect(toArgv(t('owner_prices_apple_subscriptions'), {}).argv).toEqual(['apple', 'subscriptions', '--dry']); + }); + test('builds are numbers, never "latest"; bad values never reach a command', async () => { + const s = await session(); + for (const [m, args] of [['owner.apple_submit', {}], ['owner.apple_submit', { build: 'latest' }], ['owner.apple_submit', { build: '--yes' }], ['owner.apple_resubmit', { build: '7', version: 'x' }], + ['owner.play_production', {}], ['owner.play_production', { version_code: 'latest' }], ['owner.play_production', { version_code: '3', status: 'halted' }], ['owner.play_production', { version_code: '3', name: '--yes' }], + ['owner.prices_play_products', { only: '--yes' }], ['owner.prices_apple_subscriptions', { target: 'apple_iap' }], ['owner.apple_iap_submit', { only: 'a,--yes' }]] as [string, J][]) + expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams); + expect(s.calls.length).toBe(0); + }); + test('the real run carries --yes and the owner flag only with the host token; without it, a dry run or refused', async () => { + let now = 2_000_000; + const s = await session(() => now); + const p = { project: DEMO, args: { build: '52' } }; + await s.call('owner.apple_submit', p); + expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--dry', '--json']); + expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined(); + expect(err(await s.call('owner.apple_submit', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired); + await s.call('owner.apple_submit', { ...p, dryRun: false, confirmToken: mint(SECRET, 'owner.apple_submit', p, { now }) }); + expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--yes', '--json']); + expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe('1'); + // a write method is never a way to submit: the MCP-shaped tools have no submit, and the iap plan stays --dry + expect(err(await s.call('apple.iap_review_plan', { project: DEMO, dryRun: false }))).toBeUndefined(); + expect(s.calls.at(-1)!.argv).toContain('--dry'); + }); +}); + describe('full CLI coverage: new methods', () => { test('argv of the price sheet, PPO start / stop and the offline reads', async () => { const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!; - expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv']); + // the price sheet's real run says --allow-price-changes itself (the owner pressed the dialog); its dry run never + expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--allow-price-changes']); + expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv' }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--dry']); expect(toArgv(t('owner_ppo_start'), { experiment: 'storebound b' }).argv).toEqual(['apple', 'ppo', 'start', '--experiment', 'storebound b', '--dry']); expect(toArgv(t('owner_ppo_stop'), { dry_run: false }).argv).toEqual(['apple', 'ppo', 'stop']); const s = await session(); diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index d512021..f3da4b5 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -13,6 +13,7 @@ import { notReadyYet, otherSubmissionOf, submitTiming } from '../cli/apple/submi import { run as appleIapSubmit } from '../cli/apple/iap-submit'; import { run as appleResubmit } from '../cli/apple/resubmit'; import { run as appleStatus } from '../cli/apple/status'; +import { run as appleSubmit } from '../cli/apple/submit'; import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; @@ -177,6 +178,62 @@ describe('apple resubmit --build ', () => { }); }); +describe('apple submit --build (a first submission, the desktop owner action)', () => { + /** Version 1.0.0 in Prepare for Submission without a build, build 8 VALID, the gems IAP ready, no open submission. */ + function fresh(): void { + s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); + s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); + s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } }); + s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-gems', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-gems' } }); + } + const ONE: ShipConfig = { ...CONFIG, products: CONFIG.products!.slice(0, 1) }; + + test('dry run: plan table (build attach, version + IAP), nothing sent; under serve the table comes with the --json result', async () => { + fresh(); + const p = project(ONE); + const a = s.calls.length; + const r = await capture(() => appleSubmit(p.ctx(['--build', '8']))); + expect(r.exit).toBeFalsy(); + expect(shown(a)).toEqual([]); + expect(r.out).toMatch(/build\s+build 8\s+none attached\s+attach build 8/); + expect(r.out).toMatch(/submission\s+new draft\s+-\s+create/); + expect(r.out).toMatch(/app version 1\.0\.0/); + expect(r.out).toContain('com.example.notes.gems'); + expect(r.out).toContain('Submitting is the owner'); + process.env.STOREBOUND_MACHINE = '1'; + try { + const j = await capture(() => appleSubmit(p.ctx(['--build', '8', '--json']))); + expect(j.out).toContain('STEP'); + const line = j.out.split('\n').find(l => l.includes('storebound-result'))!; + expect(JSON.parse(line.slice(line.indexOf('{')))).toMatchObject({ dryRun: true, version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: { action: 'attach', id: 'b-8' }, blockers: [] }); + } finally { delete process.env.STOREBOUND_MACHINE; } + expect(shown(a)).toEqual([]); + }); + + test('--yes: build attached, one draft with the version and the IAP, submitted; a re-run sends nothing', async () => { + fresh(); + const p = project(ONE); + const a = s.calls.length; + const r = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes']))); + expect([r.exit ?? 0, r.err]).toEqual([0, '']); + const sid = s.asc.all('reviewSubmissions')[0]!.id; + expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', `PATCH /v1/reviewSubmissions/${sid}`]); + expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); + const b = s.calls.length; + const again = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes']))); + expect(again.exit).toBeFalsy(); + expect(shown(b)).toEqual([]); + }); + + test('a live version is not submitted; no editable version is a clear stop', async () => { + fresh(); + s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE'; + const p = project(ONE); + await expect(appleSubmit(p.ctx(['--build', '8', '--version', '1.0.0']))).rejects.toThrow(/READY_FOR_SALE: only a version in Prepare for Submission/); + await expect(appleSubmit(p.ctx(['--build', '8']))).rejects.toThrow(/no App Store version to submit/); + }); +}); + describe('apple iap-submit with a submission back from App Review', () => { test('--with-version --yes reuses UNRESOLVED_ISSUES: no new draft, the missing item only, then resubmitted; a re-run writes nothing', async () => { rejected({ ver: 'READY_FOR_REVIEW' }); // the version item was resolved already diff --git a/tests/serve.test.ts b/tests/serve.test.ts index d383ca6..ac31ca3 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -5,8 +5,8 @@ import { join } from 'node:path'; import { canonical, mac, MAX_TTL_MS, mint, parseSecret, TokenGuard } from '../cli/lib/confirm-token'; import { ERR } from '../cli/lib/desktop-protocol'; import { childEnv, pickCallEnv, type RunOptions, type Running } from '../cli/lib/runner'; -import { FORBIDDEN_ARGS, TOOLS } from '../cli/mcp-tools'; -import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_TOOLS, Session } from '../cli/serve'; +import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; +import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve'; import { buildOne, hostTarget, leaks } from '../tools/build-sidecars'; type J = Record; @@ -90,17 +90,28 @@ describe('confirm tokens', () => { }); describe('serve --stdio: methods', () => { - test('nothing submits, releases or publishes', () => { + test('only owner actions submit or release; nothing publishes; MCP has none of them', () => { const names = describeMethods().map(m => m.method); - // the owner actions (rollout share, phased release, sending one approved reply: tests/desktop-d3.test.ts) are the - // only methods with those words, all under "owner.", and none submits, publishes, cancels, deletes or invites + // the owner actions (rollout share, phased release, sending one approved reply, submit / resubmit, the Play + // production release: tests/desktop-d3.test.ts) are the only methods with those words, all under "owner." const owner = describeMethods().filter(m => m.kind === 'owner').map(m => m.method); expect(owner.sort()).toEqual(OWNER_TOOLS.map(t => methodName(t.name)).sort()); for (const n of owner) expect(n.startsWith('owner.')).toBe(true); for (const n of names.filter(x => !owner.includes(x))) expect(n).not.toMatch(FORBIDDEN_METHOD_WORDS); - for (const n of names) expect(n).not.toMatch(/submit|publish|cancel|delete|remove|invite|manual/i); - for (const n of ['apple.submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import']) + // submitting: exactly these owner actions, nothing else + const submitting = [...OWNER_SUBMIT].map(methodName).sort(); + expect(submitting).toEqual(['owner.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.play_production']); + for (const n of names.filter(x => !submitting.includes(x))) expect(n).not.toMatch(/submit|production/i); + for (const n of names) expect(n).not.toMatch(/publish|cancel|delete|remove|invite|manual/i); + for (const n of ['apple.submit', 'apple.resubmit', 'apple.iap_submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import']) expect(names).not.toContain(n); + // the MCP server (AI tools) never submits, resubmits or sends a production release + for (const t of TOOLS) { + // only the read-only IAP review plan runs iap-submit, and always with --dry + if (t.command.some(c => /submit/.test(c))) { expect([t.name, t.write]).toEqual(['apple_iap_review_plan', false]); expect(toArgv(t, { dry_run: false }).argv).toContain('--dry'); } + for (const s of [{}, { track: 'production' }, { dry_run: false }]) if (!validateInput(t, s).length) expect(toArgv(t, s).argv).not.toContain('--production'); + } + expect(TOOLS.some(t => OWNER_SUBMIT.has(t.name) || t.name.startsWith('owner_'))).toBe(false); // every desktop tool still goes through the MCP rules: no forbidden flag can come out of any of them for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c))).toBe(false); expect(new Set(names).size).toBe(names.length);