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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue