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

This commit is contained in:
CRTkafa 2026-10-12 09:38:56 +08:00
parent b5b8f2da9d
commit e01a27d7b7
33 changed files with 414 additions and 117 deletions

View file

@ -2,6 +2,19 @@
## Unreleased ## 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 <id> (<from> -> <to>) 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
<form>` / `console verify <form>`), 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, - 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 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, console-autofill, play-first-release, appstore-first-release and store-declarations. The agent does API calls,

View file

@ -218,7 +218,7 @@ export async function run(ctx: Ctx): Promise<void> {
writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 }); 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)`); 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')!); 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; cert = created;
} }
for (const id of ids) { for (const id of ids) {

View file

@ -3,13 +3,15 @@
// ones), prices (USA price point + its equalizations, your overrides, optional CSV rows from the prices sheet incl. // 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 // 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. // 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 // 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 // (`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. // 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 // Prints the plan first. In a terminal (no --yes) it asks before writing anything, price changes included. With --yes
// --yes it still creates what is new (nothing is public before App Review), but never changes prices of a // or without a terminal it still creates what is new, first prices included (nothing is public before App Review), but
// subscription that already has them: that waits for the owner in a terminal. preserveCurrentPrice is always true // 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 <id> (<from> -> <to>) 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). // (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). // First subscriptions are submitted with an app version, like first in-app purchases (store-subscriptions skill).
// Pure parts and sources: lib/subscriptions.ts. // 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 type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log'; 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 { parsePriceCsv, type PriceRow } from '../lib/prices';
import { import {
allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges, allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges,
@ -79,11 +82,13 @@ export async function run(ctx: Ctx): Promise<void> {
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; } 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'); const yes = ctx.args.has('yes');
let liveOk = yes; // price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts)
if (process.stdin.isTTY && !yes) { 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; 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; 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 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); 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<void> {
const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; 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)); const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
if (!changes.length) log(TAG, ' prices up to date'); 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 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.`))) {
else { 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 }[] = []; const resolved: { territory: string; pointId: string; startDate?: string | null }[] = [];
for (const ch of changes) { for (const ch of changes) {
let pt = known.find(p => p.territory === ch.territory && Math.round(p.price * 100) === Math.round(ch.to * 100)); 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<void> {
} }
} }
} }
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 /** 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 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)); const todo = oc.customCodes!.filter(cc => !have.has(cc.code));
if (!todo.length) return; 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) { for (const cc of todo) {
// a custom code is unique: a 409 finds the existing one // a custom code is unique: a 409 finds the existing one
await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true, await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true,

View file

@ -142,7 +142,7 @@ async function pull(ctx: Ctx): Promise<void> {
if (!p.changes.length) { done(TAG, 'the config listings match the stores'); return; } 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; } 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 }); 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`); done(TAG, `proposal written: ${file} (${p.changes.length} difference(s)); storebound.config.ts is unchanged`);
} }

View file

@ -781,13 +781,18 @@ export async function resumeAt(page: Page, plan: NavStep[]): Promise<number | nu
return null; return null;
} }
/** Where the calling agent picks up when storebound's own fill cannot: its browser tool, with the plan and the read-back. */
export function agentPart(form: string): string {
return `the calling agent with its browser tool (\`storebound console plan ${form}\` has the steps and answers, \`storebound console verify ${form}\` checks the page)`;
}
function printText(res: ConsoleResult): void { function printText(res: ConsoleResult): void {
console.log(`${res.title}\n${res.url}\n`); console.log(`${res.title}\n${res.url}\n`);
for (const a of res.answers) { for (const a of res.answers) {
const tag = a.agreement ? '[you] ' : a.ownerCheck ? '[owner]' : a.certain ? '[ok] ' : '[check]'; const tag = a.agreement ? '[you] ' : a.ownerCheck ? '[owner]' : a.certain ? '[ok] ' : '[check]';
console.log(` ${tag} ${a.label}: ${a.agreement ? '(you tick it; storebound never does)' : valueText(a)} (${a.source})${(a.certain && !a.ownerCheck) || !a.note ? '' : `\n ${a.note}`}`); console.log(` ${tag} ${a.label}: ${a.agreement ? '(you tick it; storebound never does)' : valueText(a)} (${a.source})${(a.certain && !a.ownerCheck) || !a.note ? '' : `\n ${a.note}`}`);
} }
console.log(`\n${res.uncertain} answer(s) to check yourself.\n\nSteps:\n${res.guidance.map(g => ` - ${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<void> { export async function run(ctx: Ctx): Promise<void> {
@ -1012,7 +1017,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
if (r.signIn) return; if (r.signIn) return;
// refused / ambiguous: not retried on this page; "not found" / "did not appear" is retried when the step shows up // 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)}`; 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)` 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)`); : `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}`); 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<void> => { const open = async (key: string, what: string, loc: Locator | null): Promise<void> => {
if (!loc) return; if (!loc) return;
const n = opened.get(key) ?? 0; 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; if (n >= 2) return;
opened.set(key, n + 1); opened.set(key, n + 1);
await flowClick(loc, form, dataTypes, say, clicked); 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 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)); 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}` : ''}`); 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)`); 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(`do this part yourself; storebound keeps watching and goes on when it sees a page 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}`); 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. */ /** 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; 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) // 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) { 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) { if (s.fallback) {
fallback = true; 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}`); for (const g of res.guidance) say(` ${g}`);
break; break;
} }

View file

@ -103,6 +103,7 @@
"apple next-build": [], "apple next-build": [],
"apple offer-codes": [ "apple offer-codes": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"codes", "codes",
@ -173,6 +174,7 @@
], ],
"apple subscriptions": [ "apple subscriptions": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"codes", "codes",
@ -222,6 +224,7 @@
], ],
"apple win-back": [ "apple win-back": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"csv", "csv",
@ -259,6 +262,7 @@
], ],
"catalog": [ "catalog": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"csv", "csv",
@ -406,7 +410,9 @@
"write" "write"
], ],
"play next-version-code": [], "play next-version-code": [],
"play products": [], "play products": [
"allow-price-changes"
],
"play release": [ "play release": [
"alpha", "alpha",
"beta", "beta",
@ -429,9 +435,12 @@
"aab", "aab",
"apk" "apk"
], ],
"play status": [], "play status": [
"allow-price-changes"
],
"play subscriptions": [ "play subscriptions": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"codes", "codes",
@ -456,6 +465,7 @@
], ],
"prices": [ "prices": [
"all", "all",
"allow-price-changes",
"base", "base",
"ceiling", "ceiling",
"csv", "csv",
@ -610,6 +620,7 @@
"play" "play"
], ],
"validate": [ "validate": [
"allow-price-changes",
"apple", "apple",
"capacitor", "capacitor",
"cordova", "cordova",
@ -659,6 +670,7 @@
"agent", "agent",
"all", "all",
"all-pages", "all-pages",
"allow-price-changes",
"allow-stale-web", "allow-stale-web",
"alpha", "alpha",
"apk", "apk",
@ -969,7 +981,7 @@
"storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]" "storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]"
], ],
"apple subscriptions": [ "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": [ "apple testflight": [
"storebound apple testflight status [--json] groups, testers per group, recent builds + beta states", "storebound apple testflight status [--json] groups, testers per group, recent builds + beta states",
@ -1079,7 +1091,7 @@
"storebound play next-version-code -> 43" "storebound play next-version-code -> 43"
], ],
"play products": [ "play products": [
"storebound play products [--dry] [--only id1,id2]" "storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]"
], ],
"play release": [ "play release": [
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]", "storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]",
@ -1095,14 +1107,14 @@
"storebound play status [--json]" "storebound play status [--json]"
], ],
"play subscriptions": [ "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": [ "play upload": [
"storebound play upload [<app.aab>] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]" "storebound play upload [<app.aab>] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]"
], ],
"prices": [ "prices": [
"storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", "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 <USD>] [...] purchasing power price sheet (prices-ppp.ts)" "storebound prices ppp [--base <USD>] [...] purchasing power price sheet (prices-ppp.ts)"
], ],
"privacy-manifest": [ "privacy-manifest": [

View file

@ -100,7 +100,7 @@ export async function run(args: Args): Promise<void> {
const target = downloadsDir(); const target = downloadsDir();
let out: string; let out: string;
try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; } 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' 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.' ? ' 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.' : ''); : platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : '');

View file

@ -46,7 +46,7 @@ export function report(plan: ImportPlan): string {
lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` + lines.push(`fastlane import: "${plan.appName}", profile ${plan.profile}, ${plan.languages.length} language(s)` +
`${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`); `${plan.bundleId ? `, iOS ${plan.bundleId}` : ''}${plan.packageName ? `, Android ${plan.packageName}` : ''}`);
sec('mapped', plan.mapped.map(m => `${m.from} -> ${m.to}`)); 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('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('placeholders to replace in storebound.config.ts', plan.placeholders.map(n => `${n.what}: ${n.where}`));
sec('warnings', plan.warnings); sec('warnings', plan.warnings);

View file

@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>(); const repeated = new Map<string, string[]>();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = []; 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++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]!; const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; } if (!a.startsWith('--')) { _.push(a); continue; }

View file

@ -52,7 +52,7 @@ export interface ConsoleResult { form: FormId; store: Store; title: string; url:
export const DISCLAIMER = [ export const DISCLAIMER = [
'storebound console fill is AI-assisted autofill. Console pages change without notice and it may misplace an answer.', '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 <form>` 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.', 'storebound never types passwords or touches sign-in pages: you sign in in the browser window yourself.',
]; ];

View file

@ -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.' }); add({ text: 'Verify: `storebound console verify apple-age-rating` reads the age rating declaration from the API.' });
return { 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, 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.'] }, 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': case 'play-app-record':

View file

@ -367,7 +367,7 @@ export function planImport(scan: FastlaneScan, o: PlanOptions): ImportPlan {
const appIdFound = appleAppId !== undefined && /^\d+$/.test(String(appleAppId)); 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)' }); 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]) 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) // --- deliver (App Store)
const dl = [...scan.deliver.locales]; const dl = [...scan.deliver.locales];
@ -480,7 +480,7 @@ Imported from fastlane deliver (\`storebound import fastlane\`). Source for \`st
${appleSections.join('\n\n')} ${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)`); } 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' }); files.push({ path: 'store/appstore.md', text: md, kind: 'appstore' });
} }

View file

@ -21,7 +21,8 @@
// if: apple | play | exists <path> | ${VAR} | !${VAR} | ${A} == x | ${A} != x // if: apple | play | exists <path> | ${VAR} | !${VAR} | ${A} == x | ${A} != x
// continueOnError: true a failure is reported and the flow goes on // 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 // 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:`. // experiments ...) are never executed, also when written as `run:`.
export type StepKind = 'read' | 'write' | 'wait' | 'owner' | 'shell'; 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']; const OWNER_ARGS = ['--submit-review', '--manual-release', '--cancel-submission', '--production', '--phased', 'submit', 'publish', '--remove', '--invite'];
/** Never allowed inside a flow step. */ /** 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. */ /** Words of a command line, honouring "double" and 'single' quotes. */
export function splitArgs(line: string): string[] { export function splitArgs(line: string): string[] {
@ -212,7 +213,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
if (argv[0] === 'storebound') e.push(`${at}: write the command without "storebound" (run: ${argv.slice(1).join(' ')})`); 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: <name>" step instead of run: flow ...`); if (argv[0] === 'flow') e.push(`${at}: use a "flow: <name>" step instead of run: flow ...`);
const bad = argv.find(a => REFUSED_ARGS.includes(a.split('=')[0]!)); 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) { if (o.commands) {
const w = words(argv); 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] ?? ''); const known = o.commands.has(`${w[0]} ${w[1]}`) || o.commands.has(`${w[0]} ${w[1]} ${w[2]}`) || o.commands.has(w[0] ?? '');

View file

@ -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.`, `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.', '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(', ')}.`] : []), ...(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 } } : {}), ...(eur ? { eur: { fx: sig8(eur.v), year: eur.y } } : {}),
countries, countries,

41
cli/lib/price-gate.ts Normal file
View file

@ -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<boolean> {
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) } };
}

View file

@ -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)}`; return `${text.slice(0, m.index)}${ind}${key}: ${toTs(value, ind)}${text.slice(close + 1)}`;
} }
const app = /^([ \t]*)app\s*:\s*\{/m.exec(text); 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 ind = app[1]!;
const close = matchBrace(text, app.index + app[0].length - 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`); if (close < 0) throw new Error(`${file}: could not find the end of the app block`);

View file

@ -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), 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' }); 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), 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') { if (apple && iosProject && p.stacks.length === 1 && p.stacks[0] === 'ios-native') {
for (const perm of IOS_PERMISSIONS) { for (const perm of IOS_PERMISSIONS) {

View file

@ -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.', 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)] }, 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, { 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 <id> ... needs the owner", exit 3); activating an existing product needs a terminal confirmation.',
props: { only }, args: i => opt('only', i.only) }, props: { only }, args: i => opt('only', i.only) },
{ name: 'play_subscriptions', title: 'Sync Play subscriptions', command: ['play', 'subscriptions'], write: true, network: true, idempotent: true, { 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 <id> ... needs the owner", exit 3).',
props: { only, csv: { type: 'string', description: 'Prices CSV with rows for subscription ids' } }, props: { only, csv: { type: 'string', description: 'Prices CSV with rows for subscription ids' } },
args: i => [...opt('only', i.only), ...opt('csv', i.csv)] }, 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, { 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.', description: 'Create missing in-app purchases, localizations, price schedules and availability. Never submits.',
props: { only }, args: i => opt('only', i.only) }, 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, { 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 <id> ... 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' } }, 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)] }, 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, { name: 'apple_iap_review', title: 'IAP review screenshots', command: ['apple', 'iap-review'], write: true, destructive: true, network: true, idempotent: true,

View file

@ -1,13 +1,16 @@
// In-app products on Google Play through monetization.onetimeproducts (the legacy inappproducts API answers 403 // 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). // "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 // 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 // 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 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 // New products (with their first prices) and listing text changes are written directly. Activating the purchase
// is sold (regional prices, the price for other regions, dropped regions, activating its purchase option) are shown // option of an EXISTING product needs a confirmation (a terminal question or --yes). Changing what an existing product
// as a diff first and need a confirmation: a terminal question (or --yes), or under the desktop app an owner action // costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call:
// confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go. // --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 <id>
// (<from> -> <to>) 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 // 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. // 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 // --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 { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry'; import { dryPlan } from '../lib/dry';
import { done, log, step, warn } from '../lib/log'; 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 { LAG_HINT, reportMissing } from '../lib/store-lag';
import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money';
import { playClient } from './client'; import { playClient } from './client';
@ -38,7 +42,13 @@ export interface Existing {
const money = (m?: PlayMoney): string => (m ? `${m.currencyCode} ${fromMoney(m).toFixed(2)}` : '-'); 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 * 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 }]; if (!cur) return [{ text: 'create', owner: false }];
const out: ProductChange[] = []; const out: ProductChange[] = [];
const text = (t: string) => out.push({ text: t, owner: false }); 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 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 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); 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 now = new Map((opt?.regionalPricingAndAvailabilityConfigs ?? []).map(r => [r.regionCode, r]));
const prices = want.regions.flatMap(r => { const prices = want.regions.flatMap(r => {
const h = now.get(r.regionCode)?.price; 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)); 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 (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(', ')}`); 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; const nr = opt?.newRegionsConfig;
if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice)) 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)}`); const from = `${money(nr?.usdPrice)} / ${money(nr?.eurPrice)}`, to = `${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`;
if (opt?.state !== 'ACTIVE') owner(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`); owner(`other regions ${from} -> ${to}`, [{ region: 'other regions', from, to }]);
}
if (opt?.state !== 'ACTIVE') text(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`);
return out; return out;
} }
@ -123,34 +135,41 @@ export async function run(ctx: Ctx): Promise<void> {
if (dry) { if (dry) {
console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' } 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}` } : !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); 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`); 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)'); done('play', 'products checked (dry run, nothing written)');
return; return;
} }
// prices, regions and activation of existing products: the owner confirms the diff above first // prices and regions of existing products: the owner's call (never --yes); activation of an existing one: confirmed
const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner)); const priced = 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.`); 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.`);
if (!ok) warn('play', `price / region / activation changes of ${gated.length} existing product(s) skipped: confirm in a terminal (or the desktop owner action)`); 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) { for (const x of plan) {
if (!x.changes.length) continue; if (!x.changes.length) continue;
const id = encodeURIComponent(x.p.id); const id = encodeURIComponent(x.p.id);
const v = encodeURIComponent(x.version); const v = encodeURIComponent(x.version);
const ownerChange = !!x.cur && x.changes.some(c => c.owner); const ownerChange = !!x.cur && x.changes.some(c => c.owner);
const listingChange = x.changes.some(c => !c.owner); if (x.cur && ownerChange && !priceOk) {
if (x.cur && ownerChange && !ok) { held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) });
if (!listingChange) continue; if (x.changes.some(c => c.text.startsWith('listings '))) {
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body); await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body);
log('play', `${x.tag}: listings updated (prices unchanged)`); log('play', `${x.tag}: listings updated (prices unchanged)`);
continue;
} }
} else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) {
const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions'; const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions';
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true&regionsVersion.version=${v}&updateMask=${mask}`, x.body); await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true&regionsVersion.version=${v}&updateMask=${mask}`, x.body);
log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`); 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 { try {
await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, { await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, {
requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }], requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }],
@ -160,5 +179,6 @@ export async function run(ctx: Ctx): Promise<void> {
} }
const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id); 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 }); 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');
} }

View file

@ -5,15 +5,19 @@
// the subscription, activated, plus `offers` (no acquisition targeting, offer tag = id: your app decides who sees // 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 > // them). Play has no subscription groups (an Apple concept) and no API for promo codes (Play Console > Monetize >
// Promo codes). Idempotent: compares first. // Promo codes). Idempotent: compares first.
// 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]
// Prints the plan first. In a terminal it asks before writing. Without a terminal (MCP, CI) and without --yes it // Prints the plan first. In a terminal (no --yes) it asks before writing, price changes included. With --yes or
// creates what is new only and leaves price changes of existing base plans to a terminal run. A base plan price // without a terminal it writes everything new (subscriptions with their first prices, listings, base plans, offers)
// change applies to new subscribers; existing ones keep their price until you run a price migration in Play Console. // 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 <id> (<from> -> <to>) 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. // Pure parts and sources: lib/subscriptions.ts.
import { sellsOn } from '../lib/config'; import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log'; 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 { type Money, toMoney } from '../lib/money';
import { import {
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
@ -72,6 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
: [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.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'; 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(', ')}` : ''}`); 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}`); 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`); if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`);
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing }); work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
@ -81,11 +86,16 @@ export async function run(ctx: Ctx): Promise<void> {
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; } if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
const yes = ctx.args.has('yes'); const yes = ctx.args.has('yes');
let liveOk = yes; const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
if (process.stdin.isTTY && !yes) { 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; 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; 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) { for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {
const v = encodeURIComponent(version); const v = encodeURIComponent(version);
@ -99,8 +109,14 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`); 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; if (!r.created) continue;
} else if (diff.listings.length || diff.basePlan !== 'same') { } 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)`); if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) {
else { 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&regionsVersion.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&regionsVersion.version=${v}`, playPatchSubscription(cur!, body)); await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans&regionsVersion.version=${v}`, playPatchSubscription(cur!, body));
log(TAG, `${s.id}: updated`); log(TAG, `${s.id}: updated`);
} }
@ -129,5 +145,6 @@ export async function run(ctx: Ctx): Promise<void> {
if (s.intro && offerMissing) await createOffer(s.intro); if (s.intro && offerMissing) await createOffer(s.intro);
for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o); 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');
} }

View file

@ -3,12 +3,16 @@
// one-time products and (unless --no-subscriptions) auto-renewable subscriptions: Apple's current and scheduled // 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 // 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). // 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 <USD>] [...] purchasing power price sheet (prices-ppp.ts) // storebound prices ppp [--base <USD>] [...] purchasing power price sheet (prices-ppp.ts)
// CSV columns: product, store (apple | play | both), region (ISO alpha-2), currency, price, start_date (App Store // 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 // 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). // 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 <id> (<from> -> <to>)
// 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; // 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 // 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 // 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 type { Ctx } from './lib/ctx';
import { confirm, need } from './lib/ctx'; import { confirm, need } from './lib/ctx';
import { done, log, printJson, step, warn } from './lib/log'; import { done, log, printJson, step, warn } from './lib/log';
import { allowPriceChanges, type HeldPrice, holdPriceChanges } from './lib/price-gate';
import { toMoney } from './lib/money'; import { toMoney } from './lib/money';
import { import {
appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions, appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions,
@ -143,8 +148,26 @@ export async function run(ctx: Ctx): Promise<void> {
console.log(diffTable(diff, { all: a.has('all') })); console.log(diffTable(diff, { all: a.has('all') }));
if (!changed.size) { done(TAG, 'prices up to date'); return; } if (!changed.size) { done(TAG, 'prices up to date'); return; }
log(TAG, `${changed.size} product / store pair(s) to update`); 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 (dry) { done(TAG, 'dry run: nothing written'); 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; 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) { for (const p of products) {
const wanted = wantedAll.filter(r => r.product === p.id); const wanted = wantedAll.filter(r => r.product === p.id);
@ -180,5 +203,6 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${p.id} play: ${regions.length} regions set`); 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)');
} }

View file

@ -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 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[...]`, 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). 2. **Languages.** Join the deliver locales and the supply locales into storebound `languages[]` (`apple`, `play` codes).
3. **Listing text.** 3. **Listing text.**
- Write `store/appstore.md` and `store/listing.md` from the `.txt` files, mapping `default/` into every language - Write `store/appstore.md` and `store/listing.md` from the `.txt` files, mapping `default/` into every language

View file

@ -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`. - `price_tier`: products and the prices CSV (`store-iap-setup`). Age rating config: `store-declarations`.
Export compliance: `export-compliance` (`apple.usesNonExemptEncryption` stays commented out). Export compliance: `export-compliance` (`apple.usesNonExemptEncryption` stays commented out).
- Lanes, `match`, `gym` / `gradle`: building and signing stay where they are; storebound has no lanes. - 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: - Promo graphic, TV banner, TV / Wear screenshots, Apple TV privacy policy, Korean trade representative contact:
consoles. consoles.

View file

@ -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). `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). 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 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. (the error lists the neighbours) and Play's region currencies.
## Read before applying ## Read before applying

View file

@ -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 - `--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). 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 - `--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 step gets `--yes`; reads, waits and owner steps do not, so nothing owner-only runs. `--yes` never changes the price
(`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 of an existing product (`play products`, `apple subscriptions`, `play subscriptions`): such a step writes everything
else, names each held change ("price change on <id> (<from> -> <to>) 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 <name> --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). "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 - 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 imports, inviting testers, Beta App Review, starting experiments, offer codes (AGENTS.md rule 1). They print as

View file

@ -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, 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&regionsVersion.version=...` and the `regionsVersion`), your overrides on top, `PATCH .../onetimeproducts/{id}?allowMissing=true&regionsVersion.version=...`
(create or update), then `oneTimeProducts/{id}/purchaseOptions:batchUpdateStates` to activate (new options are DRAFT). (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 New products (with their first prices) and listing text go straight through; `--yes` confirms activating the
are the owner's decision: the run shows the diff and asks in a terminal; without a terminal (MCP, CI, the desktop purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision
app's normal write) they are skipped with a warning, the listing text still updates. The owner confirms in a and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those
terminal, or applies prices with the desktop Prices "Apply" (owner action, `prices import`). rows `owner`, and a real run writes everything else, prints `price change on <id> (<from> -> <to>) 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`. 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 4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
link; purchases are free test orders. link; purchases are free test orders.
@ -41,7 +44,8 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr
## App Store (API + UI) ## App Store (API + UI)
1. `storebound apple iap --dry`, then `storebound apple iap`: creates missing products, localizations, a price schedule 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 (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): 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. `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 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. 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, 3. `storebound prices import --dry` (MCP: `prices_diff`) prints the diff table: product, store, region, from, current,
new, change %. Show it to the owner. 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 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 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 product has no price for get `convertRegionPrices` from the USD base, `--reconvert` resets every region the CSV

View file

@ -34,8 +34,10 @@ offers, offerCodes, reviewScreenshot, basePlanId, gracePeriod, platforms }] }]`,
billing library on a track. billing library on a track.
2. `storebound check` (limits: Apple name 30 / description 45; Play description 200, up to 4 benefits). 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 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. yourself (`--yes`): new subscriptions, first prices, listings, base plans and offers are written. A price change
Through MCP (no terminal) they only create what is new; price changes of existing subscriptions wait for the owner. of an existing subscription is a live price and `--yes` never applies it: the run writes everything else, prints
`price change on <id> (<from> -> <to>) 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`. 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): 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 `storebound apple iap-submit --with-version --dry` plans one review submission with the app version, the

View file

@ -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(/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).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(`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'); expect(said).toContain('it stops here');
const [sub] = readdirSync(dir); const [sub] = readdirSync(dir);
const files = readdirSync(join(dir, sub!)).sort(); const files = readdirSync(join(dir, sub!)).sort();

View file

@ -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)']; '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); 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 <form> [--json] [--lookup] (then drive the page yourself'])
expect([o, CLI_HANDOFF.some(re => re.test(o))]).toEqual([o, false]);
});
}); });

View file

@ -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 () => { 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' }); 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([]))); 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.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true);
expect(r.second).toEqual([]); expect(r.second).toEqual([]);
}); });

View file

@ -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)] }; const w = { ...want, listings: [{ ...want.listings[0]!, title: 'Remove Ads' }], regions: [want.regions[0]!, region('TR', 'TRY', 99.99)] };
expect(productChanges(CUR, w)).toEqual([ expect(productChanges(CUR, w)).toEqual([
{ text: 'listings changed: en-US', owner: false }, { text: 'listings removed: tr-TR', owner: false }, { 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' }] }; 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 // the existing product as listed (2 regions), and what convertRegionPrices answers for USD 2.99
const LISTED = { productId: 'com.example.game.no_ads', const LISTED = { productId: 'com.example.game.no_ads',
listings: [{ languageCode: 'en-US', title: 'No Ads', description: 'Removes 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 out: string[] = [];
const [l, w, e] = [console.log, console.warn, console.error]; const [l, w, e] = [console.log, console.warn, console.error];
console.log = console.warn = console.error = (...a: unknown[]) => { out.push(a.join(' ')); }; 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=[^&]+&?/, '')}`); 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 () => { test('flow-style run (--yes, no terminal): the listing change and new products go, the price stays, exit 3 naming the change', async () => {
const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } }); 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.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99');
expect(r.writes).toEqual([ expect(r.writes).toEqual([
'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings', 'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings',
'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions', 'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions',
'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates', 'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates',
]); ]);
expect(r.out).toContain('skipped: confirm in a terminal'); 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 }] }; 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)).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 () => { test('under serve: refused even with --yes; a verified desktop owner confirmation lets it through', async () => {

94
tests/price-gate.test.ts Normal file
View file

@ -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<void>): Promise<void> {
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');
});
});

View file

@ -23,7 +23,7 @@ const DYNAMIC: Record<string, string[]> = {
'play/rollout.ts': ['halt', 'resume', 'complete'], 'play/rollout.ts': ['halt', 'resume', 'complete'],
'play/release.ts': ['internal', 'alpha', 'beta', 'production'], 'play/release.ts': ['internal', 'alpha', 'beta', 'production'],
'ads/apple.ts': ['adgroup', 'campaign', 'keyword'], '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/declarations.ts': [], // a.get(k) on a Map, not the args
'lib/catalog.ts': [], // a.get(w.id) on a Map, not the args 'lib/catalog.ts': [], // a.get(w.id) on a Map, not the args
}; };