From e01a27d7b71112894c66021c705ffe6dbbdb25c3 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 09:38:56 +0800 Subject: [PATCH] price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output --- CHANGELOG.md | 13 ++++ cli/apple/signing.ts | 2 +- cli/apple/subscriptions.ts | 30 +++++---- cli/catalog.ts | 2 +- cli/console/fill.ts | 19 ++++-- cli/data/flags.json | 24 +++++-- cli/desktop.ts | 2 +- cli/import.ts | 2 +- cli/lib/args.ts | 2 +- cli/lib/console-answers.ts | 2 +- cli/lib/console-plan.ts | 2 +- cli/lib/fastlane.ts | 4 +- cli/lib/flow.ts | 7 +- cli/lib/ppp.ts | 2 +- cli/lib/price-gate.ts | 41 ++++++++++++ cli/lib/scan/config-edit.ts | 2 +- cli/lib/scan/rules.ts | 2 +- cli/mcp-tools.ts | 6 +- cli/play/products.ts | 78 +++++++++++++--------- cli/play/subscriptions.ts | 37 ++++++++--- cli/prices.ts | 32 +++++++-- docs/research/fastlane-revenuecat.md | 2 +- skills/migrate-from-fastlane/SKILL.md | 2 +- skills/ppp-pricing/SKILL.md | 3 +- skills/release-flows/SKILL.md | 7 +- skills/store-iap-setup/SKILL.md | 18 +++-- skills/store-subscriptions/SKILL.md | 6 +- tests/console-flow.test.ts | 2 +- tests/do-it-dont-ask.test.ts | 32 +++++++++ tests/idempotency.test.ts | 2 +- tests/play-post-reads.test.ts | 50 +++++++++----- tests/price-gate.test.ts | 94 +++++++++++++++++++++++++++ tools/gen-flags.ts | 2 +- 33 files changed, 414 insertions(+), 117 deletions(-) create mode 100644 cli/lib/price-gate.ts create mode 100644 tests/price-gate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c5b8b..6edc53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Price changes of existing products need `--allow-price-changes` (`play products`, `play subscriptions`, + `apple subscriptions`, `prices import`; `apple iap` never changes an existing price). `--yes`, also the one + `flow run --apply --yes` adds to write steps, confirms creates, first prices and other updates only. Without the + flag a run writes everything else, prints `price change on ( -> ) needs the owner: re-run with + --allow-price-changes` per product and exits 3 (declined), so a flow stops with "step N declined". The terminal + question (no `--yes`) and the desktop owner action still cover them. Dry runs mark those rows `owner`; flows refuse + the flag in steps and never add it (lib/price-gate.ts, tests/price-gate.test.ts). Activating the purchase option + of an existing Play product is a normal update now (`--yes` confirms it). +- CLI wording: when `console fill` cannot open or fill part of a form (console UI changed, an unknown page or dialog + step), it says what is left and that the calling agent does it with its browser tool (`storebound console plan +
` / `console verify `), not "open it yourself" / "fill them yourself". Sign-in, agreements and the final + Submit / Create / Publish stay the owner's. Other "by hand" output (fastlane import, catalog pull, ppp, scan) points + at the config edit instead. `tests/do-it-dont-ask.test.ts` now scans the CLI's output strings too. - Agent rules: a first, top-level rule "Do it, don't ask" in `docs/agents-core.md` (and so AGENTS.md, GEMINI.md, the Copilot and Cursor rules), README ("How agents use storebound"), the store-pitfalls skill and the openings of console-autofill, play-first-release, appstore-first-release and store-declarations. The agent does API calls, diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 68e8cd4..9dced53 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -218,7 +218,7 @@ export async function run(ctx: Ctx): Promise { writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 }); log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`); if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!); - else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer yourself)'); + else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } for (const id of ids) { diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index e5c8508..81c8d29 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -3,13 +3,15 @@ // ones), prices (USA price point + its equalizations, your overrides, optional CSV rows from the prices sheet incl. // future-dated changes), introductory offers per territory and the review screenshot. Idempotent: what exists is // left alone, prices are compared per territory and only differences are sent. -// storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] +// storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes] // Also promotional offers (`offers`, signed by your server at purchase time) and offer code campaigns // (`offerCodes`). Custom codes of a campaign (redeemable by anyone who has the code) are only created with --codes // from a terminal that confirms; one-time-use codes are generated by the owner in App Store Connect. -// Prints the plan first. In a terminal it asks before writing anything. Without a terminal (MCP, CI) and without -// --yes it still creates what is new (nothing is public before App Review), but never changes prices of a -// subscription that already has them: that waits for the owner in a terminal. preserveCurrentPrice is always true +// Prints the plan first. In a terminal (no --yes) it asks before writing anything, price changes included. With --yes +// or without a terminal it still creates what is new, first prices included (nothing is public before App Review), but +// never changes prices of a subscription that already has them: that is the owner's call and needs +// --allow-price-changes (flows never add it). The run then prints "price change on ( -> ) needs the +// owner" and exits 3 (declined), after everything else is written. preserveCurrentPrice is always true // (existing subscribers keep their price; raising theirs is a separate step in App Store Connect). // First subscriptions are submitted with an app version, like first in-app purchases (store-subscriptions skill). // Pure parts and sources: lib/subscriptions.ts. @@ -18,6 +20,7 @@ import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup } import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; +import { allowPriceChanges, type HeldPrice, holdPriceChanges, withoutYes } from '../lib/price-gate'; import { parsePriceCsv, type PriceRow } from '../lib/prices'; import { allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges, @@ -79,11 +82,13 @@ export async function run(ctx: Ctx): Promise { if (dry) { done(TAG, `subscriptions planned (dry run, nothing written; prices are compared per territory on a real run${csv.length ? `, ${csv.length} CSV rows` : ''})`); return; } const yes = ctx.args.has('yes'); - let liveOk = yes; - if (process.stdin.isTTY && !yes) { + // price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts) + let liveOk: boolean | undefined; + if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return; liveOk = true; - } else if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing subscriptions wait for a terminal run'); + } else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner'); + const held: HeldPrice[] = []; const locale = new Map(config.languages.map(l => [l.id, l.apple] as const)); const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id); @@ -142,8 +147,10 @@ export async function run(ctx: Ctx): Promise { const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id)); if (!changes.length) log(TAG, ' prices up to date'); - else if (current.length && !liveOk) warn(TAG, ` ${changes.length} price change(s) skipped: run in a terminal to confirm`); - else { + else if (current.length && !(liveOk ??= await allowPriceChanges(ctx, changes.length, `Change the prices of existing subscriptions on App Store Connect (first: ${s.id}, ${changes.length} territories)? New subscribers pay the new price.`))) { + log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)'); + held.push({ id: s.id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) }); + } else { const resolved: { territory: string; pointId: string; startDate?: string | null }[] = []; for (const ch of changes) { let pt = known.find(p => p.territory === ch.territory && Math.round(p.price * 100) === Math.round(ch.to * 100)); @@ -205,7 +212,8 @@ export async function run(ctx: Ctx): Promise { } } } - done(TAG, 'subscriptions done (first ones are submitted with an app version: store-subscriptions skill)'); + holdPriceChanges(held); + done(TAG, `subscriptions done${held.length ? ', price changes left for the owner' : ''} (first ones are submitted with an app version: store-subscriptions skill)`); } /** Custom codes of one offer code campaign: only with --codes, only after a confirmation in a terminal (anyone with @@ -218,7 +226,7 @@ async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc: const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode)); const todo = oc.customCodes!.filter(cc => !have.has(cc.code)); if (!todo.length) return; - if (!(await confirm({ ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }, `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return; + if (!(await confirm(withoutYes(ctx), `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return; for (const cc of todo) { // a custom code is unique: a 409 finds the existing one await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true, diff --git a/cli/catalog.ts b/cli/catalog.ts index dfee206..aa2357d 100644 --- a/cli/catalog.ts +++ b/cli/catalog.ts @@ -142,7 +142,7 @@ async function pull(ctx: Ctx): Promise { if (!p.changes.length) { done(TAG, 'the config listings match the stores'); return; } if (!a.has('write') || a.has('dry')) { done(TAG, `${p.changes.length} difference(s); proposal not written (add --write for ${file})`); return; } mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, JSON.stringify({ note: 'storebound catalog pull: store texts that differ from storebound.config.ts. Merge by hand (or with your agent) into products[].listings / subscriptions[].subscriptions[].listings.', listings: p.listings, changes: p.changes }, null, 2) + '\n'); + writeFileSync(file, JSON.stringify({ note: 'storebound catalog pull: store texts that differ from storebound.config.ts. Merge them into products[].listings / subscriptions[].subscriptions[].listings.', listings: p.listings, changes: p.changes }, null, 2) + '\n'); done(TAG, `proposal written: ${file} (${p.changes.length} difference(s)); storebound.config.ts is unchanged`); } diff --git a/cli/console/fill.ts b/cli/console/fill.ts index 04407d9..087dcd6 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -781,13 +781,18 @@ export async function resumeAt(page: Page, plan: NavStep[]): Promise ` - ${g}`).join('\n')}`); + console.log(`\n${res.uncertain} answer(s) the config does not prove ([check] / [owner]): ask the owner for them in one batch, put them in storebound.config.ts, run again.\n\nSteps:\n${res.guidance.map(g => ` - ${g}`).join('\n')}`); } export async function run(ctx: Ctx): Promise { @@ -1012,7 +1017,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis if (r.signIn) return; // refused / ambiguous: not retried on this page; "not found" / "did not appear" is retried when the step shows up if (!/^(not found|did not appear)$/.test(r.why ?? '')) tried = `${(active() ?? b.page).url()}#${plan.findIndex(s => s.label === r.failed)}`; - warn(TAG, `could not open the form by itself at step "${r.failed}" (${r.why}). Open it yourself; storebound keeps watching and fills it when it appears:`); + warn(TAG, `could not open the form by itself at step "${r.failed}" (${r.why}). Opening it is left to ${agentPart(form)}; storebound keeps watching and fills it when it appears:`); if (r.candidates) say(r.failed === 'the developer account' ? `developer accounts: ${r.candidates.join(' | ')} (set play.developerAccount or play.developerId to pick one)` : `apps that match: ${r.candidates.join(' | ')} (set apple.appId / play.packageName in the config to pick one)`); if (first) for (const s of meta.open) say(`open the form: ${s}`); @@ -1100,7 +1105,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis const open = async (key: string, what: string, loc: Locator | null): Promise => { if (!loc) return; const n = opened.get(key) ?? 0; - if (n === 2) { opened.set(key, 3); warn(TAG, `${what} did not open after two clicks: the console UI may have changed. Open it yourself; storebound fills it when it appears`); } + if (n === 2) { opened.set(key, 3); warn(TAG, `${what} did not open after two clicks: the console UI may have changed. Opening it is left to ${agentPart(form)}; storebound fills it when it appears`); } if (n >= 2) return; opened.set(key, n + 1); await flowClick(loc, form, dataTypes, say, clicked); @@ -1198,8 +1203,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis const own: string[] = inDialog ? await dl.getByRole('button').filter({ visible: true }).evaluateAll((els: any[]) => els.map(e => (e.getAttribute('aria-label') || e.textContent || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 10)).catch(() => []) : []; const top = own.length ? own : dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null)); say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons ${own.length ? 'in the dialog' : 'there'}: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`); - if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. Answer it in the browser and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`); - say(`do this part yourself; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`); + if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. It is left to ${agentPart(form)}: answer it and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`); + say(`this part is left to ${agentPart(form)}; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`); for (const g of res.guidance) say(` ${g}`); }; /** Every round: a heartbeat line, the step watchdog (not while the owner answers or signs in), the idle limit. */ @@ -1254,10 +1259,10 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis misses = 0; // the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change) if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } } - if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): fill ${s.missing.length === 1 ? 'it' : 'them'} yourself`); + if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): filling ${s.missing.length === 1 ? 'it' : 'them'} is left to ${agentPart(form)}`); if (s.fallback) { fallback = true; - warn(TAG, `the page does not match what storebound expects (${s.missing.join(', ')}): the console UI changed. Nothing more is filled; follow these steps:`); + warn(TAG, `the page does not match what storebound expects (${s.missing.join(', ')}): the console UI changed. Nothing more is filled; the rest is left to ${agentPart(form)}, following these steps:`); for (const g of res.guidance) say(` ${g}`); break; } diff --git a/cli/data/flags.json b/cli/data/flags.json index 085af9c..188bd45 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -103,6 +103,7 @@ "apple next-build": [], "apple offer-codes": [ "all", + "allow-price-changes", "base", "ceiling", "codes", @@ -173,6 +174,7 @@ ], "apple subscriptions": [ "all", + "allow-price-changes", "base", "ceiling", "codes", @@ -222,6 +224,7 @@ ], "apple win-back": [ "all", + "allow-price-changes", "base", "ceiling", "csv", @@ -259,6 +262,7 @@ ], "catalog": [ "all", + "allow-price-changes", "base", "ceiling", "csv", @@ -406,7 +410,9 @@ "write" ], "play next-version-code": [], - "play products": [], + "play products": [ + "allow-price-changes" + ], "play release": [ "alpha", "beta", @@ -429,9 +435,12 @@ "aab", "apk" ], - "play status": [], + "play status": [ + "allow-price-changes" + ], "play subscriptions": [ "all", + "allow-price-changes", "base", "ceiling", "codes", @@ -456,6 +465,7 @@ ], "prices": [ "all", + "allow-price-changes", "base", "ceiling", "csv", @@ -610,6 +620,7 @@ "play" ], "validate": [ + "allow-price-changes", "apple", "capacitor", "cordova", @@ -659,6 +670,7 @@ "agent", "all", "all-pages", + "allow-price-changes", "allow-stale-web", "alpha", "apk", @@ -969,7 +981,7 @@ "storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]" ], "apple subscriptions": [ - "storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes]" + "storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]" ], "apple testflight": [ "storebound apple testflight status [--json] groups, testers per group, recent builds + beta states", @@ -1079,7 +1091,7 @@ "storebound play next-version-code -> 43" ], "play products": [ - "storebound play products [--dry] [--only id1,id2]" + "storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]" ], "play release": [ "storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]", @@ -1095,14 +1107,14 @@ "storebound play status [--json]" ], "play subscriptions": [ - "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes]" + "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]" ], "play upload": [ "storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]" ], "prices": [ "storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", - "storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes]", + "storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes] [--allow-price-changes]", "storebound prices ppp [--base ] [...] purchasing power price sheet (prices-ppp.ts)" ], "privacy-manifest": [ diff --git a/cli/desktop.ts b/cli/desktop.ts index 1a11e0a..a5a1fcd 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -100,7 +100,7 @@ export async function run(args: Args): Promise { const target = downloadsDir(); let out: string; try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; } - console.log(`saved ${out}\n SHA-256 and signature checked (key of storebound desktop). Not installed: open it yourself to install.`); + console.log(`saved ${out}\n SHA-256 and signature checked (key of storebound desktop). Not installed: the owner opens it to install it on this machine.`); if (!d.codeSigned) console.log(platform === 'darwin' ? ' This preview is not signed by Apple yet: after copying storebound to Applications, open it once, then System Settings > Privacy & Security > Open Anyway.' : platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : ''); diff --git a/cli/import.ts b/cli/import.ts index 464562d..36cea7d 100644 --- a/cli/import.ts +++ b/cli/import.ts @@ -46,7 +46,7 @@ export function report(plan: ImportPlan): string { lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` + `${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`); sec('mapped', plan.mapped.map(m => `${m.from} -> ${m.to}`)); - sec('not mapped (do these by hand)', plan.notMapped.map(n => `${n.what}: ${n.where}`)); + sec('not mapped (edit storebound.config.ts for these)', plan.notMapped.map(n => `${n.what}: ${n.where}`)); sec('keys, accounts and personal data (named only, nothing copied)', plan.secrets.map(n => `${n.what}: ${n.where}`)); sec('placeholders to replace in storebound.config.ts', plan.placeholders.map(n => `${n.what}: ${n.where}`)); sec('warnings', plan.warnings); diff --git a/cli/lib/args.ts b/cli/lib/args.ts index a6ae25a..92b3a01 100644 --- a/cli/lib/args.ts +++ b/cli/lib/args.ts @@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args { const repeated = new Map(); const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const _: string[] = []; - const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', ...booleans]); + const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', ...booleans]); for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (!a.startsWith('--')) { _.push(a); continue; } diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index 5160642..84be1f3 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -52,7 +52,7 @@ export interface ConsoleResult { form: FormId; store: Store; title: string; url: export const DISCLAIMER = [ 'storebound console fill is AI-assisted autofill. Console pages change without notice and it may misplace an answer.', - 'Review every field yourself. storebound never submits or publishes: you press the final button (App Privacy: storebound saves the answers as a draft, public only when you press Publish).', + 'Every field is checked before the final button (`storebound console verify ` reads the page back). storebound never submits or publishes: the owner presses the final button (App Privacy: storebound saves the answers as a draft, public only when the owner presses Publish).', 'storebound never types passwords or touches sign-in pages: you sign in in the browser window yourself.', ]; diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index 2a5d9ef..f9bcbdf 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -250,7 +250,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P add({ text: 'Verify: `storebound console verify apple-age-rating` reads the age rating declaration from the API.' }); return { start: { url: config.apple?.appId ? `${ASC}/apps/${config.apple.appId}` : `${ASC}/apps`, deepLink: !!config.apple?.appId, reach: ['General > App Information > Age Ratings (only to look)'] }, steps, - uiFacts: ['The age rating has an API (ageRatingDeclarations): never fill it by hand when storebound can set it.'], + uiFacts: ['The age rating has an API (ageRatingDeclarations): never fill it in the console when storebound can set it.'], verify: { command: 'storebound console verify apple-age-rating', source: 'api', capture: '', expect: ['Every question of the API declaration equals the answer sheet.'] }, }; case 'play-app-record': diff --git a/cli/lib/fastlane.ts b/cli/lib/fastlane.ts index 8e2a2ff..0726b6a 100644 --- a/cli/lib/fastlane.ts +++ b/cli/lib/fastlane.ts @@ -367,7 +367,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan { const appIdFound = appleAppId !== undefined && /^\d+$/.test(String(appleAppId)); if (appIdFound) placeholders.push({ what: 'apple.appId', where: 'commented out in storebound.config.ts: the numeric Apple ID was found in the Deliverfile; uncomment and fill it in if you want it pinned (storebound otherwise looks it up by bundle id)' }); for (const b of [...scan.appfile.byHand, ...scan.deliverfile.byHand]) - notMapped.push({ what: `${b.key}${b.line ? ` (line ${b.line})` : ''}: ${b.why}`, where: 'set by hand in storebound.config.ts if it matters (Ruby logic is never run)' }); + notMapped.push({ what: `${b.key}${b.line ? ` (line ${b.line})` : ''}: ${b.why}`, where: 'set it in storebound.config.ts if it matters (Ruby logic is never run)' }); // --- deliver (App Store) const dl = [...scan.deliver.locales]; @@ -480,7 +480,7 @@ Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`st ${appleSections.join('\n\n')} `; - if (md.includes('\n```\n```')) warnings.push('a text contains a ``` line: check store/appstore.md by hand'); + if (md.includes('\n```\n```')) warnings.push('a text contains a ``` line: check that text in store/appstore.md'); try { parseAppStoreListing(md); } catch (e) { warnings.push(`store/appstore.md: ${(e as Error).message.replace(/^appstore listing: /, '')} (\`storebound check\` reports it too)`); } files.push({ path: 'store/appstore.md', text: md, kind: 'appstore' }); } diff --git a/cli/lib/flow.ts b/cli/lib/flow.ts index 9b8f091..df60bae 100644 --- a/cli/lib/flow.ts +++ b/cli/lib/flow.ts @@ -21,7 +21,8 @@ // if: apple | play | exists | ${VAR} | !${VAR} | ${A} == x | ${A} != x // continueOnError: true a failure is reported and the flow goes on // Safety: write steps run with --dry unless `flow run --apply`; `--yes` is refused in any step (the commands ask the -// owner themselves; `flow run --apply --yes` is the owner's approval for the run and adds --yes to its write steps); owner steps (submit, release, rollout, sending replies, price changes, inviting testers, starting +// owner themselves; `flow run --apply --yes` is the owner's approval for the run and adds --yes to its write steps, which never changes the price of an existing product: --allow-price-changes is +// refused in steps and never added, so such a step exits 3 and the flow stops as declined); owner steps (submit, release, rollout, sending replies, price changes, inviting testers, starting // experiments ...) are never executed, also when written as `run:`. export type StepKind = 'read' | 'write' | 'wait' | 'owner' | 'shell'; @@ -71,7 +72,7 @@ const OWNER: string[][] = [ ]; const OWNER_ARGS = ['--submit-review', '--manual-release', '--cancel-submission', '--production', '--phased', 'submit', 'publish', '--remove', '--invite']; /** Never allowed inside a flow step. */ -const REFUSED_ARGS = ['--yes', '--apply']; +const REFUSED_ARGS = ['--yes', '--apply', '--allow-price-changes']; /** Words of a command line, honouring "double" and 'single' quotes. */ export function splitArgs(line: string): string[] { @@ -212,7 +213,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set } = {}) if (argv[0] === 'storebound') e.push(`${at}: write the command without "storebound" (run: ${argv.slice(1).join(' ')})`); if (argv[0] === 'flow') e.push(`${at}: use a "flow: " step instead of run: flow ...`); const bad = argv.find(a => REFUSED_ARGS.includes(a.split('=')[0]!)); - if (bad) e.push(`${at}: ${bad} is not allowed in a flow (writes are confirmed by the owner, --apply is for flow run)`); + if (bad) e.push(`${at}: ${bad} is not allowed in a flow (${bad === '--allow-price-changes' ? 'a price change of an existing product is the owner\'s own run' : 'writes are confirmed by the owner, --apply is for flow run'})`); if (o.commands) { const w = words(argv); const known = o.commands.has(`${w[0]} ${w[1]}`) || o.commands.has(`${w[0]} ${w[1]} ${w[2]}`) || o.commands.has(w[0] ?? ''); diff --git a/cli/lib/ppp.ts b/cli/lib/ppp.ts index da9008d..dffe2c5 100644 --- a/cli/lib/ppp.ts +++ b/cli/lib/ppp.ts @@ -298,7 +298,7 @@ export function worldBankDataset(pppResp: WbResp, fxResp: WbResp, countriesResp: `Every EUR region (euro area members, Montenegro, Kosovo) uses the euro area rate (XC, ${eur ? `${sig8(eur.v)} EUR per USD, ${eur.y}` : 'missing'}): per-country FCRF is missing for most members and still in BGN for Bulgaria, whose PPP factor is already in EUR.`, 'Only App Store territories are kept; aggregates (regions, income groups) are excluded.', ...(dropped.length ? [`Dropped as a unit mismatch (PPP / fx outside 0.05..3): ${dropped.join(', ')}.`] : []), - 'Missing countries (no recent World Bank data, e.g. Taiwan, Venezuela) get no proposal; set them by hand or with --source file.', + 'Missing countries (no recent World Bank data, e.g. Taiwan, Venezuela) get no proposal; set them in the sheet or with --source file.', ], ...(eur ? { eur: { fx: sig8(eur.v), year: eur.y } } : {}), countries, diff --git a/cli/lib/price-gate.ts b/cli/lib/price-gate.ts new file mode 100644 index 0000000..d82e2e4 --- /dev/null +++ b/cli/lib/price-gate.ts @@ -0,0 +1,41 @@ +// Price changes of existing (live) products: the owner's call, never confirmed by --yes. Used by play products, +// play subscriptions, apple subscriptions and prices import (apple iap never changes the prices of an existing product). +// Imported only by those commands, so --allow-price-changes is a flag of theirs alone (tools/gen-flags.ts). +import { confirm, type Ctx, declined } from './ctx'; + +/** The flag that lets a write command change the price of an existing product. `flow run` never adds it and refuses it in steps. */ +export const ALLOW_PRICE_CHANGES = 'allow-price-changes'; + +/** A held price change of one existing product: per region / territory, current -> wanted. */ +export interface HeldPrice { id: string; changes: { region: string; from: string; to: string }[] } + +/** + * Price changes of existing (live) products are the owner's call. `--yes` (also the one `flow run --apply --yes` adds + * to write steps) confirms creates, first prices and other updates, never these. They go with --allow-price-changes, + * or when the owner answers the question in a terminal (no --yes), or as a desktop owner action. Nothing to change, + * true. When false the command applies everything else and ends with holdPriceChanges(). + */ +export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise { + if (!count || ctx.args.has('allow-price-changes')) return true; + const yes = ctx.args.has('yes'); + if (process.env.STOREBOUND_SERVE === '1' || (process.stdin.isTTY && !yes)) return confirm(withoutYes(ctx), question); + return false; +} + +/** The end of a run that left price changes alone: one line per product, then declined (exit DECLINED_EXIT), so a flow stops there. */ +export function holdPriceChanges(held: HeldPrice[]): void { + if (!held.length) return; + for (const h of held) console.error(`price change on ${h.id} (${priceDiff(h.changes)}) needs the owner: re-run with --${ALLOW_PRICE_CHANGES}`); + declined(`${held.length} price change(s) of existing products need the owner (re-run with --${ALLOW_PRICE_CHANGES}); everything else was applied`); +} + +/** "US USD 2.99 -> USD 3.99, DE ..." (at most 4 regions, then "+N more"). Pure. */ +export function priceDiff(changes: HeldPrice['changes']): string { + const shown = changes.slice(0, 4).map(c => `${c.region} ${c.from} -> ${c.to}`).join(', '); + return changes.length > 4 ? `${shown}, +${changes.length - 4} more` : shown; +} + +/** The same context with --yes hidden: for questions --yes must not answer. */ +export function withoutYes(ctx: Ctx): Ctx { + return { ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }; +} diff --git a/cli/lib/scan/config-edit.ts b/cli/lib/scan/config-edit.ts index 4eb064d..60a72e2 100644 --- a/cli/lib/scan/config-edit.ts +++ b/cli/lib/scan/config-edit.ts @@ -60,7 +60,7 @@ export function upsertBlock(text: string, file: string, key: string, value: unkn return `${text.slice(0, m.index)}${ind}${key}: ${toTs(value, ind)}${text.slice(close + 1)}`; } const app = /^([ \t]*)app\s*:\s*\{/m.exec(text); - if (!app) throw new Error(`${file}: no app block found to insert ${key} after; add it by hand`); + if (!app) throw new Error(`${file}: no app block found to insert ${key} after; add it to the config file directly`); const ind = app[1]!; const close = matchBrace(text, app.index + app[0].length - 1); if (close < 0) throw new Error(`${file}: could not find the end of the app block`); diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index 7cb15e1..1592f4a 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -468,7 +468,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] { if (apple) add({ rule: 'privacy-labels', guideline: '5.1.2', store: 'apple', severity: 'medium', title: `App Privacy likely misses "${g.apple}": an SDK collects it, the config does not declare it`, evidence: g.evidence.slice(0, 2), fix: 'Run `storebound scan` and read the declarations proposal; confirm with the SDK\'s disclosure, then add the type to dataSafety (`storebound scan --write`) and to App Privacy in App Store Connect.', skill: 'store-declarations' }); if (play) add({ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: `Data safety likely misses ${g.type}: an SDK collects it, dataSafety does not declare it`, evidence: g.evidence.slice(0, 2), - fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or by hand), then `storebound play data-safety`.', skill: 'store-declarations', ref: `${PLAY}10787469` }); + fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: `${PLAY}10787469` }); } if (apple && iosProject && p.stacks.length === 1 && p.stacks[0] === 'ios-native') { for (const perm of IOS_PERMISSIONS) { diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index cae7161..1b8101a 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -156,10 +156,10 @@ export const TOOLS: ToolDef[] = [ description: 'Replace icon, feature graphic and phone screenshots per Play language from the renders folder (or an A/B variant folder); optional 7" / 10" tablet, TV and Wear sets only when rendered (play.tablets). A missing optional set never deletes what Play has (--prune is an owner terminal run). Slow (minutes): prefer the terminal with a log for many languages.', props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] }, { name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true, - description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price, region or activation changes of existing products are the owner\'s (terminal confirmation): here they are skipped with a warning.', + description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price or region changes of existing products are the owner\'s (a terminal run with --allow-price-changes): here everything else is written and each held change is named ("price change on ... needs the owner", exit 3); activating an existing product needs a terminal confirmation.', props: { only }, args: i => opt('only', i.only) }, { name: 'play_subscriptions', title: 'Sync Play subscriptions', command: ['play', 'subscriptions'], write: true, network: true, idempotent: true, - description: 'Auto-renewable subscriptions on Google Play from the config: listings per language (title, description, benefits), one auto-renewing base plan with regional prices (convertRegionPrices + overrides + optional prices CSV), activation, and the introductory offer for new subscribers. dry_run prints the plan. Without a terminal it creates only what is new; price changes of existing base plans are an owner terminal run.', + description: 'Auto-renewable subscriptions on Google Play from the config: listings per language (title, description, benefits), one auto-renewing base plan with regional prices (convertRegionPrices + overrides + optional prices CSV), activation, and the introductory offer for new subscribers. dry_run prints the plan. Without a terminal it writes what is new; price changes of existing base plans are the owner\'s (a terminal run with --allow-price-changes): each is named ("price change on ... needs the owner", exit 3).', props: { only, csv: { type: 'string', description: 'Prices CSV with rows for subscription ids' } }, args: i => [...opt('only', i.only), ...opt('csv', i.csv)] }, { name: 'play_data_safety', title: 'Push Play Data safety', command: ['play', 'data-safety'], write: true, destructive: true, network: true, idempotent: true, @@ -176,7 +176,7 @@ export const TOOLS: ToolDef[] = [ description: 'Create missing in-app purchases, localizations, price schedules and availability. Never submits.', props: { only }, args: i => opt('only', i.only) }, { name: 'apple_subscriptions', title: 'Sync App Store subscriptions', command: ['apple', 'subscriptions'], write: true, network: true, idempotent: true, - description: 'Auto-renewable subscriptions from the config: subscription groups and display names, subscriptions (period, level), localizations, availability, prices (USA price point + equalizations + overrides, optional prices CSV), introductory offers, review screenshot. dry_run prints the plan. Without a terminal it creates only what is new; price changes of existing subscriptions are an owner terminal run. Never submits.', + description: 'Auto-renewable subscriptions from the config: subscription groups and display names, subscriptions (period, level), localizations, availability, prices (USA price point + equalizations + overrides, optional prices CSV), introductory offers, review screenshot. dry_run prints the plan. Without a terminal it writes what is new; price changes of existing subscriptions are the owner\'s (a terminal run with --allow-price-changes): each is named ("price change on ... needs the owner", exit 3). Never submits.', props: { only, csv: { type: 'string', description: 'Prices CSV (product, store, region, currency, price, start_date) with rows for subscription ids' } }, args: i => [...opt('only', i.only), ...opt('csv', i.csv)] }, { name: 'apple_iap_review', title: 'IAP review screenshots', command: ['apple', 'iap-review'], write: true, destructive: true, network: true, idempotent: true, diff --git a/cli/play/products.ts b/cli/play/products.ts index 9f8daf7..359e7b1 100644 --- a/cli/play/products.ts +++ b/cli/play/products.ts @@ -1,13 +1,16 @@ // In-app products on Google Play through monetization.onetimeproducts (the legacy inappproducts API answers 403 // "Please migrate to the new publishing API" for apps on the new model). -// storebound play products [--dry] [--only id1,id2] +// storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes] // Per product: convertRegionPrices (USD base -> every region at local price points, plus regionsVersion), your // explicit overrides on top, PATCH ...?allowMissing=true (create or update), then activate the purchase option // (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent. -// New products and listing text changes are written directly. Changes to what an EXISTING product costs or where it -// is sold (regional prices, the price for other regions, dropped regions, activating its purchase option) are shown -// as a diff first and need a confirmation: a terminal question (or --yes), or under the desktop app an owner action -// confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go. +// New products (with their first prices) and listing text changes are written directly. Activating the purchase +// option of an EXISTING product needs a confirmation (a terminal question or --yes). Changing what an existing product +// costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call: +// --yes never confirms it (flows add --yes); it needs --allow-price-changes, the owner's answer in a terminal (no +// --yes) or the desktop owner action. Without that the run writes everything else, prints one "price change on +// ( -> ) needs the owner" line per product and exits 3 (declined), so a flow stops there. The dry run marks +// those rows "owner". (lib/price-gate.ts) // Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it // when the access ends). Products with `platforms` that leave out android are skipped. // --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product @@ -17,6 +20,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { dryPlan } from '../lib/dry'; import { done, log, step, warn } from '../lib/log'; +import { allowPriceChanges, type HeldPrice, holdPriceChanges } from '../lib/price-gate'; import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; import { playClient } from './client'; @@ -38,7 +42,13 @@ export interface Existing { const money = (m?: PlayMoney): string => (m ? `${m.currencyCode} ${fromMoney(m).toFixed(2)}` : '-'); -export interface ProductChange { text: string; /** price / region / activation change of an existing product: asks first */ owner: boolean } +export interface ProductChange { + text: string; + /** price or region change of an existing product: the owner's call (--allow-price-changes), never --yes */ + owner: boolean; + /** owner rows: per region, current -> wanted */ + prices?: HeldPrice['changes']; +} /** * What a run would change on one product, from the product Play has now (oneTimeProducts list) and the body the run @@ -49,7 +59,7 @@ export function productChanges(cur: Existing | undefined, want: { listings: { la if (!cur) return [{ text: 'create', owner: false }]; const out: ProductChange[] = []; const text = (t: string) => out.push({ text: t, owner: false }); - const owner = (t: string) => out.push({ text: t, owner: true }); + const owner = (t: string, prices: HeldPrice['changes']) => out.push({ text: t, owner: true, prices }); const have = new Map((cur.listings ?? []).map(l => [l.languageCode, l])); const added = want.listings.filter(l => !have.has(l.languageCode)).map(l => l.languageCode); const changed = want.listings.filter(l => { const h = have.get(l.languageCode); return h && ((h.title ?? '') !== l.title || (h.description ?? '') !== l.description); }).map(l => l.languageCode); @@ -61,15 +71,17 @@ export function productChanges(cur: Existing | undefined, want: { listings: { la const now = new Map((opt?.regionalPricingAndAvailabilityConfigs ?? []).map(r => [r.regionCode, r])); const prices = want.regions.flatMap(r => { const h = now.get(r.regionCode)?.price; - return h && h.currencyCode === r.price.currencyCode && Math.round(fromMoney(h) * 100) === Math.round(fromMoney(r.price) * 100) ? [] : [`${r.regionCode} ${money(h)} -> ${money(r.price)}`]; + return h && h.currencyCode === r.price.currencyCode && Math.round(fromMoney(h) * 100) === Math.round(fromMoney(r.price) * 100) ? [] : [{ region: r.regionCode, from: money(h), to: money(r.price) }]; }); const gone = [...now.keys()].filter(c => !want.regions.some(r => r.regionCode === c)); - if (prices.length) owner(`prices in ${prices.length} region(s): ${prices.slice(0, 6).join(', ')}${prices.length > 6 ? ', ...' : ''}`); - if (gone.length) owner(`regions dropped: ${gone.join(', ')}`); + if (prices.length) owner(`prices in ${prices.length} region(s): ${prices.slice(0, 6).map(x => `${x.region} ${x.from} -> ${x.to}`).join(', ')}${prices.length > 6 ? ', ...' : ''}`, prices); + if (gone.length) owner(`regions dropped: ${gone.join(', ')}`, gone.map(c => ({ region: c, from: money(now.get(c)?.price), to: 'not sold' }))); const nr = opt?.newRegionsConfig; - if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice)) - owner(`other regions ${money(nr?.usdPrice)} / ${money(nr?.eurPrice)} -> ${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`); - if (opt?.state !== 'ACTIVE') owner(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`); + if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice)) { + const from = `${money(nr?.usdPrice)} / ${money(nr?.eurPrice)}`, to = `${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`; + owner(`other regions ${from} -> ${to}`, [{ region: 'other regions', from, to }]); + } + if (opt?.state !== 'ACTIVE') text(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`); return out; } @@ -123,34 +135,41 @@ export async function run(ctx: Ctx): Promise { if (dry) { console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' } : !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` } - : { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner confirms)' : 'update', detail: x.changes.map(c => c.text).join('; ') }))); + : { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') }))); const absent = plan.filter(x => !x.cur).map(x => x.p.id); if (absent.length) log('play', `not on Google Play: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`); done('play', 'products checked (dry run, nothing written)'); return; } - // prices, regions and activation of existing products: the owner confirms the diff above first - const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner)); - const ok = !gated.length || await confirm(ctx, `Change prices / regions / activation of ${gated.length} existing product(s) on Google Play (${gated.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`); - if (!ok) warn('play', `price / region / activation changes of ${gated.length} existing product(s) skipped: confirm in a terminal (or the desktop owner action)`); + // prices and regions of existing products: the owner's call (never --yes); activation of an existing one: confirmed + const priced = plan.filter(x => x.cur && x.changes.some(c => c.owner)); + const priceOk = await allowPriceChanges(ctx, priced.length, `Change prices / regions of ${priced.length} existing product(s) on Google Play (${priced.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`); + const optionOf = (x: (typeof plan)[number]) => x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION); + // an existing product without the option gets it with the prices, so it waits for them + const activate = plan.filter(x => x.cur && optionOf(x)?.state !== 'ACTIVE' && (optionOf(x) || priceOk)); + const activateOk = !activate.length || await confirm(ctx, `Activate the purchase option of ${activate.length} existing product(s) on Google Play (${activate.map(x => x.p.id).join(', ')})? Buyers can buy them as soon as the app is published.`); + if (!activateOk) warn('play', `activation of ${activate.length} existing product(s) skipped: confirm with --yes or in a terminal`); + const held: HeldPrice[] = []; for (const x of plan) { if (!x.changes.length) continue; const id = encodeURIComponent(x.p.id); const v = encodeURIComponent(x.version); const ownerChange = !!x.cur && x.changes.some(c => c.owner); - const listingChange = x.changes.some(c => !c.owner); - if (x.cur && ownerChange && !ok) { - if (!listingChange) continue; - await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body); - log('play', `${x.tag}: listings updated (prices unchanged)`); - continue; + if (x.cur && ownerChange && !priceOk) { + held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) }); + if (x.changes.some(c => c.text.startsWith('listings '))) { + await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body); + log('play', `${x.tag}: listings updated (prices unchanged)`); + } + } else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) { + const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; + await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true®ionsVersion.version=${v}&updateMask=${mask}`, x.body); + log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); } - const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; - await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true®ionsVersion.version=${v}&updateMask=${mask}`, x.body); - log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); - if (x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION)?.state === 'ACTIVE') continue; + // a new product's option starts as DRAFT; an existing one is activated after the confirmation + if (optionOf(x)?.state === 'ACTIVE' || (x.cur && (!activateOk || !activate.includes(x)))) continue; try { await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, { requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }], @@ -160,5 +179,6 @@ export async function run(ctx: Ctx): Promise { } const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id); if (created.length) reportMissing('play', 'new products', created, (await listOneTimeProducts(play)).map(p => p.productId), { fail: false }); - done('play', 'products done'); + holdPriceChanges(held); + done('play', held.length ? 'products done, price changes left for the owner' : 'products done'); } diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index 66e9f7d..e25dbd8 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -5,15 +5,19 @@ // the subscription, activated, plus `offers` (no acquisition targeting, offer tag = id: your app decides who sees // them). Play has no subscription groups (an Apple concept) and no API for promo codes (Play Console > Monetize > // Promo codes). Idempotent: compares first. -// storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] -// Prints the plan first. In a terminal it asks before writing. Without a terminal (MCP, CI) and without --yes it -// creates what is new only and leaves price changes of existing base plans to a terminal run. A base plan price -// change applies to new subscribers; existing ones keep their price until you run a price migration in Play Console. +// storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes] +// Prints the plan first. In a terminal (no --yes) it asks before writing, price changes included. With --yes or +// without a terminal it writes everything new (subscriptions with their first prices, listings, base plans, offers) +// but never changes the prices of an existing base plan: that is the owner's call and needs --allow-price-changes +// (flows never add it). The run then prints "price change on ( -> ) needs the owner" and exits 3 +// (declined). A base plan price change applies to new subscribers; existing ones keep their price until you run a +// price migration in Play Console. // Pure parts and sources: lib/subscriptions.ts. import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; +import { allowPriceChanges, type HeldPrice, holdPriceChanges } from '../lib/price-gate'; import { type Money, toMoney } from '../lib/money'; import { allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, @@ -72,6 +76,7 @@ export async function run(ctx: Ctx): Promise { : [diff.listings.length ? `listings ${diff.listings.join(', ')}` : '', diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '', diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date'; log(TAG, `${step(i + 1, subs.length, s.id)}: ${what}${offerMissing ? ` · intro offer ${offerId(s)} (${s.intro!.type})` : ''}${extraMissing.length ? ` · offers ${extraMissing.join(', ')}` : ''}`); + if (diff.priceChanges.length) log(TAG, ' owner: price change of an existing base plan (--allow-price-changes)'); for (const p of diff.priceChanges.slice(0, 5)) log(TAG, ` ${p.regionCode}: ${p.from} -> ${p.to}`); if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`); work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing }); @@ -81,11 +86,16 @@ export async function run(ctx: Ctx): Promise { if (!todo.length) { done(TAG, 'subscriptions up to date'); return; } const yes = ctx.args.has('yes'); - let liveOk = yes; - if (process.stdin.isTTY && !yes) { + const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length); + let liveOk: boolean; + if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return; liveOk = true; - } else if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing base plans wait for a terminal run'); + } else { + liveOk = await allowPriceChanges(ctx, priced.length, `Change the base plan prices of ${priced.length} existing subscription(s) on Google Play (${priced.map(w => w.s.id).join(', ')})? New subscribers pay the new price.`); + if (!yes && !liveOk) log(TAG, 'not a terminal: writing what is new; price changes of existing base plans wait for the owner'); + } + const held: HeldPrice[] = []; for (const { s, body, diff, version, offerMissing, extraMissing } of todo) { const v = encodeURIComponent(version); @@ -99,8 +109,14 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`); if (!r.created) continue; } else if (diff.listings.length || diff.basePlan !== 'same') { - if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) warn(TAG, `${s.id}: ${diff.priceChanges.length} price change(s) skipped (run in a terminal to confirm)`); - else { + if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) { + held.push({ id: s.id, changes: diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }); + if (diff.listings.length) { + // the listings only; the base plans as Play has them + await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings®ionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body), basePlans: cur!.basePlans?.map(({ state: _s, ...b }) => b) }); + log(TAG, `${s.id}: listings updated (prices unchanged)`); + } + } else { await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body)); log(TAG, `${s.id}: updated`); } @@ -129,5 +145,6 @@ export async function run(ctx: Ctx): Promise { if (s.intro && offerMissing) await createOffer(s.intro); for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o); } - done(TAG, 'subscriptions done'); + holdPriceChanges(held); + done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done'); } diff --git a/cli/prices.ts b/cli/prices.ts index 2460bf4..09f6b31 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -3,12 +3,16 @@ // one-time products and (unless --no-subscriptions) auto-renewable subscriptions: Apple's current and scheduled // subscription prices per territory, Play's regional prices of the base plan. Subscription rows use the same // columns; `apple subscriptions --csv` / `play subscriptions --csv` read them back (prices import skips them). -// storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes] +// storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes] [--allow-price-changes] // storebound prices ppp [--base ] [...] purchasing power price sheet (prices-ppp.ts) // CSV columns: product, store (apple | play | both), region (ISO alpha-2), currency, price, start_date (App Store // only, a future YYYY-MM-DD = scheduled change), kind (manual | auto; auto rows are Apple's equalized prices, exported // for reference and ignored on import). -// import: reads the current prices, prints the diff table, and (not --dry) asks before writing. Apple: one new price +// import: reads the current prices, prints the diff table, and (not --dry) asks before writing. A product that already +// has prices on a store is a price change: the owner's call. In a terminal (no --yes) the one question covers it; +// --yes (also from `flow run --apply --yes`) only sets first prices and never changes existing ones, which need +// --allow-price-changes: without it the run writes the first prices, prints "price change on ( -> ) +// needs the owner" per product and exits 3 (declined). The dry run marks those pairs "owner". Apple: one new price // schedule per changed product (base territory + every manual price; territories without one are equalized by Apple; // an increase cannot be undone once it starts). Play: the purchase option's regional prices are replaced: CSV regions // on top of the current ones; regions without a price get convertRegionPrices from the USD base (CSV US row, else the @@ -18,6 +22,7 @@ import { ascClient, type Asc, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { confirm, need } from './lib/ctx'; import { done, log, printJson, step, warn } from './lib/log'; +import { allowPriceChanges, type HeldPrice, holdPriceChanges } from './lib/price-gate'; import { toMoney } from './lib/money'; import { appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions, @@ -143,8 +148,26 @@ export async function run(ctx: Ctx): Promise { console.log(diffTable(diff, { all: a.has('all') })); if (!changed.size) { done(TAG, 'prices up to date'); return; } log(TAG, `${changed.size} product / store pair(s) to update`); + // a pair that has prices on the store already is a price change of a live product: the owner's call + const live = [...changed].filter(k => current.some(r => `${r.product}|${r.store}` === k && r.kind !== 'auto')); + const first = [...changed].filter(k => !live.includes(k)); + if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`); if (dry) { done(TAG, 'dry run: nothing written'); return; } - if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return; + let liveOk: boolean; + if (process.stdin.isTTY && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { + if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return; + liveOk = true; + } else { + if (first.length && !(await confirm(ctx, `Set the first prices of ${first.length} product / store pair(s) (${first.map(k => k.replace('|', ' ')).join(', ')})?`))) return; + liveOk = await allowPriceChanges(ctx, live.length, `Change the prices of ${live.length} existing product / store pair(s) (${live.map(k => k.replace('|', ' ')).join(', ')})? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`); + } + const held: HeldPrice[] = []; + if (!liveOk) for (const k of live) { + changed.delete(k); + const rows = diff.filter(d => `${d.product}|${d.store}` === k && d.change !== 'same'); + held.push({ id: k.replace('|', ' '), changes: rows.length ? rows.map(d => ({ region: d.region, from: d.from === null ? '-' : `${d.currency} ${d.from}`, to: `${d.currency} ${d.to}${d.startDate ? ` from ${d.startDate}` : ''}` })) + : [{ region: 'every region', from: 'current', to: 'reconverted' }] }); + } for (const p of products) { const wanted = wantedAll.filter(r => r.product === p.id); @@ -180,5 +203,6 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${p.id} play: ${regions.length} regions set`); } } - done(TAG, 'prices imported (storebound prices export shows the stores\' view)'); + holdPriceChanges(held); + done(TAG, held.length ? `${first.length ? 'first prices imported' : 'nothing written'}, price changes left for the owner` : 'prices imported (storebound prices export shows the stores\' view)'); } diff --git a/docs/research/fastlane-revenuecat.md b/docs/research/fastlane-revenuecat.md index 9733ffc..33e124e 100644 --- a/docs/research/fastlane-revenuecat.md +++ b/docs/research/fastlane-revenuecat.md @@ -32,7 +32,7 @@ recommendations are ours. 1. **Store ids.** Parse the simple `key("value")` / `key "value"` lines of `Appfile` and `Deliverfile` with a strict regex. That gives `apple.bundleId`, `play.packageName` and the languages. Ruby logic (conditionals, `ENV[...]`, - lanes) is reported as "set by hand", never guessed. + lanes) is reported as "set it in storebound.config.ts", never guessed. 2. **Languages.** Join the deliver locales and the supply locales into storebound `languages[]` (`apple`, `play` codes). 3. **Listing text.** - Write `store/appstore.md` and `store/listing.md` from the `.txt` files, mapping `default/` into every language diff --git a/skills/migrate-from-fastlane/SKILL.md b/skills/migrate-from-fastlane/SKILL.md index 3046756..0739025 100644 --- a/skills/migrate-from-fastlane/SKILL.md +++ b/skills/migrate-from-fastlane/SKILL.md @@ -50,7 +50,7 @@ every one of `fastlane/`, `ios/fastlane/`, `android/fastlane/` that exists; pass - `price_tier`: products and the prices CSV (`store-iap-setup`). Age rating config: `store-declarations`. Export compliance: `export-compliance` (`apple.usesNonExemptEncryption` stays commented out). - Lanes, `match`, `gym` / `gradle`: building and signing stay where they are; storebound has no lanes. -- Ruby logic (`for_platform`, `for_lane`, `ENV[...]`, interpolation): reported by key and line, set by hand. +- Ruby logic (`for_platform`, `for_lane`, `ENV[...]`, interpolation): reported by key and line; set those in storebound.config.ts. - Promo graphic, TV banner, TV / Wear screenshots, Apple TV privacy policy, Korean trade representative contact: consoles. diff --git a/skills/ppp-pricing/SKILL.md b/skills/ppp-pricing/SKILL.md index 669f68c..a6c359b 100644 --- a/skills/ppp-pricing/SKILL.md +++ b/skills/ppp-pricing/SKILL.md @@ -42,7 +42,8 @@ Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`. `storebound prices ppp` (default `store/prices-ppp.csv`, `--out` to change it). 4. The owner reviews and edits the CSV (delete rows to keep a store's own conversion, round to a house price ladder). 5. Apply through the normal price sheet flow: `storebound prices import --csv store/prices-ppp.csv --dry`, then - without `--dry` in a terminal after the owner says yes (see store-iap-setup). Import checks Apple price points + `--yes --allow-price-changes` after the owner says yes to that diff (see store-iap-setup; `--yes` alone never + changes an existing price). Import checks Apple price points (the error lists the neighbours) and Play's region currencies. ## Read before applying diff --git a/skills/release-flows/SKILL.md b/skills/release-flows/SKILL.md index 1b7c08f..266e326 100644 --- a/skills/release-flows/SKILL.md +++ b/skills/release-flows/SKILL.md @@ -49,8 +49,11 @@ once (the second build step prints "prebuild skipped (already ran in this flow r - `--apply`: writes and shell steps run for real; each write command still asks its own confirmation in the terminal. Never add `--yes` to a step (flow validate refuses it). - `--apply --yes`: how you run it after showing the dry run (do it, don't ask: AGENTS.md first rule): every write - step gets `--yes`; reads, waits and owner steps do not, so nothing owner-only runs. When the dry run shows a live price change - (`play products`, `apple subscriptions` on an existing product), get the owner's yes for it first. Without `--yes` and without a terminal, a confirming command declines: the flow stops there with + step gets `--yes`; reads, waits and owner steps do not, so nothing owner-only runs. `--yes` never changes the price + of an existing product (`play products`, `apple subscriptions`, `play subscriptions`): such a step writes everything + else, names each held change ("price change on ( -> ) needs the owner") and exits 3, so the flow + stops as declined. After the owner's yes for that diff, run that one command with `--allow-price-changes` (refused + in flow steps, never added by `flow run`), then `flow run --apply --yes --resume`. Without `--yes` and without a terminal, a confirming command declines: the flow stops there with "step N declined: not a terminal and no --yes ...", never "ok" (status `declined` in `--json` and the state file). - Owner steps never run in any mode: submit, release, phased release, rollout, sending review replies, price imports, inviting testers, Beta App Review, starting experiments, offer codes (AGENTS.md rule 1). They print as diff --git a/skills/store-iap-setup/SKILL.md b/skills/store-iap-setup/SKILL.md index 4188354..e8d2925 100644 --- a/skills/store-iap-setup/SKILL.md +++ b/skills/store-iap-setup/SKILL.md @@ -30,10 +30,13 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr 2. `storebound play products`. Per product: `pricing:convertRegionPrices` (USD -> every region at local price points, and the `regionsVersion`), your overrides on top, `PATCH .../onetimeproducts/{id}?allowMissing=true®ionsVersion.version=...` (create or update), then `oneTimeProducts/{id}/purchaseOptions:batchUpdateStates` to activate (new options are DRAFT). - New products and listing text go straight through. Price, region or activation changes of an EXISTING product - are the owner's decision: the run shows the diff and asks in a terminal; without a terminal (MCP, CI, the desktop - app's normal write) they are skipped with a warning, the listing text still updates. The owner confirms in a - terminal, or applies prices with the desktop Prices "Apply" (owner action, `prices import`). + New products (with their first prices) and listing text go straight through; `--yes` confirms activating the + purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision + and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those + rows `owner`, and a real run writes everything else, prints `price change on ( -> ) needs the + owner: re-run with --allow-price-changes` and exits 3 (declined; a flow stops there). After the owner's yes in + chat for exactly that diff, run `storebound play products --allow-price-changes`; the owner can also answer the + question in a terminal (no `--yes`) or use the desktop Prices "Apply" (owner action). 3. Verify: `storebound play status` lists every product with `buy:ACTIVE`. 4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing link; purchases are free test orders. @@ -41,7 +44,8 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr ## App Store (API + UI) 1. `storebound apple iap --dry`, then `storebound apple iap`: creates missing products, localizations, a price schedule (USA base + overrides; Apple equalizes the rest) and availability. Existing schedules and availability are never - changed by it; price changes go through `storebound prices import` (diff first, then the owner confirms). + changed by it; price changes go through `storebound prices import` (diff first, then the owner's yes and + `--allow-price-changes`). 2. Review screenshot per product (a real device size, e.g. 1290 x 2796, showing the item in the shop): `storebound apple iap-review`. States move to READY_TO_SUBMIT when complete. 3. **Send them to App Review** (the owner's step): `storebound apple iap-submit --dry` shows the plan: which version @@ -74,7 +78,9 @@ App Store only, a future YYYY-MM-DD). neighbours); Play takes any price in the region's currency. 3. `storebound prices import --dry` (MCP: `prices_diff`) prints the diff table: product, store, region, from, current, new, change %. Show it to the owner. -4. The owner runs `storebound prices import` (asks first; not an MCP tool). Apple: one new price schedule per changed +4. After the owner's yes for that diff: `storebound prices import --yes --allow-price-changes` (not an MCP tool; + `--yes` alone only sets first prices of products that have none and exits 3 naming every held price change). + Apple: one new price schedule per changed product (base territory, default the current one or `prices.baseTerritory`, plus every manual price; territories without one follow Apple's equalization). Play: the purchase option's regional prices are replaced; regions the product has no price for get `convertRegionPrices` from the USD base, `--reconvert` resets every region the CSV diff --git a/skills/store-subscriptions/SKILL.md b/skills/store-subscriptions/SKILL.md index 1ea4e8c..88c223c 100644 --- a/skills/store-subscriptions/SKILL.md +++ b/skills/store-subscriptions/SKILL.md @@ -34,8 +34,10 @@ offers, offerCodes, reviewScreenshot, basePlanId, gracePeriod, platforms }] }]`, billing library on a track. 2. `storebound check` (limits: Apple name 30 / description 45; Play description 200, up to 4 benefits). 3. `storebound apple subscriptions --dry` and `storebound play subscriptions --dry`; show the plan, then run them - yourself (`--yes`). A price change of an existing subscription is a live price: get the owner's yes for it first. - Through MCP (no terminal) they only create what is new; price changes of existing subscriptions wait for the owner. + yourself (`--yes`): new subscriptions, first prices, listings, base plans and offers are written. A price change + of an existing subscription is a live price and `--yes` never applies it: the run writes everything else, prints + `price change on ( -> ) needs the owner: re-run with --allow-price-changes` and exits 3. After the + owner's yes in chat for that diff, run it again with `--yes --allow-price-changes`. Flows never pass that flag. 4. Review screenshot of the paywall per subscription (`reviewScreenshot`), uploaded by `apple subscriptions`. 5. **First subscriptions go with an app version** (and a new group with at least one of its subscriptions): `storebound apple iap-submit --with-version --dry` plans one review submission with the app version, the diff --git a/tests/console-flow.test.ts b/tests/console-flow.test.ts index b2ca8fd..9e1cff5 100644 --- a/tests/console-flow.test.ts +++ b/tests/console-flow.test.ts @@ -397,7 +397,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a expect(said).toMatch(/still waiting for the page after "Next", \d+ s/); expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/); expect(said).toContain(`Debug saved to ${dir}`); - expect(said).toContain('do this part yourself'); + expect(said).toContain('this part is left to the calling agent with its browser tool (`storebound console plan apple-app-privacy`'); expect(said).toContain('it stops here'); const [sub] = readdirSync(dir); const files = readdirSync(join(dir, sub!)).sort(); diff --git a/tests/do-it-dont-ask.test.ts b/tests/do-it-dont-ask.test.ts index 32d73ed..09bf471 100644 --- a/tests/do-it-dont-ask.test.ts +++ b/tests/do-it-dont-ask.test.ts @@ -66,4 +66,36 @@ describe("first rule: do it, don't ask", () => { 'press Create app yourself', 'Granting them is the owner\'s (a permission grant)']; for (const o of ok) expect(HANDOFF.some(re => re.test(o))).toBe(false); }); + + // The CLI's own output: when storebound's scripted part cannot go on, what is left goes to the calling agent (its + // browser tool with `console plan` / `console verify`, or an edit of the config), not "do it yourself" to the owner. + // Sign-in, the final Submit / Create / Publish and agreements stay the owner's. + const CLI_HANDOFF: RegExp[] = [ + /\b(open|fill|answer|check|do|merge|add|set)\b[^.\n]{0,40}\b(yourself|by hand)\b/i, + /\bdo (this|that) part\b/i, + /\bto check yourself\b/i, + ]; + const cliFiles = (dir: string): string[] => readdirSync(join(root, dir), { withFileTypes: true }).flatMap(e => + e.isDirectory() ? (e.name === 'data' ? [] : cliFiles(`${dir}/${e.name}`)) : e.name.endsWith('.ts') ? [`${dir}/${e.name}`] : []); + + test('CLI output never hands agent work to the owner', () => { + const hits: string[] = []; + for (const f of cliFiles('cli')) { + read(f).split('\n').forEach((line, i) => { + const t = line.trim(); + if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // comments are for the reader of the code + for (const re of CLI_HANDOFF) if (re.test(line)) hits.push(`${f}:${i + 1}: ${t.slice(0, 140)}`); + }); + } + expect(hits).toEqual([]); + }); + + test('the CLI patterns catch the old console fill lines and keep the owner-only ones', () => { + for (const b of ['Open it yourself; storebound keeps watching', 'fill them yourself', 'do this part yourself; storebound keeps watching', + '3 answer(s) to check yourself.', 'not mapped (do these by hand)']) + expect([b, CLI_HANDOFF.some(re => re.test(b))]).toEqual([b, true]); + for (const o of ['review every field and press Submit yourself', 'sign in to App Store Connect yourself (Apple ID, password, two-factor code)', + 'Read and tick them yourself, then press Create app.', 'storebound console plan [--json] [--lookup] (then drive the page yourself']) + expect([o, CLI_HANDOFF.some(re => re.test(o))]).toEqual([o, false]); + }); }); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index 9691f72..b5867c5 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -420,7 +420,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri test('prices import (Play): changed regions written once, then the diff is empty', async () => { const p = project({ ...CONFIG, products: [CONFIG.products![0]!] }, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' }); await capture(() => playProducts(p.ctx([]))); - const r = await twice(() => prices(p.ctx(['import', '--store', 'play', '--yes']))); + const r = await twice(() => prices(p.ctx(['import', '--store', 'play', '--yes', '--allow-price-changes']))); expect(r.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true); expect(r.second).toEqual([]); }); diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index 29ab182..9159923 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -353,13 +353,15 @@ describe('play products --dry: what would change on an existing product', () => const w = { ...want, listings: [{ ...want.listings[0]!, title: 'Remove Ads' }], regions: [want.regions[0]!, region('TR', 'TRY', 99.99)] }; expect(productChanges(CUR, w)).toEqual([ { text: 'listings changed: en-US', owner: false }, { text: 'listings removed: tr-TR', owner: false }, - { text: 'prices in 1 region(s): TR TRY 129.99 -> TRY 99.99', owner: true }, { text: 'regions dropped: JP', owner: true }]); + { text: 'prices in 1 region(s): TR TRY 129.99 -> TRY 99.99', owner: true, prices: [{ region: 'TR', from: 'TRY 129.99', to: 'TRY 99.99' }] }, + { text: 'regions dropped: JP', owner: true, prices: [{ region: 'JP', from: 'JPY 450.00', to: 'not sold' }] }]); + // activation is not a price change: --yes confirms it const inactive = { ...CUR, purchaseOptions: [{ ...CUR.purchaseOptions![0]!, state: 'INACTIVE' }] }; - expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: true }]); + expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: false }]); }); }); -describe('play products: price, region and activation changes of existing products ask first', () => { +describe('play products: price and region changes of existing products are the owner\'s (--allow-price-changes, never --yes)', () => { // the existing product as listed (2 regions), and what convertRegionPrices answers for USD 2.99 const LISTED = { productId: 'com.example.game.no_ads', listings: [{ languageCode: 'en-US', title: 'No Ads', description: 'Removes ads.' }], @@ -389,26 +391,42 @@ describe('play products: price, region and activation changes of existing produc const out: string[] = []; const [l, w, e] = [console.log, console.warn, console.error]; console.log = console.warn = console.error = (...a: unknown[]) => { out.push(a.join(' ')); }; - try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; } + const before = process.exitCode; + process.exitCode = 0; + let exit = 0; + try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; exit = Number(process.exitCode ?? 0); process.exitCode = before; } const writes = calls.filter(c => c.method !== 'GET' && !c.url.endsWith(':convertRegionPrices')).map(c => `${c.method} ${c.url.replace(API, '').replace(/regionsVersion\.version=[^&]+&?/, '')}`); - return { writes, out: out.join('\n') }; + return { writes, out: out.join('\n'), exit }; } - test('without a terminal: an existing product\'s price change is skipped, its listing change and new products still go', async () => { - const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } }); - expect(r.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99'); - expect(r.writes).toEqual([ - 'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings', - 'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions', - 'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates', - ]); - expect(r.out).toContain('skipped: confirm in a terminal'); + test('flow-style run (--yes, no terminal): the listing change and new products go, the price stays, exit 3 naming the change', async () => { + for (const argv of [[], ['--yes']]) { + const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } }, argv); + expect(r.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99'); + expect(r.writes).toEqual([ + 'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings', + 'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions', + 'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates', + ]); + expect(r.out).toContain('price change on com.example.game.no_ads (TR TRY 129.99 -> TRY 99.99) needs the owner: re-run with --allow-price-changes'); + expect(r.out).toContain('declined: 1 price change(s) of existing products need the owner'); + expect(r.exit).toBe(3); + } }); - test('a price-only change is not written at all without a confirmation; --yes confirms in a terminal-less run', async () => { + test('a price-only change: never written with --yes; --allow-price-changes writes it (exit 0)', async () => { const over = { overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }; expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]); - expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']); + expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]); + const ok = await runWith(over, ['--yes', '--allow-price-changes']); + expect(ok.writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']); + expect([ok.exit, ok.out.includes('needs the owner')]).toEqual([0, false]); + }); + + test('the dry run marks the price row "owner"', async () => { + const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }, ['--dry']); + expect(r.out).toMatch(/com\.example\.game\.no_ads\s+update \(owner: price change, --allow-price-changes\)/); + expect(r.writes).toEqual([]); }); test('under serve: refused even with --yes; a verified desktop owner confirmation lets it through', async () => { diff --git a/tests/price-gate.test.ts b/tests/price-gate.test.ts new file mode 100644 index 0000000..2237366 --- /dev/null +++ b/tests/price-gate.test.ts @@ -0,0 +1,94 @@ +// Price changes of existing products are the owner's call (lib/price-gate.ts): `--yes`, which `flow run --apply --yes` +// adds to write steps, confirms creates, first prices and other updates, never a price change. Without +// --allow-price-changes the command writes everything else, names each held change and exits 3 (declined), so a flow +// stops there. Runs against the stateful store doubles (tests/helpers/fake-stores.ts). +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import type { ShipConfig } from '../cli/lib/config'; +import { DECLINED_EXIT } from '../cli/lib/ctx'; +import { planStep, toFlow, validateFlows } from '../cli/lib/flow'; +import { priceDiff } from '../cli/lib/price-gate'; +import { run as appleSubscriptions } from '../cli/apple/subscriptions'; +import { run as playProducts } from '../cli/play/products'; +import { run as playSubscriptions } from '../cli/play/subscriptions'; +import { run as prices } from '../cli/prices'; +import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; +import { upsertTiming } from '../cli/lib/upsert'; + +let s: Stores; +beforeAll(() => { ascEnv(); playEnv(); }); +afterAll(restore); +beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); +afterEach(() => { uninstallStores(); }); + +const MONTHLY = 'com.example.notes.monthly'; +const sub = (usd: number, title = 'Monthly'): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [ + { id: MONTHLY, period: 'P1M', level: 1, usd, listings: { en: { title, description: 'Pro every month.', benefits: ['Sync'] } } }, +] } as never] }); + +async function ok(run: () => Promise): Promise { + const r = await capture(run); + if (r.exit) throw new Error(`exit ${r.exit}:\n${r.out}\n${r.err}`); +} + +describe('flow-style runs (--yes, no terminal) never change a live price', () => { + test('play subscriptions: the listing change goes, the base plan price stays, exit 3; --allow-price-changes applies it', async () => { + await ok(() => playSubscriptions(project(sub(4.99)).ctx(['--yes']))); + const usPrice = () => JSON.stringify((s.play.subs.get(MONTHLY)!.basePlans as { regionalConfigs: { regionCode: string; price: unknown }[] }[])[0]!.regionalConfigs.find(r => r.regionCode === 'US')!.price); + const before = usPrice(); + const p = project(sub(9.99, 'Monthly Pro')); + const r = await capture(() => playSubscriptions(p.ctx(['--yes']))); + expect(r.exit).toBe(DECLINED_EXIT); + expect(r.err).toContain(`price change on ${MONTHLY} (`); + expect(r.err).toContain('needs the owner: re-run with --allow-price-changes'); + expect(usPrice()).toBe(before); + expect((s.play.subs.get(MONTHLY)!.listings as { title: string }[])[0]!.title).toBe('Monthly Pro'); + const r2 = await capture(() => playSubscriptions(p.ctx(['--yes', '--allow-price-changes']))); + expect([r2.exit, r2.err.includes('needs the owner')]).toEqual([null, false]); + expect(usPrice()).not.toBe(before); + }); + + test('apple subscriptions: first prices go with --yes; a changed price is held (exit 3), then applied with --allow-price-changes', async () => { + await ok(() => appleSubscriptions(project(sub(4.99)).ctx(['--yes']))); + const n = s.asc.all('subscriptionPrices').length; + expect(n).toBeGreaterThan(0); + const p = project(sub(9.99)); + const r = await capture(() => appleSubscriptions(p.ctx(['--yes']))); + expect(r.exit).toBe(DECLINED_EXIT); + expect(r.err).toContain(`price change on ${MONTHLY} (USA 4.99 -> 9.99`); + expect(s.asc.all('subscriptionPrices').length).toBe(n); + await ok(() => appleSubscriptions(p.ctx(['--yes', '--allow-price-changes']))); + expect(s.asc.all('subscriptionPrices').length).toBeGreaterThan(n); + }); + + test('prices import: an existing Play product\'s price is held with --yes (exit 3, nothing written), written with --allow-price-changes', async () => { + const cfg: ShipConfig = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], + products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } }] }; + const p = project(cfg, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' }); + await ok(() => playProducts(p.ctx([]))); + const dry = await capture(() => prices(p.ctx(['import', '--store', 'play', '--dry']))); + expect(dry.out + dry.err).toContain('owner: price change of 1 existing product / store pair(s) (--allow-price-changes): com.example.notes.pro play'); + const patches = () => s.calls.filter(c => c.method === 'PATCH').length; + const k = patches(); + const r = await capture(() => prices(p.ctx(['import', '--store', 'play', '--yes']))); + expect(r.exit).toBe(DECLINED_EXIT); + expect(r.err).toContain('price change on com.example.notes.pro play (DE EUR 2.99 -> EUR 3.49) needs the owner: re-run with --allow-price-changes'); + expect(patches()).toBe(k); + await ok(() => prices(p.ctx(['import', '--store', 'play', '--yes', '--allow-price-changes']))); + expect(patches()).toBe(k + 1); + }); +}); + +describe('flows never pass --allow-price-changes', () => { + test('refused in a step; flow run --apply --yes adds only --yes', () => { + const f = toFlow('p', 'p.yml', { steps: [{ id: 'a', run: 'play products --allow-price-changes' }] }).flow!; + expect(validateFlows([f]).join('\n')).toContain('--allow-price-changes is not allowed in a flow'); + const step = planStep({ id: 'b', run: 'play products' }, 'steps', 'apply', {}, { apple: true, play: true, exists: () => false }, {}, true); + expect(step.argv).toEqual(['play', 'products', '--yes']); + }); + + test('the held line names region, current and wanted price', () => { + expect(priceDiff([{ region: 'TR', from: 'TRY 129.99', to: 'TRY 99.99' }])).toBe('TR TRY 129.99 -> TRY 99.99'); + expect(priceDiff(['A', 'B', 'C', 'D', 'E', 'F'].map(region => ({ region, from: '1', to: '2' })))).toBe('A 1 -> 2, B 1 -> 2, C 1 -> 2, D 1 -> 2, +2 more'); + }); +}); diff --git a/tools/gen-flags.ts b/tools/gen-flags.ts index ab9d3e2..c32c12e 100644 --- a/tools/gen-flags.ts +++ b/tools/gen-flags.ts @@ -23,7 +23,7 @@ const DYNAMIC: Record = { 'play/rollout.ts': ['halt', 'resume', 'complete'], 'play/release.ts': ['internal', 'alpha', 'beta', 'production'], 'ads/apple.ts': ['adgroup', 'campaign', 'keyword'], - 'apple/subscriptions.ts': [], // a wrapper of ctx.args.has(n) (confirm without --yes) + 'lib/price-gate.ts': [], // withoutYes: a wrapper of ctx.args.has(n) (confirm without --yes) 'lib/declarations.ts': [], // a.get(k) on a Map, not the args 'lib/catalog.ts': [], // a.get(w.id) on a Map, not the args };