261 lines
16 KiB
TypeScript
261 lines
16 KiB
TypeScript
// 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 [--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
|
|
// 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, type DryPending, expand, type Flow, flowsOfFile, kindOf, missingParams, type Mode, noteDry, parseParams, planStep, plannedForDryInput, 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<unknown> {
|
|
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<string> {
|
|
const r = rt.spawnSync([...selfCommand(), 'help'], { stdout: 'pipe', stderr: 'pipe' });
|
|
const names = new Set<string>(['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<CondCtx & { config: ShipConfig | null }> {
|
|
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<string, string> = { 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<string, string> = {}): 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<void> {
|
|
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<void> {
|
|
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<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 ')}`);
|
|
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<string, string> = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params };
|
|
const c = await condCtx(root);
|
|
vars['config.testflight.internal'] = (c.config?.apple?.testflight?.groups ?? []).filter(g => g.internal).map(g => g.name).join(',');
|
|
if (!args.has('resume')) {
|
|
const missing = missingParams(vars);
|
|
if (missing.length) throw new Error(`${name} needs ${missing.map(k => `${k}=<value>`).join(' ')} (storebound flow run ${name} ${missing.map(k => `${k}=...`).join(' ')})`);
|
|
}
|
|
const pending: DryPending = { built: new Set(), uploaded: new Set() };
|
|
const prebuilt = prebuildsDone();
|
|
const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true };
|
|
const env = process.env as Record<string, string | undefined>;
|
|
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<boolean> => {
|
|
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;
|
|
}
|
|
const planned = plannedForDryInput(p, mode, pending);
|
|
if (planned) {
|
|
r.status = 'planned'; r.detail = planned;
|
|
if (!json) log(TAG, `${label}: ${describe(p)} (${planned})`);
|
|
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';
|
|
noteDry(p, pending);
|
|
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<void> {
|
|
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> [NAME=value ...] [--dry | --apply] [--from <step> | --resume]');
|
|
}
|