// 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=value ...] [--dry | --apply [--yes]] [--from | --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: ", never ok. // --from : 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/.json) // Progress: "[flow] 3/9 upload: storebound apple upload --no-wait --dry" before each step, the step's own output // indented, then ok / failed with the time. Exit 1 when a step failed (and had no continueOnError). // build.prebuild runs once per run: after a real apple build / play build step, its prebuild command goes to the later // steps in STOREBOUND_PREBUILD_DONE and they skip it. import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import type { Args } from './lib/args'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config'; 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'; import { parseYaml } from './lib/yaml'; import * as rt from './lib/rt'; const TAG = 'flow'; const EXT = ['.yml', '.yaml', '.json', '.ts']; /** Project folder: the folder of the nearest storebound.config.* above the working directory, else the working directory. */ export function projectRoot(start = process.cwd()): string { for (let d = start; ; d = dirname(d)) { if (CONFIG_NAMES.some(n => existsSync(join(d, n))) || existsSync(join(d, 'store', 'workflows'))) return d; if (dirname(d) === d) return start; } } async function readData(path: string): Promise { if (path.endsWith('.ts')) { const m = await rt.importConfigModule(path) as { default?: unknown }; return m.default; } const text = readFileSync(path, 'utf8'); if (path.endsWith('.json')) return JSON.parse(text); return parseYaml(text); } /** Every flow file of a project, read and checked. */ export async function loadFlows(root: string): Promise<{ flows: Flow[]; errors: string[]; files: string[] }> { const flows: Flow[] = [], errors: string[] = [], files: string[] = []; const add = async (path: string, single: string | null) => { const rel = relative(root, path); files.push(rel); try { const r = flowsOfFile(rel, await readData(path), single); flows.push(...r.flows); errors.push(...r.errors); } catch (e) { errors.push(`${rel}: ${(e as Error).message}`); } }; for (const ext of EXT) { const p = join(root, `storebound.workflows${ext}`); if (existsSync(p)) await add(p, null); } const dir = join(root, 'store', 'workflows'); if (existsSync(dir)) for (const f of readdirSync(dir).sort()) { const ext = EXT.find(x => f.endsWith(x)); if (ext) await add(join(dir, f), f.slice(0, -ext.length)); } return { flows, errors, files }; } /** Command names of this storebound (from `storebound help`), for `flow validate`. */ function commandNames(): Set { const r = rt.spawnSync([...selfCommand(), 'help'], { stdout: 'pipe', stderr: 'pipe' }); const names = new Set(['help', 'version']); for (const l of r.stdout.toString().split('\n')) if (/^ {2}[a-z]/.test(l)) { const n = l.slice(2, 27).trim(); if (n && !n.includes(':')) names.add(n); } return names; } function statePath(root: string, name: string): string { return join(root, '.storebound', 'flows', `${name}.json`); } function readState(root: string, name: string): RunState | null { try { return JSON.parse(readFileSync(statePath(root, name), 'utf8')) as RunState; } catch { return null; } } async function condCtx(root: string): Promise { let config: ShipConfig | null = null; try { config = (await loadConfig(undefined, root)).config; } catch { /* no config: store conditions are false */ } 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 { if (!config || argv.includes('--dry') || argv.includes('--no-prebuild')) return null; const store = argv[0] === 'apple' && argv[1] === 'build' ? 'apple' : argv[0] === 'play' && argv[1] === 'build' ? 'play' : null; return store ? prebuildFor(config, store) ?? null : null; } const KIND_LABEL: Record = { read: 'read', write: 'write', wait: 'wait', owner: 'your click', shell: 'shell' }; function describe(p: PlannedStep): string { if (p.owner !== undefined) return p.owner; if (p.shell !== undefined) return `sh: ${p.shell}`; return `storebound ${p.argv!.join(' ')}`; } /** Runs one storebound step (or shell line) with stdin attached, so the command's own confirmation works; output indented. */ async function exec(p: PlannedStep, root: string, quiet: boolean, extraEnv: Record = {}): Promise<{ code: number; last: string; out: string }> { const cmd = p.shell !== undefined ? ['sh', '-c', p.shell] : [...selfCommand(), ...p.argv!]; const proc = rt.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe', env: childEnv(extraEnv) }); const lines: string[] = []; let last = ''; const each = (line: string, kind: 'stdout' | 'stderr') => { lines.push(line); if (kind === 'stdout' && line.trim()) last = line.trim(); if (!quiet) (kind === 'stderr' ? console.error : console.log)(` ${line}`); }; await Promise.all([pump(proc.stdout, 'stdout', each), pump(proc.stderr, 'stderr', each)]); return { code: await proc.exited, last, out: lines.join('\n') }; } async function list(args: Args, root: string): Promise { const { flows, errors, files } = await loadFlows(root); const all = new Map(flows.map(f => [f.name, f] as const)); const shown = flows.filter(f => !f.private); const rows = shown.map(f => ({ name: f.name, file: f.file, description: f.description ?? '', params: f.params, steps: expand(f, all).steps.map(s => ({ id: s.id, kind: kindOf(s), what: s.run ?? s.sh ?? s.owner ?? '' })) })); if (args.has('json')) { printJson({ root, files, flows: rows, errors }); return; } if (!files.length) { log(TAG, 'no flows here: storebound init --profile app|game writes store/workflows/release.yml and beta.yml, or add storebound.workflows.yml'); return; } for (const f of rows) { console.log(`\n${f.name} (${f.file})${f.description ? ` ${f.description}` : ''}`); if (Object.keys(f.params).length) console.log(` params: ${Object.entries(f.params).map(([k, v]) => `${k}=${v || '""'}`).join(' ')}`); f.steps.forEach((s, i) => console.log(` ${String(i + 1).padStart(2)}. ${s.id.padEnd(18)} ${KIND_LABEL[s.kind]!.padEnd(10)} ${s.what}`)); } for (const e of errors) warn(TAG, e); if (errors.length) process.exitCode = 1; } async function validate(args: Args, root: string): Promise { const { flows, errors, files } = await loadFlows(root); const problems = [...errors, ...validateFlows(flows, { commands: commandNames() })]; if (args.has('json')) printJson({ root, files, flows: flows.map(f => f.name), problems, ok: !problems.length }); else { for (const p of problems) warn(TAG, p); done(TAG, problems.length ? `${problems.length} problem(s) in ${files.length} file(s)` : `${flows.length} flow(s) in ${files.length} file(s): ok`); } if (problems.length) process.exitCode = 1; } async function runFlow(args: Args, root: string): Promise { const name = args._[1]; if (!name) throw new Error('usage: storebound flow run [NAME=value ...] [--dry | --apply] [--from | --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 ')}`); const all = new Map(flows.map(f => [f.name, f] as const)); const flow = all.get(name); if (!flow || flow.private) throw new Error(`no flow "${name}" (${flows.filter(f => !f.private).map(f => f.name).join(', ') || 'none: storebound flow list'})`); const mode: Mode = args.has('apply') ? 'apply' : args.has('dry') ? 'plan' : 'dry'; const json = args.has('json'); const parts = { before: expand(flow, all, 'before').steps, steps: expand(flow, all, 'steps').steps, after: expand(flow, all, 'after').steps, onError: expand(flow, all, 'onError').steps }; const last = readState(root, name); if (args.has('resume') && args.has('from')) throw new Error('--from or --resume, not both'); const from = args.has('resume') ? resumePoint(parts.steps, last) : args.get('from'); if (args.has('resume') && !from) throw new Error(last ? `the last run of ${name} finished every step; nothing to resume` : `no earlier run of ${name} to resume`); const begin = startIndex(parts.steps, from); const params = parseParams(args._.slice(2)); const subParams = expand(flow, all, 'steps').params; const vars: Record = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params }; const c = await condCtx(root); const prebuilt = prebuildsDone(); const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true }; const env = process.env as Record; const save = () => { if (mode === 'plan') return; mkdirSync(dirname(statePath(root, name)), { recursive: true }); writeFileSync(statePath(root, name), JSON.stringify(state, null, 2) + '\n'); }; if (!json) { log(TAG, `${name}${flow.description ? `: ${flow.description}` : ''}`); log(TAG, mode === 'plan' ? 'plan only (--dry): nothing runs' : mode === 'dry' ? 'reads run, writes run as dry runs (--apply runs them for real), owner steps are yours' : '--apply: writes run for real; each command asks before it changes a store'); if (from) log(TAG, `starting at ${parts.steps[begin]!.id}${args.has('resume') ? ' (--resume)' : ''}`); } // results of the steps before --from carry over, so a later --resume knows they are done if (from && last) state.results.push(...last.results.filter(r => r.part === 'steps' && parts.steps.slice(0, begin).some(s => s.id === r.id))); const runPart = async (part: PlannedStep['part'], steps: typeof parts.steps, offset = 0): Promise => { for (let i = offset; i < steps.length; i++) { const s = steps[i]!; 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) { r.status = p.kind === 'owner' && !p.skip.startsWith('if ') ? 'owner' : mode === 'plan' && !p.skip.startsWith('if ') && p.kind !== 'shell' ? 'planned' : 'skipped'; r.detail = p.skip; if (!json) log(TAG, `${label}: ${p.kind === 'owner' && r.status === 'owner' ? `YOUR CLICK ${describe(p)}` : `${describe(p)} (${p.skip})`}`); state.results.push(r); save(); continue; } if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`); const t = Date.now(); const x = await exec(p, root, json, prebuilt.size ? { [PREBUILD_DONE_ENV]: [...prebuilt].join('\n') } : {}); r.exitCode = x.code; r.ms = Date.now() - t; if (x.code === 0) { r.status = 'ok'; const pre = p.argv ? prebuildRan(p.argv, c.config) : null; if (pre) prebuilt.add(pre); if (p.capture) { if (/^[\w.:/@+-]{1,200}$/.test(x.last)) { vars[p.capture] = x.last; r.detail = `${p.capture}=${x.last}`; } else warn(TAG, `${s.id}: capture ${p.capture}: the last output line is not a single value ("${x.last.slice(0, 60)}")`); } if (!json) log(TAG, `${label}: ok (${(r.ms / 1000).toFixed(1)}s)${r.detail ? ` ${r.detail}` : ''}`); } else { 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 === 'declined' || (r.status === 'failed' && !p.continueOnError)) return false; } return true; }; let ok = (await runPart('before', parts.before)) && (await runPart('steps', parts.steps, begin)); if (ok) ok = await runPart('after', parts.after); else if (parts.onError.length) await runPart('onError', parts.onError); state.ok = ok; state.finishedAt = new Date().toISOString(); save(); const owner = state.results.filter(r => r.status === 'owner'); 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.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'})`); } if (!ok) process.exitCode = 1; } export async function run(args: Args): Promise { const root = projectRoot(); const sub = args._[0] ?? 'list'; if (sub === 'list') return list(args, root); if (sub === 'validate') return validate(args, root); if (sub === 'run') return runFlow(args, root); throw new Error('usage: storebound flow list | validate | run [NAME=value ...] [--dry | --apply] [--from | --resume]'); }