storebound/cli/lib/ship-step.ts

417 lines
27 KiB
TypeScript

// The "ship step" contract (docs/ship-step.md): how an external release pipeline (a Jenkins node, a VOX Ship job)
// calls `play build | play upload | play release | apple build | apple upload | apple submit` without a person:
// exit codes 0 ok, 1 failure, 2 usage, 3 needs the owner, 4 the store refused it, 7 nothing to do (SHIP_EXIT)
// --json exactly one final JSON line on stdout (ShipResult); every log line goes to stderr
// --result-file the same JSON written atomically to a file, also on failure
// --progress json one JSON line per phase on stderr: {"phase":"upload","pct":40,"message":"..."}
// --env-file KEY=VALUE secrets loaded before the credentials resolve (POSIX: mode 600 or stricter); every known
// secret value is scrubbed from stdout, stderr and the result file
// --version-from version.json: the version and build for the step ({version, build} | {versionName, versionCode} |
// {marketing, build})
// The commands keep their own logic; they report what they did with shipReport / shipNoop / shipPhase, and printJson
// hands its data here during a ship run. Everything else (classifying a thrown error, the final line, the file) is
// in this one module, so every ship command answers the same way.
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import type { Args } from './args';
import { type ConsolePage, consoleUrl } from './console-links';
/** The exit codes of a ship step (the same for every ship command). */
export const SHIP_EXIT = { ok: 0, failure: 1, usage: 2, owner: 3, refused: 4, noop: 7 } as const;
export type ShipExitCode = (typeof SHIP_EXIT)[keyof typeof SHIP_EXIT];
/** The commands that follow the contract: command key -> step name and store. */
export const SHIP_STEPS: Record<string, { step: string; store: 'apple' | 'play' }> = {
'play build': { step: 'build', store: 'play' },
'play upload': { step: 'upload', store: 'play' },
'play release': { step: 'release', store: 'play' },
'apple build': { step: 'build', store: 'apple' },
'apple upload': { step: 'upload', store: 'apple' },
'apple submit': { step: 'submit', store: 'apple' },
'apple resubmit': { step: 'resubmit', store: 'apple' },
'apple release': { step: 'release', store: 'apple' },
};
/** error.code of an exit 3: what the owner has to do. */
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED', 'OWNER_STEP'] as const;
export interface ShipErrorInfo { code: string; message: string; consoleUrl?: string }
export interface ShipResult {
ok: boolean;
step: string;
store: 'apple' | 'play';
track: string | null;
version: string | null;
build: string | null;
state: string | null;
url: string | null;
warnings: string[];
/** The file a build step wrote (AAB / IPA); absent for other steps. */
artifact?: string;
error?: ShipErrorInfo;
}
/** An error that knows its ship exit code (thrown by commands where the generic classification would guess). */
export class ShipStepError extends Error {
constructor(message: string, readonly exit: ShipExitCode, readonly code: string, readonly consoleUrl?: string) { super(message); }
}
type Fields = Partial<Pick<ShipResult, 'track' | 'version' | 'build' | 'state' | 'url' | 'artifact'>>;
interface Run {
key: string; step: string; store: 'apple' | 'play';
progress: boolean;
fields: Fields; extra?: Record<string, unknown>; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
}
const RUN = Symbol.for('storebound.shipRun');
const g = globalThis as unknown as Record<symbol, Run | null | undefined>;
/** The ship run going on, or null (a normal command run). */
export const shipRun = (): Run | null => g[RUN] ?? null;
/** What the command did: fields of the final result (later calls overwrite earlier ones). */
export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); }
/** Extra keys of the final result (e.g. the preCheck evidence), kept also when the step fails. */
export function shipExtra(x: Record<string, unknown>): void { const r = shipRun(); if (r) r.extra = { ...r.extra, ...x }; }
/** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */
export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } }
/** A warning for the result's warnings[] (the log line is the command's own). */
export function shipWarn(msg: string): void { const r = shipRun(); if (r && !r.warnings.includes(msg)) r.warnings.push(msg); }
// printJson (log.ts) puts its data in run.detail and ctx.declined puts its reason in run.declined, through the same
// globalThis key (no import cycle with log.ts / ctx.ts)
/** One progress line on stderr ({"phase","pct"?,"message"}) when the run asked for --progress json. */
export function shipPhase(phase: string, message: string, pct?: number): void {
const r = shipRun();
if (!r?.progress) return;
process.stderr.write(JSON.stringify({ phase, ...(pct === undefined ? {} : { pct: Math.max(0, Math.min(100, Math.round(pct))) }), message }) + '\n');
}
// ---- error classification ----
const page = (p: ConsolePage): string => consoleUrl(p);
/** The ship exit code, error code and console deep link of an error a ship command threw. Pure but for the console
* ids of the loaded config (console-links). */
export function classifyError(e: unknown, store: 'apple' | 'play'): { exit: ShipExitCode; error: ShipErrorInfo } {
if (e instanceof ShipStepError) return { exit: e.exit, error: { code: e.code, message: e.message, ...(e.consoleUrl ? { consoleUrl: e.consoleUrl } : {}) } };
const message = e instanceof Error ? e.message : String(e);
const m = message;
const apple = store === 'apple';
const owner = (code: string, url: string): { exit: ShipExitCode; error: ShipErrorInfo } => ({ exit: SHIP_EXIT.owner, error: { code, message, consoleUrl: url } });
// usage: a flag or argument problem (the dispatcher's own unknown-option exit 2 happens before this)
if (/^usage:|^--[a-z][\w-]*[ :<=]|is required\b|^unknown (option|command)/i.test(m)) return { exit: SHIP_EXIT.usage, error: { code: 'USAGE', message } };
// the owner: agreements, sign-in / key access, compliance answers, console-only steps
if (/REQUIRED_AGREEMENTS|agreement|Program License|terms of service|must (accept|agree)/i.test(m)) return owner('AGREEMENT_PENDING', apple ? page('asc.agreements') : page('play.app-list'));
if (/-> 401\b|NOT_AUTHORIZED|NOT_AUTHENTICATED|unauthori[sz]ed|invalid_grant|Google token:|two-factor|2FA|sign[ -]?in required|are not set \(env|is not set \(env|key not found|not a Google service account|-> 403\b|does not have permission|PERMISSION_DENIED|FORBIDDEN_ERROR/i.test(m))
return owner('SIGN_IN_REQUIRED', apple ? page('asc.keys') : page('play.api-access'));
if (/never been published|draft app|status draft|from the Console|in the Console|console plan|Play Console UI|create the app record in the web UI|sent for review from the Google Play Console/i.test(m))
return owner('CONSOLE_STEP', apple ? (/app record/i.test(m) ? page('asc.new-app') : page('asc.version')) : page('play.publishing'));
if (/export compliance|usesNonExemptEncryption|Missing Compliance|encryption answer/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.encryption'));
if (/data safety/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.data-safety'));
if (/app privacy/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.app-privacy'));
if (/content rating|IARC/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.content-rating'));
if (/age rating/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.age-rating'));
if (/target audience/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.target-audience'));
// the store said no: a 4xx answer, Apple's ITMS / state errors, a rejected upload or an invalid build
if (/-> (400|404|409|413|422)\b|ENTITY_ERROR|STATE_ERROR|ITMS-\d+|INVALID_ARGUMENT|FAILED_PRECONDITION|upload rejected|processingState (INVALID|FAILED)|is INVALID|Invalid Binary/i.test(m))
return { exit: SHIP_EXIT.refused, error: { code: 'STORE_REFUSED', message, consoleUrl: apple ? page('asc.testflight') : page('play.releases') } };
return { exit: SHIP_EXIT.failure, error: { code: 'FAILED', message } };
}
/** A legacy exit code a command (or a library) passed to process.exit / process.exitCode during a ship run. */
export function classifyExit(code: number, run: Pick<Run, 'declined' | 'detail' | 'store'>): { exit: ShipExitCode; error?: ShipErrorInfo } {
if (code === 0) return { exit: SHIP_EXIT.ok };
if (code === 2) return { exit: SHIP_EXIT.usage, error: { code: 'USAGE', message: 'usage error (see stderr)' } };
if (code === 3) return { exit: SHIP_EXIT.owner, error: { code: 'CONFIRMATION_REQUIRED', message: run.declined ?? 'a confirmation was declined or could not be asked (pass --yes in a pipeline)' } };
// a command that reported blockers (apple submit) and set exit 1: classify the blockers' text
const blockers = (run.detail as { blockers?: unknown } | undefined)?.blockers;
if (Array.isArray(blockers) && blockers.length) {
const c = classifyError(new Error(blockers.map(String).join('; ')), run.store);
return { exit: c.exit === SHIP_EXIT.usage ? SHIP_EXIT.failure : c.exit, error: c.exit === SHIP_EXIT.failure ? { ...c.error, code: 'BLOCKERS' } : c.error };
}
if (code === 7) return { exit: SHIP_EXIT.failure, error: { code: 'TIMEOUT', message: 'the step stopped at its timeout (see stderr); run it again to go on' } };
return { exit: SHIP_EXIT.failure, error: { code: 'FAILED', message: `exit ${code} (see stderr)` } };
}
// ---- the result ----
const str = (v: unknown): string | null => (v === undefined || v === null || v === '' ? null : String(v));
/** Fields of the result read from the data a command printed with --json (when it did not shipReport them). */
export function fieldsFromDetail(key: string, d: unknown): Fields {
if (!d || typeof d !== 'object') return {};
const x = d as Record<string, unknown>;
const dry = x.dry === true || x.dryRun === true;
switch (key) {
case 'play build': return { version: str(x.versionName), build: str(x.versionCode), state: x.aab ? 'built' : 'planned', ...(x.aab ? { artifact: String(x.aab) } : {}) };
case 'apple build': return { version: str(x.version), build: str(x.build), state: x.ipa ? 'built' : 'planned', ...(x.ipa ? { artifact: String(x.ipa) } : {}) };
case 'play upload': return { version: str(x.versionName), build: str(x.versionCode), track: str(x.track), state: x.uploaded ? str(x.status) : 'planned' };
case 'play release': {
const tracks = Array.isArray(x.tracks) ? x.tracks as { track: string; status: string }[] : [];
return { build: str(x.versionCode), version: str(x.name), track: tracks.map(t => t.track).join(',') || null, state: dry ? 'planned' : tracks.map(t => t.status).join(',') || null };
}
case 'apple upload': return { version: str(x.version), build: str(x.build), state: x.uploaded ? (str(x.processingState) ?? 'uploaded') : 'planned' };
case 'apple submit': case 'apple resubmit': {
const v = x.version as { versionString?: string; state?: string } | undefined;
const b = x.build as { number?: string } | undefined;
return { version: str(v?.versionString), build: str(b?.number), state: dry ? 'planned' : 'WAITING_FOR_REVIEW' };
}
case 'apple release': return { version: str(x.version), state: dry ? 'planned' : str(x.state) };
default: return {};
}
}
const DEFAULT_URL: Record<string, ConsolePage> = {
'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases',
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', 'apple release': 'asc.version',
};
/** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers
* of the old output, like a stage reading versionCode / sha256 of `play release --json`, keep working). */
export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo): ShipResult & Record<string, unknown> {
const f = { ...fieldsFromDetail(run.key, run.detail), ...run.fields };
let url: string | null = f.url ?? null;
if (!url) { try { url = consoleUrl(DEFAULT_URL[run.key] ?? (run.store === 'apple' ? 'asc.apps' : 'play.app-list')); } catch { url = null; } }
const own = run.detail && typeof run.detail === 'object' && !Array.isArray(run.detail) ? run.detail as Record<string, unknown> : {};
const out: ShipResult = {
ok: exit === SHIP_EXIT.ok || exit === SHIP_EXIT.noop,
step: run.step, store: run.store,
track: f.track ?? null, version: f.version ?? null, build: f.build ?? null,
state: exit === SHIP_EXIT.noop ? (f.state ?? 'unchanged') : error ? (f.state && f.state !== 'planned' ? f.state : 'failed') : f.state ?? null,
url, warnings: run.warnings,
...(f.artifact ? { artifact: f.artifact } : {}),
...(error ? { error } : {}),
};
return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
}
const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']);
// ---- secrets: --env-file and redaction ----
const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASS$|_KEY$|^ASC_P8$|P8|PRIVATE|CREDENTIAL|SERVICE_ACCOUNT|KEYSTORE_PASSWORD|AUTH)/i;
const extraSecrets = new Set<string>();
/** Parses KEY=VALUE lines (# comments, `export ` prefix, single or double quotes; \n in double quotes). */
export function parseEnvFile(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (!m) throw new Error(`--env-file: not KEY=VALUE: "${line.split('=')[0]!.slice(0, 40)}..."`);
let v = m[2]!;
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
else if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
else v = v.replace(/\s+#.*$/, '');
out[m[1]!] = v;
}
return out;
}
/** Why an env file may not be read (group / world readable on POSIX), or null. Windows: no mode check. */
export function envFileProblem(path: string, platform: string = process.platform): string | null {
if (!existsSync(path)) return `--env-file ${path}: no such file`;
if (platform === 'win32') return null;
const mode = statSync(path).mode & 0o777;
if (mode & 0o077) return `--env-file ${path}: mode ${mode.toString(8).padStart(3, '0')} lets others read it; chmod 600 ${path}`;
return null;
}
/** Loads an env file into process.env (it wins over the environment); its values are scrubbed from every output. */
export function loadEnvFile(path: string): string[] {
const why = envFileProblem(path);
if (why) throw new ShipStepError(why, SHIP_EXIT.usage, 'ENV_FILE');
const vars = parseEnvFile(readFileSync(path, 'utf8'));
for (const [k, v] of Object.entries(vars)) { process.env[k] = v; if (v) extraSecrets.add(v); }
return Object.keys(vars);
}
/** The secret values to scrub: every --env-file value and every env var whose name says secret. */
export function secretValues(env: Record<string, string | undefined> = process.env): string[] {
const vals = new Set<string>(extraSecrets);
for (const [k, v] of Object.entries(env)) if (v && SECRET_NAME.test(k)) vals.add(v);
const out = new Set<string>();
for (const v of vals) {
if (v.length < 6) continue; // "true", "1": too short to be a secret, too common to scrub
out.add(v);
const esc = JSON.stringify(v).slice(1, -1);
if (esc !== v) out.add(esc);
if (v.includes('\n')) for (const l of v.split(/\r?\n/)) if (l.trim().length >= 16) out.add(l.trim());
}
return [...out].sort((a, b) => b.length - a.length);
}
/** Replaces every secret value in the text with [redacted]. */
export function redact(text: string, secrets: string[] = secretValues()): string {
let t = text;
for (const s of secrets) if (t.includes(s)) t = t.split(s).join('[redacted]');
return t;
}
/** Scrubs secrets from everything written to stdout / stderr until the returned function is called. */
export function installRedaction(): () => void {
const secrets = secretValues();
if (!secrets.length) return () => {};
const wrap = (s: NodeJS.WriteStream): (() => void) => {
const orig = s.write.bind(s);
s.write = ((chunk: unknown, ...rest: unknown[]) => {
const text = typeof chunk === 'string' ? chunk : chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk);
return (orig as (c: string, ...r: unknown[]) => boolean)(redact(text, secrets), ...rest);
}) as typeof s.write;
return () => { s.write = orig as typeof s.write; };
};
const a = wrap(process.stdout), b = wrap(process.stderr);
// console.* in Bun writes to the fd directly, not through process.stdout.write: route it through the wrapped streams
const keep = { log: console.log, info: console.info, warn: console.warn, error: console.error };
const fmt = (args: unknown[]): string => args.map(x => (typeof x === 'string' ? x : x instanceof Error ? x.stack ?? x.message : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())).join(' ') + '\n';
console.log = (...x: unknown[]) => { process.stdout.write(fmt(x)); };
console.info = (...x: unknown[]) => { process.stdout.write(fmt(x)); };
console.warn = (...x: unknown[]) => { process.stderr.write(fmt(x)); };
console.error = (...x: unknown[]) => { process.stderr.write(fmt(x)); };
return () => { Object.assign(console, keep); a(); b(); };
}
// ---- --version-from ----
export interface VersionFrom { version: string | null; build: string | null; shape: string }
/** version.json shapes: {version, build} | {versionName, versionCode} | {marketing, build} (also marketingVersion /
* buildNumber); the build may be a number or a string. */
export function parseVersionFile(text: string, where = 'version.json'): VersionFrom {
let j: Record<string, unknown>;
try { j = JSON.parse(text) as Record<string, unknown>; } catch (e) { throw new ShipStepError(`--version-from ${where}: not JSON (${(e as Error).message})`, SHIP_EXIT.usage, 'USAGE'); }
if (!j || typeof j !== 'object' || Array.isArray(j)) throw new ShipStepError(`--version-from ${where}: a JSON object is needed`, SHIP_EXIT.usage, 'USAGE');
const pick = (...k: string[]): [string, string] | null => { for (const n of k) if (j[n] !== undefined && j[n] !== null && j[n] !== '') return [n, String(j[n])]; return null; };
const v = pick('version', 'versionName', 'marketing', 'marketingVersion');
const b = pick('build', 'versionCode', 'buildNumber');
if (!v && !b) throw new ShipStepError(`--version-from ${where}: no version / build ({version, build}, {versionName, versionCode} or {marketing, build})`, SHIP_EXIT.usage, 'USAGE');
if (v && !/^\d+(\.\d+){0,3}$/.test(v[1])) throw new ShipStepError(`--version-from ${where}: ${v[0]} "${v[1]}" is not x.y.z`, SHIP_EXIT.usage, 'USAGE');
if (b && !/^\d+(\.\d+)*$/.test(b[1])) throw new ShipStepError(`--version-from ${where}: ${b[0]} "${b[1]}" is not a build number`, SHIP_EXIT.usage, 'USAGE');
return { version: v?.[1] ?? null, build: b?.[1] ?? null, shape: `{${[v?.[0], b?.[0]].filter(Boolean).join(', ')}}` };
}
/** The flags --version-from stands for, per command. */
const VERSION_FLAGS: Record<string, { version?: string; build?: string; intBuild?: boolean }> = {
'play build': { version: 'version-name', build: 'version-code', intBuild: true },
'play upload': { build: 'version-code', intBuild: true },
'play release': { build: 'version-code', intBuild: true },
'apple build': { version: 'version', build: 'build-number' },
'apple upload': { build: 'build' },
'apple submit': { version: 'version', build: 'build' },
'apple resubmit': { version: 'version', build: 'build' },
'apple release': { version: 'version' },
};
/** The argv with the flags of --version-from added (a flag given by hand must agree with the file). */
export function versionArgv(key: string, argv: string[], hand: Args, v: VersionFrom): string[] {
const map = VERSION_FLAGS[key];
if (!map) return argv;
const out = [...argv];
const add = (flag: string | undefined, value: string | null, int = false): void => {
if (!flag || value === null) return;
if (int && !/^\d+$/.test(value)) throw new ShipStepError(`--version-from: build "${value}" is not an Android versionCode (an integer)`, SHIP_EXIT.usage, 'USAGE');
const given = hand.get(flag);
if (given !== undefined && given !== 'true') { if (given !== value) throw new ShipStepError(`--${flag} ${given} disagrees with --version-from (${value})`, SHIP_EXIT.usage, 'USAGE'); return; }
// play release with an .aab: the bundle carries its versionCode
if (key === 'play release' && flag === 'version-code' && hand._.some(x => x.endsWith('.aab'))) return;
out.push(`--${flag}`, value);
};
add(map.version, v.version);
add(map.build, v.build, map.intBuild);
return out;
}
// ---- the run ----
export interface ShipOptions { json: boolean; resultFile?: string; progress: boolean; envFile?: string; versionFrom?: string }
/** The ship flags of a run (also lists them for tools/gen-flags.ts: every ship command imports this module). */
export function shipOptions(a: Args): ShipOptions {
const p = a.get('progress');
if (p !== undefined && p !== 'json' && p !== 'text') throw new ShipStepError('--progress json | text', SHIP_EXIT.usage, 'USAGE');
const file = (n: string, v: string | undefined): string | undefined => { if (v === 'true') throw new ShipStepError(`--${n} <path> needs a path`, SHIP_EXIT.usage, 'USAGE'); return v; };
return { json: a.has('json'), resultFile: file('result-file', a.get('result-file')), progress: p === 'json', envFile: file('env-file', a.get('env-file')), versionFrom: file('version-from', a.get('version-from')) };
}
/** True when this run uses the contract (any ship flag): plain runs keep their old output and exit codes. A --json run
* under `storebound serve` / flows (STOREBOUND_MACHINE=1: the desktop reads the command's own result line) keeps
* the old result unless another ship flag asks for the contract. */
export const usesContract = (o: ShipOptions, env: Record<string, string | undefined> = process.env): boolean =>
(o.json && env.STOREBOUND_MACHINE !== '1') || !!o.resultFile || o.progress || !!o.envFile || !!o.versionFrom;
/** Writes the result file atomically (temp file in the same folder, then rename). */
export function writeResultFile(path: string, data: string): void {
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
writeFileSync(tmp, data, { mode: 0o600 });
renameSync(tmp, path);
}
class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } }
/**
* Runs one ship command under the contract. `prepare` turns the raw argv into the command's Args (after --env-file
* and --version-from); `body` runs the command. Returns the ship exit code; prints the final line / writes the file.
*/
export async function runShipStep(key: string, rest: string[], parse: (argv: string[]) => Args, body: (args: Args) => Promise<void>): Promise<ShipExitCode> {
const meta = SHIP_STEPS[key]!;
const run: Run = { key, step: meta.step, store: meta.store, progress: false, fields: {}, noop: false, warnings: [] };
const prevRun = g[RUN];
g[RUN] = run;
let opts: ShipOptions = { json: rest.includes('--json'), progress: false };
let unredact = (): void => {};
const exitOrig = process.exit;
let exit: ShipExitCode = SHIP_EXIT.ok;
let error: ShipErrorInfo | undefined;
const before = process.exitCode;
process.exitCode = 0;
try {
let args = parse(rest);
opts = shipOptions(args);
run.progress = opts.progress;
if (opts.envFile) loadEnvFile(opts.envFile);
unredact = installRedaction();
if (opts.versionFrom) {
if (!existsSync(opts.versionFrom)) throw new ShipStepError(`--version-from ${opts.versionFrom}: no such file`, SHIP_EXIT.usage, 'USAGE');
const v = parseVersionFile(readFileSync(opts.versionFrom, 'utf8'), opts.versionFrom);
args = parse(versionArgv(key, rest, args, v));
shipReport({ version: v.version ?? undefined, build: v.build ?? undefined });
}
shipPhase('start', `storebound ${key}`);
process.exit = ((c?: number) => { throw new ExitSignal(c ?? 0); }) as typeof process.exit;
await body(args);
const c = Number(process.exitCode ?? 0);
const k = classifyExit(c, run);
exit = k.exit; error = k.error;
if (exit === SHIP_EXIT.ok && run.noop) exit = SHIP_EXIT.noop;
} catch (e) {
if (e instanceof ExitSignal) { const k = classifyExit(e.code, run); exit = k.exit; error = k.error; if (exit === SHIP_EXIT.ok && run.noop) exit = SHIP_EXIT.noop; }
else {
const k = classifyError(e, run.store);
exit = k.exit; error = k.error;
console.error(`\nerror: ${(e as Error).message ?? String(e)}`);
}
} finally { process.exit = exitOrig; }
const result = buildResult(run, exit, error);
shipPhase('done', exit === SHIP_EXIT.ok ? 'ok' : exit === SHIP_EXIT.noop ? 'nothing to do' : `${error?.code ?? 'failed'}`);
const line = redact(JSON.stringify(result));
try {
if (opts.resultFile) writeResultFile(opts.resultFile, line + '\n');
} catch (e) { console.error(`--result-file ${opts.resultFile}: ${(e as Error).message}`); if (exit === SHIP_EXIT.ok) exit = SHIP_EXIT.failure; }
if (opts.json) process.stdout.write(line + '\n');
unredact();
g[RUN] = prevRun ?? null;
process.exitCode = before ?? 0;
return exit;
}
/** The answer of a ship step stopped by the dispatcher (an unknown flag): the usage result line and file, exit 2. */
export function usageResult(key: string, argv: string[], message: string): void {
const meta = SHIP_STEPS[key];
if (!meta) return;
const run: Run = { key, step: meta.step, store: meta.store, progress: false, fields: {}, noop: false, warnings: [] };
const line = JSON.stringify(buildResult(run, SHIP_EXIT.usage, { code: 'USAGE', message }));
const at = argv.findIndex(x => x === '--result-file' || x.startsWith('--result-file='));
const file = at < 0 ? undefined : argv[at]!.includes('=') ? argv[at]!.slice(argv[at]!.indexOf('=') + 1) : argv[at + 1];
if (file && !file.startsWith('--')) { try { writeResultFile(file, line + '\n'); } catch { /* the exit code still says usage */ } }
if (argv.includes('--json')) process.stdout.write(line + '\n');
}