storebound/cli/lib/flow.ts

366 lines
22 KiB
TypeScript

// Repo-local workflows (`storebound flow`): named, ordered storebound steps kept in the project, e.g. a release flow
// next-build -> build -> upload -> wait -> whats-new apply -> validate -> scan -> plan -> your Submit click. Pure parts:
// reading and checking the definitions, classifying each step (read / write / owner / shell / wait), variables, `if`
// conditions and the run plan. The runner is cli/flow.ts.
//
// Where flows live (all are read; a name defined twice is an error):
// storebound.workflows.(yml|yaml|json|ts) several flows: { flows: { <name>: Flow } } or { <name>: Flow }
// store/workflows/<name>.(yml|yaml|json|ts) one flow per file, named after the file
// A flow:
// description: text
// params: { VERSION: "1.0.0" } defaults; `flow run release VERSION=1.2.0` overrides
// before: [steps] steps: [steps] after: [steps] onError: [steps] (hooks: before / after the steps, on a failure)
// private: true only usable as a sub-flow (`flow: <name>` step), not listed or run directly
// A step (exactly one of run / sh / owner / flow):
// id: next-build unique in the flow; `--from <id>` resumes there
// run: apple next-build a storebound command line (no "storebound" prefix); ${VAR} substituted
// sh: npm run build:web a shell command of yours: only runs with --apply
// owner: "App Store Connect: Submit for Review" never run: shown as "your click"
// flow: beta the steps of another flow (ids prefixed "beta.")
// capture: BUILD the last stdout line becomes ${BUILD} for later steps
// 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, 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';
export interface FlowStep {
id: string;
run?: string;
sh?: string;
owner?: string;
flow?: string;
capture?: string;
if?: string;
continueOnError?: boolean;
/** Free text shown next to the step. */
note?: string;
}
export interface Flow {
name: string;
file: string;
description?: string;
params: Record<string, string>;
before: FlowStep[];
steps: FlowStep[];
after: FlowStep[];
onError: FlowStep[];
private: boolean;
}
/** Commands that only read (stores or local files); they run for real in every mode except a --dry plan. */
const READ: string[][] = [
['check'], ['validate'], ['scan'], ['inspect'], ['doctor'], ['apps'], ['release', 'status'], ['release', 'plan'], ['originality'],
['apple', 'next-build'], ['play', 'next-version-code'], ['apple', 'status'], ['play', 'status'], ['health'], ['catalog', 'diff'], ['triage'],
['shots', 'check'], ['whats-new', 'check'], ['listing', 'check'], ['apple', 'testflight', 'status'], ['apple', 'testflight', 'groups'],
['apple', 'signing', 'status'], ['apple', 'preorder', 'status'], ['apple', 'cpp', 'list'], ['apple', 'events', 'list'], ['ads', 'scan'],
['privacy-manifest'], ['apple', 'encryption'], ['ws', 'list'], ['ws', 'status'], ['ws', 'check'], ['ws', 'validate'], ['apple', 'adhoc', 'devices'],
['revenuecat', 'diff'], ['ads', 'apple', 'status'], ['ads', 'apple', 'campaigns'], ['ads', 'apple', 'report'], ['analytics', 'compare'],
];
/** Waits for a store (build processing): with --dry it checks once instead of waiting. */
const WAIT: string[][] = [['apple', 'testflight', 'wait']];
/** Never executed by a flow: the owner does these (AGENTS.md rule 1). */
const OWNER: string[][] = [
['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'resubmit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'],
['apple', 'cpp', 'submit'], ['apple', 'cpp', 'visible'], ['apple', 'events', 'submit'], ['apple', 'preorder', 'enable'], ['apple', 'preorder', 'end'],
['apple', 'testflight', 'testers'], ['apple', 'testflight', 'expire'], ['apple', 'offer-codes'], ['apple', 'win-back'], ['ads', 'apple', 'bid'],
['ads', 'apple', 'budget'], ['ads', 'apple', 'pause'], ['apple', 'adhoc', 'register'],
];
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', '--allow-price-changes'];
/** Words of a command line, honouring "double" and 'single' quotes. */
export function splitArgs(line: string): string[] {
const out: string[] = [];
let cur = '', q: string | null = null, any = false;
for (const c of line.trim()) {
if (q) { if (c === q) q = null; else cur += c; continue; }
if (c === '"' || c === '\'') { q = c; any = true; continue; }
if (/\s/.test(c)) { if (cur || any) out.push(cur); cur = ''; any = false; continue; }
cur += c;
}
if (q) throw new Error(`unterminated quote in: ${line}`);
if (cur || any) out.push(cur);
return out;
}
const words = (argv: string[]): string[] => { const w: string[] = []; for (const a of argv) { if (a.startsWith('-')) break; w.push(a); } return w; };
const starts = (w: string[], p: string[]) => p.length <= w.length && p.every((x, i) => w[i] === x);
/** What a `run:` step is. */
export function classify(argv: string[]): StepKind {
const w = words(argv);
if (OWNER.some(p => starts(w, p)) || argv.some(a => OWNER_ARGS.includes(a))) return argv.includes('--dry') && !argv.some(a => OWNER_ARGS.includes(a)) ? 'read' : 'owner';
if (WAIT.some(p => starts(w, p))) return 'wait';
if (starts(w, ['apple', 'testflight', 'groups', 'sync'])) return 'write';
if (READ.some(p => starts(w, p))) return 'read';
// `api` reads run, writes stay dry inside the command itself; treat it as a write so it gets --dry
return 'write';
}
export const kindOf = (s: FlowStep, argv?: string[]): StepKind =>
s.owner !== undefined ? 'owner' : s.sh !== undefined ? 'shell' : classify(argv ?? splitArgs(s.run ?? ''));
// ---- loading ----
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,62}$/;
const VAR_RE = /^[A-Z][A-Z0-9_]{0,40}$/;
const STEP_KEYS = ['id', 'run', 'sh', 'owner', 'flow', 'capture', 'if', 'continueOnError', 'note'];
const FLOW_KEYS = ['description', 'params', 'before', 'steps', 'after', 'onError', 'private'];
function asSteps(v: unknown, where: string, e: string[]): FlowStep[] {
if (v === undefined || v === null) return [];
if (!Array.isArray(v)) { e.push(`${where}: must be a list of steps`); return []; }
const out: FlowStep[] = [];
v.forEach((x, i) => {
const at = `${where}[${i}]`;
if (typeof x !== 'object' || x === null || Array.isArray(x)) { e.push(`${at}: a step is a map (id, run | sh | owner | flow, ...)`); return; }
const o = x as Record<string, unknown>;
for (const k of Object.keys(o)) if (!STEP_KEYS.includes(k)) e.push(`${at}: unknown key "${k}" (${STEP_KEYS.join(', ')})`);
const kinds = ['run', 'sh', 'owner', 'flow'].filter(k => o[k] !== undefined);
if (kinds.length !== 1) e.push(`${at}: exactly one of run, sh, owner, flow`);
for (const k of ['id', 'run', 'sh', 'owner', 'flow', 'capture', 'if', 'note']) if (o[k] !== undefined && typeof o[k] !== 'string') e.push(`${at}.${k}: must be a string`);
const id = typeof o.id === 'string' ? o.id : typeof o.flow === 'string' ? o.flow : '';
if (!ID_RE.test(id)) e.push(`${at}.id: "${id}" (lowercase letters, digits, . _ -)`);
if (o.capture !== undefined && !VAR_RE.test(String(o.capture))) e.push(`${at}.capture: an UPPER_CASE name`);
if (o.continueOnError !== undefined && typeof o.continueOnError !== 'boolean') e.push(`${at}.continueOnError: true or false`);
out.push({ ...(o as unknown as FlowStep), id });
});
return out;
}
/** One flow definition (already parsed from YAML / JSON / TS) -> Flow, with every problem. */
export function toFlow(name: string, file: string, v: unknown): { flow: Flow | null; errors: string[] } {
const e: string[] = [];
const at = `${file}: ${name}`;
if (!ID_RE.test(name)) e.push(`${at}: flow name must be lowercase letters, digits, . _ -`);
if (typeof v !== 'object' || v === null || Array.isArray(v)) return { flow: null, errors: [...e, `${at}: a flow is a map (description, params, steps, ...)`] };
const o = v as Record<string, unknown>;
for (const k of Object.keys(o)) if (!FLOW_KEYS.includes(k)) e.push(`${at}: unknown key "${k}" (${FLOW_KEYS.join(', ')})`);
const params: Record<string, string> = {};
if (o.params !== undefined) {
if (typeof o.params !== 'object' || o.params === null || Array.isArray(o.params)) e.push(`${at}.params: a map of NAME: default`);
else for (const [k, val] of Object.entries(o.params)) {
if (!VAR_RE.test(k)) e.push(`${at}.params.${k}: UPPER_CASE names`);
params[k] = val === null || val === undefined ? '' : String(val);
}
}
const flow: Flow = {
name, file, ...(typeof o.description === 'string' ? { description: o.description } : {}), params,
before: asSteps(o.before, `${at}.before`, e), steps: asSteps(o.steps, `${at}.steps`, e), after: asSteps(o.after, `${at}.after`, e),
onError: asSteps(o.onError, `${at}.onError`, e), private: o.private === true,
};
if (!flow.steps.length) e.push(`${at}: no steps`);
return { flow, errors: e };
}
/** Flows of one file: a single-flow file (store/workflows/<name>.*) or a multi-flow file (storebound.workflows.*). */
export function flowsOfFile(file: string, data: unknown, single: string | null): { flows: Flow[]; errors: string[] } {
if (single) { const r = toFlow(single, file, data); return { flows: r.flow ? [r.flow] : [], errors: r.errors }; }
if (typeof data !== 'object' || data === null || Array.isArray(data)) return { flows: [], errors: [`${file}: expected a map of flows`] };
const d = data as Record<string, unknown>;
const map = (d.flows && typeof d.flows === 'object' && !Array.isArray(d.flows) ? d.flows : d) as Record<string, unknown>;
const flows: Flow[] = [], errors: string[] = [];
for (const [n, v] of Object.entries(map)) { const r = toFlow(n, file, v); if (r.flow) flows.push(r.flow); errors.push(...r.errors); }
return { flows, errors };
}
/** Steps with `flow:` sub-flows expanded (ids prefixed), with cycle and unknown-flow errors. */
export function expand(flow: Flow, all: Map<string, Flow>, part: 'before' | 'steps' | 'after' | 'onError' = 'steps', stack: string[] = []): { steps: FlowStep[]; errors: string[]; params: Record<string, string> } {
const errors: string[] = [];
const out: FlowStep[] = [];
const params: Record<string, string> = {};
for (const s of flow[part]) {
if (s.flow === undefined) { out.push(s); continue; }
const sub = all.get(s.flow);
if (!sub) { errors.push(`${flow.name}: step ${s.id}: no flow "${s.flow}"`); continue; }
if ([...stack, flow.name].includes(sub.name)) { errors.push(`${flow.name}: cycle ${[...stack, flow.name, sub.name].join(' -> ')}`); continue; }
const r = expand(sub, all, 'steps', [...stack, flow.name]);
errors.push(...r.errors);
for (const [k, v] of Object.entries({ ...sub.params, ...r.params })) if (!(k in params)) params[k] = v;
for (const x of r.steps) out.push({ ...x, id: `${s.id}.${x.id}`, ...(s.if && x.if ? { if: `${s.if} && ${x.if}` } : s.if ? { if: s.if } : {}) });
}
return { steps: out, errors, params };
}
/** Everything wrong with a set of flows: duplicate names and ids, refused args, unknown commands and variables. */
export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {}): string[] {
const e: string[] = [];
const all = new Map<string, Flow>();
for (const f of flows) { if (all.has(f.name)) e.push(`flow "${f.name}" is defined in ${all.get(f.name)!.file} and ${f.file}`); else all.set(f.name, f); }
for (const f of all.values()) {
const vars = new Set(Object.keys(f.params));
for (const part of ['before', 'steps', 'after', 'onError'] as const) {
const r = expand(f, all, part);
e.push(...r.errors);
for (const k of Object.keys(r.params)) vars.add(k);
const ids = new Set<string>();
for (const s of r.steps) {
const at = `${f.name}: ${part === 'steps' ? '' : `${part} `}step ${s.id}`;
if (ids.has(s.id)) e.push(`${at}: id used twice`);
ids.add(s.id);
for (const t of [s.run, s.sh, s.owner, s.if].filter((x): x is string => typeof x === 'string'))
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!) && !CONFIG_VARS.includes(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
if (s.capture) vars.add(s.capture);
if (s.run !== undefined) {
let argv: string[] = [];
try { argv = splitArgs(s.run); } catch (x) { e.push(`${at}: ${(x as Error).message}`); continue; }
if (!argv.length) { e.push(`${at}: empty run`); continue; }
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 (${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] ?? '');
if (!known && argv[0] !== 'storebound' && argv[0] !== 'flow') e.push(`${at}: "${w.slice(0, 2).join(' ')}" is not a storebound command`);
}
}
if (s.if !== undefined && !/\S/.test(s.if)) e.push(`${at}: empty if`);
}
}
}
return e;
}
// ---- variables, conditions ----
/** Variables the runner fills from the config (empty when the config has none), usable in `run` and `if`. */
export const CONFIG_VARS = ['config.testflight.internal'];
/** The required-param marker: `VERSION: "?"` in params means the run needs VERSION=... (no silent default). */
export const REQUIRED = '?';
/** Params still at the required marker (not given on the command line). Pure. */
export function missingParams(vars: Record<string, string>): string[] { return Object.keys(vars).filter(k => vars[k] === REQUIRED); }
/** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */
export function substitute(text: string, vars: Record<string, string>, env: Record<string, string | undefined> = {}): string {
return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m));
}
export interface CondCtx { apple: boolean; play: boolean; exists(path: string): boolean }
/** `if:` after substitution: apple | play | exists <path> | !x | a == b | a != b | x (non-empty, not false / 0); && joins. */
export function evalIf(expr: string, c: CondCtx): boolean {
return expr.split('&&').every(part => {
const s = part.trim();
if (s.startsWith('!')) return !evalIf(s.slice(1), c);
if (s === 'apple') return c.apple;
if (s === 'play') return c.play;
const ex = /^exists\s+(.+)$/.exec(s);
if (ex) return c.exists(ex[1]!.trim());
const cmp = /^(.*?)\s*(==|!=)\s*(.*)$/.exec(s);
if (cmp) { const eq = cmp[1]!.trim() === cmp[3]!.trim(); return cmp[2] === '==' ? eq : !eq; }
return s !== '' && s !== 'false' && s !== '0' && !/^\$\{[^}]*\}$/.test(s);
});
}
/** `KEY=value` positionals of `flow run <name> KEY=value ...`. */
export function parseParams(args: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const a of args) {
const i = a.indexOf('=');
if (i < 1 || !VAR_RE.test(a.slice(0, i))) throw new Error(`"${a}": parameters are NAME=value (UPPER_CASE name)`);
out[a.slice(0, i)] = a.slice(i + 1);
}
return out;
}
// ---- the plan ----
export type Mode = 'plan' | 'dry' | 'apply';
export interface PlannedStep {
id: string;
part: 'before' | 'steps' | 'after' | 'onError';
kind: StepKind;
/** What runs: storebound argv (run steps), the shell line (sh), the owner's click text (owner). */
argv?: string[];
shell?: string;
owner?: string;
/** Not run in this mode, and why. */
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> = {}, 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)) };
return { ...base, ...what, kind: kindOf(s), dryAdded: false, skip: `if ${s.if}: false` };
}
if (s.owner !== undefined) return { ...base, kind: 'owner', owner: substitute(s.owner, vars, env), dryAdded: false, skip: 'your click' };
if (s.sh !== undefined) {
const shell = substitute(s.sh, vars, env);
return { ...base, kind: 'shell', shell, dryAdded: false, ...(mode === 'apply' ? {} : { skip: 'shell step: runs with --apply' }) };
}
const argv = splitArgs(substitute(s.run ?? '', vars, env));
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');
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' } : {}) };
}
const PRODUCERS = ['apple build', 'play build'];
const CONSUMERS = ['apple upload', 'play upload'];
const AFTER_UPLOAD = ['apple testflight wait', 'apple testflight distribute'];
/** Stores whose artifact (IPA / AAB / PKG) or uploaded build only exists after a real earlier step ("apple", "apple:mac", "play"). */
export interface DryPending { built: Set<string>; uploaded: Set<string> }
const storeKey = (argv: string[]): string => {
const i = argv.indexOf('--platform');
const mac = (i >= 0 && /^(macos|mac)$/i.test(argv[i + 1] ?? '')) || argv.includes('--tauri');
return `${argv[0]}${mac ? ':mac' : ''}`;
};
/** Records a step that ran as a dry run: a dry build leaves no IPA / AAB. Pure. */
export function noteDry(p: PlannedStep, pending: DryPending): void {
if (!p.argv || !p.dryAdded || p.skip) return;
if (PRODUCERS.includes(words(p.argv).slice(0, 2).join(' '))) pending.built.add(storeKey(p.argv));
}
/** Why a step is only planned in a dry run: its input (the IPA / AAB, or the uploaded build) would come from an earlier
* step that did not really run. null = run it (always null with --apply: a real upload with no IPA still fails). Pure. */
export function plannedForDryInput(p: PlannedStep, mode: Mode, pending: DryPending): string | null {
if (mode === 'apply' || !p.argv || p.skip) return null;
const w = words(p.argv);
const k2 = w.slice(0, 2).join(' ');
if (CONSUMERS.includes(k2) && !p.argv.some(a => /\.(ipa|aab|pkg)$/.test(a)) && pending.built.has(storeKey(p.argv))) {
pending.uploaded.add(storeKey(p.argv));
return `planned: would upload the ${k2 === 'play upload' ? 'AAB' : storeKey(p.argv).endsWith(':mac') ? 'PKG' : 'IPA'} the ${w[0]} build step writes (dry run: nothing built)`;
}
if (AFTER_UPLOAD.includes(w.slice(0, 3).join(' ')) && pending.uploaded.has('apple')) return 'planned: the build is not on App Store Connect in a dry run';
return null;
}
/** Index of the step to start from: --from <id> (exact, or the first id under a sub-flow prefix). */
export function startIndex(steps: FlowStep[], from: string | undefined): number {
if (!from) return 0;
const i = steps.findIndex(s => s.id === from || s.id.startsWith(`${from}.`));
if (i < 0) throw new Error(`--from ${from}: no such step (${steps.map(s => s.id).join(', ')})`);
return i;
}
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. */
export function resumePoint(steps: FlowStep[], last: RunState | null): string | undefined {
if (!last) return undefined;
const done = new Set(last.results.filter(r => r.part === 'steps' && (r.status === 'ok' || r.status === 'skipped' || r.status === 'owner')).map(r => r.id));
return steps.find(s => !done.has(s.id))?.id;
}