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:
parent
5fbb9e63e3
commit
366362d62b
12 changed files with 116 additions and 19 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue