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