// `storebound release`: release trains from the terminal, read-only. The same stages as the desktop's Releases tab // (cli/lib/train.ts, cli/lib/app-status.ts are shared with it). // release status [--app a,b] [--versions] [--json] // per app: the version that reaches users and the next one, both stores on one train // (build -> testing -> review -> live -> rollout %); --versions lists every version the stores know // release plan [--app a] [--no-validate] [--json] // what is left for on each store, in fix order: config problems (check), the version on the store, // store blockers (validate), the next move (owner steps are named, never done), warnings // In an app folder: that app (its own credentials). In a workspace (storebound.workspace.json above): every app, or // --app a,b, with the credential profiles of the workspace. import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import type { Args } from './lib/args'; import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status'; import { CONFIG_NAMES } from './lib/config'; import type { AppStatus, ReleaseTrain } from './lib/desktop-protocol'; import { log, printJson, warn } from './lib/log'; import { runCommand, type RunResult } from './lib/runner'; import { textTable } from './lib/table'; import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVersion, trackText, versionHistory } from './lib/train'; import type { Finding } from './lib/validate-rules'; import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace'; import { appDir, appEnv, workspaceApps } from './workspace'; interface Target { name: string; dir: string; virtual: boolean; env: Record; problems: string[] } const USAGE = 'usage: storebound release status [--app a,b] [--versions] [--json] | release plan [--app a] [--no-validate] [--json]'; function configDir(from: string): string | null { for (let d = resolve(from); ; d = dirname(d)) { if (CONFIG_NAMES.some(n => existsSync(join(d, n)))) return d; if (dirname(d) === d) return null; } } /** The apps to read: this app folder, or the workspace's apps (filtered by --app). */ async function targets(args: Args): Promise<{ list: Target[]; concurrency: number }> { const cwd = process.cwd(); const here = configDir(cwd); const wsFile = findWorkspace(cwd, args.get('workspace')); // an app folder wins unless --app asks for the workspace (or the workspace file is closer than the config) if (here && !args.get('app') && (!wsFile || dirname(wsFile).length <= here.length)) { return { list: [{ name: here.split(/[\\/]/).pop() ?? here, dir: here, virtual: false, env: {}, problems: [] }], concurrency: 1 }; } if (!wsFile) throw new Error('no storebound.config.ts here and no storebound.workspace.json above (run it in an app folder or a workspace)'); const l = await loadWorkspace(cwd, args.get('workspace')); const n = Math.min(8, Math.max(1, l.ws.concurrency ?? 4)); const { apps: folders } = resolveApps(l); const summaries = new Map(); await pool(folders, n, async f => { const r = await runCommand(['inspect', '--json'], { cwd: f.path, machine: true }).result; if (r.code === 0 && r.data) summaries.set(f.id, r.data as AppStatus['summary']); }); const { apps, accounts } = workspaceApps(l, summaries); const { picked, unknown } = selectApps(apps.filter(a => !a.ignored), args.list('app')); if (unknown.length) throw new Error(`--app: no app named ${unknown.join(', ')} (storebound ws list shows the names)`); return { list: picked.map(a => { const { env, problems } = appEnv(a.unified, accounts); return { name: a.name, dir: appDir(a), virtual: a.virtual, env, problems }; }), concurrency: n }; } const exec = (env: Record) => (argv: string[], cwd: string): Promise => runCommand(argv, { cwd, env, machine: true }).result; async function readLive(t: Target): Promise { const e = emptyStatus(t.dir); if (t.virtual) e.virtual = true; try { await fillAppStatus(e, t.dir, { live: true, virtual: t.virtual, exec: exec(t.env) }); } catch (err) { e.errors.push((err as Error).message); } if (e.summary?.name) t.name = e.summary.name; return e; } export interface TrainRow { app: string; version: string; which: 'current' | 'next' | 'other'; ios: string; android: string } /** The trains of one app to show: current + next (the desktop's portfolio), or every version. */ export function trainsOf(s: AppStatus, all: boolean): { which: TrainRow['which']; train: ReleaseTrain }[] { if (all) { const cur = s.release.current?.version, next = s.release.next?.version; return versionHistory(s.release.all ?? []).map(t => ({ which: sameVersion(t.version, cur) ? 'current' as const : sameVersion(t.version, next) ? 'next' as const : 'other' as const, train: t })); } return [...(s.release.current ? [{ which: 'current' as const, train: s.release.current }] : []), ...(s.release.next ? [{ which: 'next' as const, train: s.release.next }] : [])]; } const cell = (store: 'ios' | 'android', t: ReleaseTrain, s: AppStatus): string => { if (s.storeErrors[store]) return 'unreadable'; const on = store === 'ios' ? s.summary?.apple : s.summary?.play; if (!on) return ''; const r = t[store]; if (!r) return '-'; const extra = [r.build ? `build ${r.build}` : '', store === 'android' && r.track && r.track !== 'production' ? r.track : ''].filter(Boolean).join(', '); return `${laneText(laneOf(store, r))}${extra ? ` (${extra})` : ''}`; }; async function status(args: Args): Promise { const { list, concurrency } = await targets(args); const all = args.has('versions'); const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) })); if (args.has('json')) { printJson({ apps: read.map(({ t, s }) => ({ app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], storeErrors: s.storeErrors, trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android, lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })), })), at: new Date().toISOString() }); } else { const rows: string[][] = []; for (const { t, s } of read) { const trains = trainsOf(s, all); const unread = Object.keys(s.storeErrors).length > 0; if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']); for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]); for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`); } console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows)); log('release', 'stages: build > testing > review > live > rollout (%); owner steps (submit, release, rollout) stay with the owner'); } if (read.some(({ s }) => s.errors.length || s.storeErrors.ios || s.storeErrors.android)) process.exitCode = 1; } const KIND: Record = { config: 'CONFIG ', store: 'STORE ', blocker: 'BLOCKER', owner: 'OWNER ', wait: 'wait ', warning: 'warn ', done: 'done ' }; export function formatPlan(app: string, p: ReleasePlan): string { const out: string[] = [`${app} ${p.version}${p.done ? ': live everywhere, nothing left' : ''}`]; let i = 0; const line = (s: PlanStep) => ` ${String(++i).padStart(2)}. ${KIND[s.kind]} ${s.text}${s.fix ? `\n -> ${s.fix}` : ''}`; if (p.shared.length) { out.push(' both stores (fix first):'); out.push(...p.shared.map(line)); } for (const sp of [p.ios, p.android]) { if (!sp) continue; out.push(` ${sp.store === 'ios' ? 'App Store' : 'Google Play'}: ${sp.release ? `${laneText(sp.lane)} [${trackText(sp.lane)}]` : 'not on the store'}`); out.push(...sp.steps.map(line)); } for (const u of p.unknown) out.push(` unknown: ${u}`); return out.join('\n'); } async function plan(args: Args): Promise { const version = args._[1]; if (!version || !/^\d+(\.\d+){0,3}$/.test(version)) throw new Error('release plan , e.g. release plan 2.4.0'); const { list, concurrency } = await targets(args); const plans = await pool(list, concurrency, async t => { const s = await readLive(t); const check = s.check ? { problems: s.check.problems, warnings: s.check.warnings } : null; let validate: Finding[] | null = null; // validate only while something is not yet submitted on a store (it reads the editable version / the Play edit) const pre = releasePlan({ version, platforms: { ios: !!s.summary?.apple, android: !!s.summary?.play }, check, validate: [], releases: s.release.all ?? null, storeErrors: s.storeErrors }); const needs = [pre.ios, pre.android].some(x => x && !x.done && (!x.release || ['draft', 'testing', 'rejected', 'other'].includes(x.release.phase))); if (needs && !args.has('no-validate') && !t.virtual && s.summary) { if (!args.has('json')) log('release', `${t.name}: validate (read-only, both stores)`); const r = await exec(t.env)(['validate', '--json', '--no-health'], t.dir); const d = r.data as { findings?: Finding[] } | undefined; if (d?.findings) validate = d.findings; else s.errors.push(`validate: ${lastLine(r.out) || `exit ${r.code}`}`); } else if (!needs) validate = []; const p = releasePlan({ version, platforms: { ios: !!s.summary?.apple, android: !!s.summary?.play }, check, validate, releases: s.summary ? s.release.all ?? null : null, storeErrors: s.storeErrors }); if (args.has('no-validate') && needs) p.unknown = p.unknown.map(u => (u.startsWith('store readiness') ? 'store readiness not checked (--no-validate)' : u)); return { t, s, p }; }); if (args.has('json')) printJson({ version, apps: plans.map(({ t, s, p }) => ({ app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], plan: p })) }); else { for (const { t, s, p } of plans) { console.log(formatPlan(t.name, p)); for (const e of [...t.problems, ...s.errors]) warn('release', `${t.name}: ${e}`); console.log(''); } } if (plans.some(({ s }) => s.errors.length)) process.exitCode = 1; } export async function run(args: Args): Promise { const sub = args._[0]; if (sub === 'status') return status(args); if (sub === 'plan') return plan(args); console.error(USAGE); process.exit(2); }