price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output

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

View file

@ -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) {

View file

@ -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,