flow run: a declined confirmation stops the flow; --apply --yes confirms write steps

confirm() prints 'declined: <reason>' and exits 3 whenever the answer is no or nobody can answer (not a
terminal without --yes, serve without the owner's dialog), so no command reports success after declining.
flow run stops at such a step with 'step N declined: <reason>' (status declined), never ok. flow run
--apply --yes passes --yes to write steps only; --yes without --apply is refused. Pitfall 24.
This commit is contained in:
CRTkafa 2026-10-12 08:44:56 +08:00
parent 5fbb9e63e3
commit 366362d62b
12 changed files with 116 additions and 19 deletions

View file

@ -2,6 +2,14 @@
## Unreleased
- Fix (`flow run`, every confirming command): a declined confirmation is no longer a success. `confirm()` prints
`declined: <reason>` and exits 3 when the owner answers no, when there is no terminal and no `--yes`, or under
`serve` without the owner's dialog; `flow run` stops at that step with "step N declined: <reason>" (status
`declined`), never "ok". `flow run --apply --yes` is the owner's approval for the run: write steps get `--yes`
(reads, waits and owner steps do not); `--yes` without `--apply` is refused. Commands that went on with a partial
write after a declined part (`play products` prices, `testflight testers --invite`, `distribute --submit-review`)
now exit 3 too. Pitfall 24.
- Fix (`apple build`, `play build`, Capacitor): a native build no longer ships old web assets silently. New
`build.prebuild` (per store `apple.prebuild` / `play.prebuild`): a shell command run in the project root before the
archive / bundle (e.g. `npm run build && npx cap sync`; printed, output streamed, nonzero exit stops the build,

View file

@ -324,7 +324,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `whats-new --languages en,de,tr` / `check` / `apply` | local | one file per language (`store/whats-new/`) for your agent (or a `--translate` hook of yours) to translate, per-store limits (Play 500, App Store 4000), then into both listing files |
| `listing translate --to de,ja [--from en] [--translate "<cmd>"]` | local | per-language listing sections in both listing files (App Store name, subtitle, promotional text, keywords, description; Play title, short, full) for your agent (or a hook) to translate, marked as drafts until reviewed; keywords cleaned up |
| `listing check` / `listing reviewed --lang <ids>` | local | every language section against the store limits and keyword rules, untranslated and unreviewed drafts (exit 1 on a problem) / remove the draft marker after a human read it |
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](#repo-local-workflows)) |
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply [--yes]] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](#repo-local-workflows)) |
| `catalog pull [--write]` | API, read-only + local | IAP and subscription texts on the stores vs the config: a proposal diff (`.storebound/pull/catalog.json`); the config is never rewritten |
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
@ -646,7 +646,9 @@ $ storebound flow run release VERSION=1.2.0 --dry
- Read steps (check, validate, scan, release plan, next-build, status) run; **write steps run with `--dry`** unless
`--apply`, and even then each command asks its own confirmation; wait steps check once; `sh:` steps (your own build
commands) run only with `--apply`. `--dry` prints the plan without running anything.
commands) run only with `--apply`. `--dry` prints the plan without running anything. `--apply --yes` is your
approval of every write step of the run (no terminal needed); without it, a step whose confirmation cannot be
answered stops the flow as "declined", never "ok".
- Owner steps (submit, release, phased release, rollout, review replies, price imports, tester invitations, Beta App
Review) are never executed, also when written as `run:`; the flow ends with the list of your clicks.
- `capture:` turns a step's last output line into a variable (`${BUILD}`), `if:` skips a step (`apple`, `play`,

View file

@ -1028,7 +1028,7 @@
"flow": [
"storebound flow list [--json] the flows of this project and their steps",
"storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem",
"storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume] [--json]"
"storebound flow run <name> [NAME=value ...] [--dry | --apply [--yes]] [--from <step> | --resume] [--json]"
],
"health": [
"storebound health [--apple] [--play] [--days 7] [--json]",

View file

@ -1,11 +1,14 @@
// Repo-local workflows: named, ordered storebound steps kept in the project (definitions and rules: lib/flow.ts).
// storebound flow list [--json] the flows of this project and their steps
// storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem
// storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume] [--json]
// storebound flow run <name> [NAME=value ...] [--dry | --apply [--yes]] [--from <step> | --resume] [--json]
// default: read steps run, write steps run with --dry (nothing changes), wait steps check once, shell steps are
// skipped, owner steps (submit, release, rollout ...) are shown as "your click" and never run
// --dry: only the plan: nothing runs at all
// --apply: write steps and shell steps run for real; every write command still asks the owner itself
// --apply --yes: the owner's approval for this run: write steps get --yes (owner steps still never run)
// A step whose command declined its confirmation ("declined: ...", e.g. not a terminal without --yes) stops the flow:
// "step N declined: <reason>", never ok.
// --from <step>: start at that step (variables captured by the last run are reused); --resume: from the first step
// the last run did not finish (.storebound/flows/<name>.json)
// Progress: "[flow] 3/9 upload: storebound apple upload --no-wait --dry" before each step, the step's own output
@ -20,6 +23,7 @@ import {
type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex,
type StepResult, validateFlows,
} from './lib/flow';
import { DECLINED_MARK } from './lib/ctx';
import { done, log, printJson, step, warn } from './lib/log';
import { childEnv, pump, selfCommand } from './lib/runner';
import { PREBUILD_DONE_ENV, prebuildFor, prebuildsDone } from './lib/web-assets';
@ -77,6 +81,12 @@ async function condCtx(root: string): Promise<CondCtx & { config: ShipConfig | n
return { apple: !!config?.apple, play: !!config?.play, exists: p => existsSync(join(root, p)), config };
}
/** The reason of a declined confirmation in a step's output (the "declined: ..." line of confirm()), else null. Pure. */
export function declinedReason(out: string): string | null {
const l = out.split('\n').reverse().find(x => x.startsWith(DECLINED_MARK));
return l ? l.slice(DECLINED_MARK.length).trim() : null;
}
/** The prebuild a finished build step ran (apple build / play build, for real, without --no-prebuild), else null:
* later build steps of the same flow run skip that command (build.prebuild runs once per run, not per store). Pure. */
export function prebuildRan(argv: string[], config: ShipConfig | null): string | null {
@ -140,6 +150,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
const name = args._[1];
if (!name) throw new Error('usage: storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume]');
if (args.has('dry') && args.has('apply')) throw new Error('--dry or --apply, not both');
if (args.has('yes') && !args.has('apply')) throw new Error('--yes only with --apply (it confirms the write steps of this run)');
const { flows, errors } = await loadFlows(root);
const problems = [...errors, ...validateFlows(flows)];
if (problems.length) throw new Error(`flow definitions have problems (storebound flow validate):\n ${problems.join('\n ')}`);
@ -174,7 +185,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
const runPart = async (part: PlannedStep['part'], steps: typeof parts.steps, offset = 0): Promise<boolean> => {
for (let i = offset; i < steps.length; i++) {
const s = steps[i]!;
const p = planStep(s, part, mode, vars, c, env);
const p = planStep(s, part, mode, vars, c, env, args.has('yes'));
const label = `${part === 'steps' ? '' : `${part} `}${step(i + 1, steps.length, s.id)}`;
const r: StepResult = { id: s.id, part, kind: p.kind, status: 'planned', command: describe(p) };
if (p.skip) {
@ -198,12 +209,13 @@ async function runFlow(args: Args, root: string): Promise<void> {
}
if (!json) log(TAG, `${label}: ok (${(r.ms / 1000).toFixed(1)}s)${r.detail ? ` ${r.detail}` : ''}`);
} else {
r.status = 'failed';
r.detail = x.out.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 300);
if (!json) warn(TAG, `${label}: failed (exit ${x.code})${p.continueOnError ? ', continueOnError: going on' : ''}`);
const why = declinedReason(x.out);
r.status = why ? 'declined' : 'failed';
r.detail = why ?? x.out.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 300);
if (!json) warn(TAG, why ? `step ${i + 1} declined: ${why}${s.id ? ` (${s.id})` : ''}` : `${label}: failed (exit ${x.code})${p.continueOnError ? ', continueOnError: going on' : ''}`);
}
state.vars = vars; state.results.push(r); save();
if (r.status === 'failed' && !p.continueOnError) return false;
if (r.status === 'declined' || (r.status === 'failed' && !p.continueOnError)) return false;
}
return true;
};
@ -217,7 +229,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
if (json) printJson({ ...state, stateFile: mode === 'plan' ? null : relative(root, statePath(root, name)) });
else {
if (owner.length) { console.log('\nYour clicks (storebound never does these):'); for (const r of owner) console.log(` - ${r.command}`); }
const failed = state.results.find(r => r.status === 'failed' && r.part === 'steps');
const failed = state.results.find(r => (r.status === 'failed' || r.status === 'declined') && r.part === 'steps');
if (!ok && failed) log(TAG, `stopped at ${failed.id}. Fix it, then: storebound flow run ${name} --resume${mode === 'apply' ? ' --apply' : ''}`);
done(TAG, `${name}: ${ok ? 'done' : 'failed'} (${mode === 'plan' ? 'plan only' : mode === 'dry' ? 'dry run: nothing changed' : 'applied'})`);
}

View file

@ -27,15 +27,33 @@ export function byAppleLocale(langs: Language[]): Map<string, Language> {
* so the answer is no. The one exception: a desktop owner action (serve.ts OWNER_TOOLS: rollout changes, the phased
* release, sending one approved reply) whose confirm token the sidecar verified after the owner pressed the button of
* a native dialog showing the dry run; serve sets STOREBOUND_OWNER_CONFIRMED for that one child only.
* A "no" (answered, or nobody could answer) is never a success: it prints "declined: <reason>" on stderr and sets the
* exit code to DECLINED_EXIT, so a script or `flow run` stops there instead of going on as if the write happened.
*/
export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
if (process.env.STOREBOUND_SERVE === '1') {
if (process.env.STOREBOUND_OWNER_CONFIRMED === '1') { console.error(`confirmed by the owner in the desktop dialog: ${question}`); return true; }
console.error(`confirm-required: ${question}\n terminal only: run it in a terminal`); return false;
console.error(`confirm-required: ${question}\n terminal only: run it in a terminal`);
return declined('confirm-required: terminal only');
}
if (ctx.args.has('yes')) return true;
if (!process.stdin.isTTY) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return false; }
if (!process.stdin.isTTY) {
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
}
process.stdout.write(`${question} [y/N] `);
for await (const line of console) return /^y(es)?$/i.test(line.trim());
for await (const line of console) return /^y(es)?$/i.test(line.trim()) || declined('the owner answered no');
return declined('no answer (stdin closed)');
}
/** Exit code of a command whose confirmation was declined or could not be asked. */
export const DECLINED_EXIT = 3;
/** Prefix of the stderr line a declined confirmation prints (`flow run` reports it as "step N declined: ..."). */
export const DECLINED_MARK = 'declined: ';
/** A declined confirmation: the reason on stderr, a nonzero exit code; returns false for confirm(). */
export function declined(reason: string): false {
console.error(`${DECLINED_MARK}${reason}`);
process.exitCode = DECLINED_EXIT;
return false;
}

View file

@ -21,7 +21,7 @@
// 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); 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); 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';
@ -277,13 +277,15 @@ export interface PlannedStep {
skip?: string;
/** --dry was added (write steps without --apply, wait steps without --apply). */
dryAdded: boolean;
/** --yes was added (write steps of `flow run --apply --yes`: the owner approved the writes for this run). */
yesAdded?: boolean;
capture?: string;
continueOnError: boolean;
note?: string;
}
/** How one step runs in a mode, with the variables known so far. Pure. */
export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, vars: Record<string, string>, c: CondCtx, env: Record<string, string | undefined> = {}): PlannedStep {
export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, vars: Record<string, string>, c: CondCtx, env: Record<string, string | undefined> = {}, yes = false): PlannedStep {
const base = { id: s.id, part, continueOnError: !!s.continueOnError, ...(s.capture ? { capture: s.capture } : {}), ...(s.note ? { note: s.note } : {}) };
if (s.if !== undefined && !evalIf(substitute(s.if, vars, env), c)) {
const what = s.owner !== undefined ? { owner: substitute(s.owner, vars, env) } : s.sh !== undefined ? { shell: substitute(s.sh, vars, env) } : { argv: splitArgs(substitute(s.run ?? '', vars, env)) };
@ -298,7 +300,8 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var
const kind = classify(argv);
if (kind === 'owner') return { ...base, kind, owner: `storebound ${argv.join(' ')}`, dryAdded: false, skip: 'your click (owner step: run it yourself after checking)' };
const addDry = (kind === 'write' || kind === 'wait') && mode !== 'apply' && !argv.includes('--dry');
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : argv, dryAdded: addDry, ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
const addYes = yes && kind === 'write' && mode === 'apply' && !argv.includes('--dry');
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
}
/** Index of the step to start from: --from <id> (exact, or the first id under a sub-flow prefix). */
@ -309,7 +312,7 @@ export function startIndex(steps: FlowStep[], from: string | undefined): number
return i;
}
export interface StepResult { id: string; part: PlannedStep['part']; kind: StepKind; status: 'ok' | 'failed' | 'skipped' | 'owner' | 'planned'; exitCode?: number; ms?: number; detail?: string; command?: string }
export interface StepResult { id: string; part: PlannedStep['part']; kind: StepKind; status: 'ok' | 'failed' | 'declined' | 'skipped' | 'owner' | 'planned'; exitCode?: number; ms?: number; detail?: string; command?: string }
export interface RunState { flow: string; mode: Mode; startedAt: string; finishedAt?: string; vars: Record<string, string>; results: StepResult[]; ok: boolean }
/** First step of the last run that did not finish (failed, or never reached): where `--resume` starts. */

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: <reason>" (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). |
| 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `<webDir>/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). |
| 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. |
| 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. |

View file

@ -46,7 +46,11 @@ once (the second build step prints "prebuild skipped (already ran in this flow r
changes. Show this output to the owner.
- `--dry`: the plan only, nothing runs.
- `--apply`: writes and shell steps run for real, **only after the owner agreed in chat**; each write command still
asks its own confirmation in the terminal. Never add `--yes` (flow validate refuses it).
asks its own confirmation in the terminal. Never add `--yes` to a step (flow validate refuses it).
- `--apply --yes`: the owner's approval of every write step of this run (they get `--yes`; reads, waits and owner
steps do not). Only when the owner said so in chat for this run, e.g. a background run with a log where no terminal
can answer. 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
"YOUR CLICK" with the exact text, also when written as `run:`.

View file

@ -1,6 +1,6 @@
---
name: store-pitfalls
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets. Read it before any store task, and when a store command does something you did not expect.
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect.
---
# Store pitfalls
@ -283,3 +283,16 @@ that fails without the fix (see CONTRIBUTING.md).
once failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check and `check`
now find the one Capacitor project one or two levels down (workspaces, apps/*, packages/*) and name it; several are
listed and need `play.build.dir` / `apple.build.workspace|project` (`capacitorProject` in `cli/lib/web-assets.ts`).
## 24. A flow step that declined its confirmation was logged "ok"
- **Symptom:** `flow run release --apply` ran `storebound apple upload --no-wait`, which printed "not a terminal:
re-run with --yes to confirm" and uploaded nothing, yet the flow logged "4/13 upload: ok" and went on to
`apple testflight wait` for a build that was never uploaded.
- **Cause:** a declined confirmation (`confirm()` in `cli/lib/ctx.ts`) returned from the command with exit 0, and
`flow run --yes` was not passed on to the steps, so a run without a terminal could never confirm.
- **Do:** read a step's output, not only its status. For a run without a terminal the owner can approve the writes
with `flow run <name> --apply --yes` (only on their word, per run); otherwise run the flow in a terminal.
- **Guard:** every declined or unanswerable confirmation prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`);
`flow run` stops with "step N declined: <reason>" (status `declined`, never `ok`) and adds `--yes` to write steps
only with `--apply --yes` (`planStep` in `cli/lib/flow.ts`, `declinedReason` in `cli/flow.ts`).
`tests/flow.test.ts` (non-TTY flow with a confirming step and no `--yes` fails at that step with "declined").

View file

@ -35,6 +35,7 @@ beforeEach(() => {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
});
afterEach(() => {
process.exitCode = 0; // a declined confirmation sets it (DECLINED_EXIT); the tests below check it
globalThis.fetch = realFetch;
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
rmSync(root, { recursive: true, force: true });
@ -236,12 +237,14 @@ describe('command (mocked fetch)', () => {
expect(out).toContain('100.00 -> 150.00 USD (+50%)');
expect(out).toContain('not a terminal');
expect(calls.some(c => c.method === 'PUT')).toBe(false);
expect(process.exitCode).toBe(3);
});
test('pause under storebound serve is refused even with --yes', async () => {
process.env.STOREBOUND_SERVE = '1';
const calls = mockFetch(api);
await quiet(() => run(ctx(['pause', '--campaign', '444555666', '--yes'])));
expect(calls.some(c => c.method === 'PUT')).toBe(false);
expect(process.exitCode).toBe(3);
});
test('budget with --yes in a terminal-less run sends the documented body', async () => {
const calls = mockFetch(api);

View file

@ -83,6 +83,12 @@ describe('steps', () => {
expect(planStep({ id: 'sh', sh: 'npm run build' }, 'steps', 'apply', vars, C).skip).toBeUndefined();
expect(planStep({ id: 'p', run: 'play build', if: 'play' }, 'steps', 'apply', vars, C).skip).toBe('if play: false');
expect(planStep({ id: 'n', run: 'whats-new apply', if: 'exists store/whats-new' }, 'steps', 'apply', vars, C).skip).toBeUndefined();
// flow run --apply --yes: the owner's approval reaches write steps only (never reads, waits or owner steps)
expect(planStep(up, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ argv: ['apple', 'upload', '--no-wait', '--yes'], yesAdded: true });
expect(planStep(up, 'steps', 'dry', vars, C, {}, true).argv).toEqual(['apple', 'upload', '--no-wait', '--dry']);
expect(planStep({ id: 'v', run: 'validate' }, 'steps', 'apply', vars, C, {}, true).argv).toEqual(['validate']);
expect(planStep({ id: 'w', run: 'apple testflight wait --build 1' }, 'steps', 'apply', vars, C, {}, true).argv).not.toContain('--yes');
expect(planStep({ id: 'r', run: 'apple release' }, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ kind: 'owner', skip: expect.stringContaining('your click') });
});
test('conditions, variables, params', () => {
expect(evalIf('apple && !play', C)).toBe(true);
@ -191,6 +197,27 @@ steps:
expect(sb(app, 'flow', 'run', 'nope').stderr.toString()).toContain('no flow "nope"');
});
test('a declined confirmation (not a terminal, no --yes) stops the flow at that step, never "ok"', () => {
const d = join(tmp, 'declined');
mkdirSync(join(d, 'store', 'workflows'), { recursive: true });
writeFileSync(join(d, 'storebound.config.ts'), `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', play: 'en-US' }], play: { packageName: 'com.example.game' } };\n`);
writeFileSync(join(d, 'app.aab'), 'not really a bundle');
writeFileSync(join(d, 'store', 'workflows', 'share.yml'), 'steps:\n - id: share\n run: play share --aab app.aab\n - id: after\n sh: touch after.txt\n');
const direct = sb(d, 'play', 'share', '--aab', 'app.aab');
expect(direct.exitCode).not.toBe(0);
expect(direct.stderr.toString()).toContain('declined: not a terminal and no --yes');
const r = sb(d, 'flow', 'run', 'share', '--apply');
const out = r.stdout.toString() + r.stderr.toString();
expect(r.exitCode).toBe(1);
expect(out).toContain('step 1 declined: not a terminal and no --yes');
expect(out).not.toContain('share: ok');
expect(existsSync(join(d, 'after.txt'))).toBe(false);
const state = JSON.parse(readFileSync(join(d, '.storebound/flows/share.json'), 'utf8'));
expect(state.results.map((x: { id: string; status: string }) => `${x.id}:${x.status}`)).toEqual(['share:declined']);
expect(sb(d, 'flow', 'run', 'share', '--yes').stderr.toString()).toContain('--yes only with --apply');
});
test('MCP: flow_list and a dry flow_run that never applies', () => {
const list = TOOLS.find(t => t.name === 'flow_list')!;
const run = TOOLS.find(t => t.name === 'flow_run')!;

View file

@ -1,4 +1,10 @@
// Loaded before every test file (bunfig.toml): no real waiting between the re-checks of cli/lib/upsert.ts or for
// the store's list to settle after a create (cli/apple/iap.ts).
import { afterEach } from 'bun:test';
process.env.STOREBOUND_UPSERT_WAIT_MS = '0';
process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.
afterEach(() => { process.exitCode = 0; });