/** * ship-bin-common — what gstack-ship-preflight and gstack-ship-release share: running git * and sibling bins with timeouts, flag reading, the effective ship policy, and the compact * result every step prints (KEY: lines, STOP: where the agent must judge, NEXT: what it * does next). The bins chain existing commands and never make a decision the /ship prose * assigns to the agent. */ import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation'; export interface Run { status: number | null; stdout: string; stderr: string } const done = (r: { status: number | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; error?: Error }): Run => ({ status: r.status, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') + (r.error ? r.error.message : '') }); export function git(cwd: string, args: string[], timeout = 120_000): Run { return done(spawnSync('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })); } export function bin(cwd: string, name: string, args: string[], timeout = 600_000): Run { return done(runScriptSync(path.join(BIN_DIR, name), args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 })); } /** Whether origin's configured URL names a host (https, ssh or scp form), before any insteadOf rewrite; a local path or file:// remote has none, so gh cannot map it to a repository. */ export function hostedOrigin(cwd: string): boolean { const url = git(cwd, ['config', '--get', 'remote.origin.url']).stdout.trim(); return /^(?:[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s]+@)?[^/:\s]+[:/]|[^@/\s]+@[^/:\s]+:)\S+$/i.test(url) && !/^file:/i.test(url); } /** gh with argv, never a shell, so a branch name cannot change what runs. */ export function gh(cwd: string, args: string[], timeout = 60_000): Run { return done(spawnSync('gh', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })); } export function sh(cwd: string, command: string, timeout = 1_800_000): Run { return done(runBashSync(command, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 })); } /** A sibling bin run without blocking, so independent test lanes run in parallel. */ export function binAsync(cwd: string, name: string, args: string[], timeout = 3_600_000): Promise { const inv = scriptInvocation(path.join(BIN_DIR, name), args); if (!inv.ok) return Promise.resolve({ status: null, stdout: '', stderr: inv.error }); return new Promise(resolve => { const child = spawn(inv.cmd, inv.argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; child.stdout.on('data', d => { stdout = (stdout + d).slice(-200_000); }); child.stderr.on('data', d => { stderr = (stderr + d).slice(-200_000); }); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); }); }); } export function flagReader(argv: readonly string[]) { const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : [])); return { all, flag: (n: string) => all(n).at(-1), has: (n: string) => argv.includes(n) }; } /** --lane