@mindlace/chreode 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ import { defaultExec, lines } from '../run/exec.js';
2
+ /** Successful signoffs are recorded as notes under refs/notes/chreode. */
3
+ export const NOTES_REF = 'chreode';
4
+ export function hasNote(sha, exec = defaultExec) {
5
+ try {
6
+ exec('git', ['notes', `--ref=${NOTES_REF}`, 'show', sha]);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /** The nearest commit on this branch (not on origin/<baseBranch>) carrying a signoff note;
14
+ * else the merge-base with origin/<baseBranch>. */
15
+ export function resolveBase(baseBranch, exec = defaultExec) {
16
+ const head = exec('git', ['rev-parse', 'HEAD']).trim();
17
+ const upstream = `origin/${baseBranch}`;
18
+ for (const sha of lines(exec('git', ['rev-list', '--topo-order', 'HEAD', `^${upstream}`]))) {
19
+ if (hasNote(sha, exec))
20
+ return { base: sha, head, source: 'note' };
21
+ }
22
+ return { base: exec('git', ['merge-base', 'HEAD', upstream]).trim(), head, source: 'merge-base' };
23
+ }
24
+ export function resolveRef(ref, exec = defaultExec) {
25
+ return exec('git', ['rev-parse', '--verify', `${ref}^{commit}`]).trim();
26
+ }
27
+ /** Paths changed between base and head. --no-renames lists both sides of a rename, so a
28
+ * file moved out of a rule's reach still fires that rule; deletions are included. */
29
+ export function committedPaths(base, head, exec = defaultExec) {
30
+ if (base === head)
31
+ return [];
32
+ const out = exec('git', ['diff', '--name-only', '--no-renames', '-z', `${base}..${head}`]);
33
+ return out.split('\0').filter((p) => p !== '');
34
+ }
35
+ export function isWorktree(exec = defaultExec) {
36
+ const [gitDir, commonDir] = lines(exec('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir']));
37
+ return gitDir !== commonDir;
38
+ }
39
+ export function writeNote(sha, ranLabels, exec = defaultExec) {
40
+ const summary = ranLabels.length > 0 ? ranLabels.join(' ') : 'no-op';
41
+ exec('git', ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-m', summary, sha]);
42
+ }
@@ -0,0 +1,21 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ export type MergeOutcome = {
3
+ kind: 'skipped';
4
+ } | {
5
+ kind: 'up-to-date';
6
+ } | {
7
+ kind: 'merged';
8
+ } | {
9
+ kind: 'conflict';
10
+ } | {
11
+ kind: 'failed';
12
+ message: string;
13
+ };
14
+ /**
15
+ * Fold the latest origin/<baseBranch> into the branch so what is signed off is what will
16
+ * land. Merge, never rebase: existing SHAs (and their notes) survive and the push stays a
17
+ * fast-forward. On conflict the merge is left in progress for the user to resolve.
18
+ */
19
+ export declare function mergeBaseBranch(baseBranch: string, opts: {
20
+ noMerge: boolean;
21
+ }, exec?: Exec, warn?: (m: string) => void): MergeOutcome;
@@ -0,0 +1,33 @@
1
+ import { defaultExec } from '../run/exec.js';
2
+ /**
3
+ * Fold the latest origin/<baseBranch> into the branch so what is signed off is what will
4
+ * land. Merge, never rebase: existing SHAs (and their notes) survive and the push stays a
5
+ * fast-forward. On conflict the merge is left in progress for the user to resolve.
6
+ */
7
+ export function mergeBaseBranch(baseBranch, opts, exec = defaultExec, warn = (m) => console.warn(m)) {
8
+ if (opts.noMerge)
9
+ return { kind: 'skipped' };
10
+ const upstream = `origin/${baseBranch}`;
11
+ try {
12
+ exec('git', ['fetch', 'origin', baseBranch]);
13
+ }
14
+ catch (err) {
15
+ warn(`[chreode] git fetch origin ${baseBranch} failed (${err.message.trim()}); ` +
16
+ `continuing with the local view of ${upstream}`);
17
+ }
18
+ if (exec('git', ['rev-list', `HEAD..${upstream}`]).trim() === '')
19
+ return { kind: 'up-to-date' };
20
+ try {
21
+ exec('git', ['merge', '--no-edit', upstream]);
22
+ }
23
+ catch (err) {
24
+ try {
25
+ exec('git', ['rev-parse', '-q', '--verify', 'MERGE_HEAD']);
26
+ return { kind: 'conflict' };
27
+ }
28
+ catch {
29
+ return { kind: 'failed', message: err.message.trim() };
30
+ }
31
+ }
32
+ return { kind: 'merged' };
33
+ }
@@ -0,0 +1,20 @@
1
+ import type { ResolvedConfig } from '../config/types.js';
2
+ import { type Exec } from '../run/exec.js';
3
+ import { type RepoInfo } from './status.js';
4
+ export declare const DEFAULT_BRANCH_PROTECTION: Record<string, unknown>;
5
+ export declare const DEFAULT_REPO_SETTINGS: Record<string, unknown>;
6
+ /** Defaults shallow-merged with config.protection; the status context is always required. */
7
+ export declare function protectionPayloads(config: ResolvedConfig): {
8
+ branch: Record<string, unknown>;
9
+ repo: Record<string, unknown>;
10
+ };
11
+ export interface ProtectDeps {
12
+ exec: Exec;
13
+ log: (m: string) => void;
14
+ repo: () => RepoInfo;
15
+ }
16
+ /** Idempotently require the signoff status on the base branch and set squash-only merges. */
17
+ export declare function runProtect(config: ResolvedConfig, opts: {
18
+ dryRun: boolean;
19
+ repo: RepoInfo | null;
20
+ }, overrides?: Partial<ProtectDeps>): number;
@@ -0,0 +1,90 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '../config/validate.js';
5
+ import { defaultExec } from '../run/exec.js';
6
+ import { repoInfo } from './status.js';
7
+ export const DEFAULT_BRANCH_PROTECTION = {
8
+ enforce_admins: true,
9
+ required_pull_request_reviews: {
10
+ dismiss_stale_reviews: true,
11
+ require_code_owner_reviews: false,
12
+ required_approving_review_count: 0
13
+ },
14
+ restrictions: null,
15
+ allow_force_pushes: false,
16
+ allow_deletions: false,
17
+ required_linear_history: true,
18
+ required_conversation_resolution: false
19
+ };
20
+ export const DEFAULT_REPO_SETTINGS = {
21
+ allow_squash_merge: true,
22
+ allow_merge_commit: false,
23
+ allow_rebase_merge: false,
24
+ squash_merge_commit_title: 'PR_TITLE',
25
+ squash_merge_commit_message: 'PR_BODY',
26
+ delete_branch_on_merge: true
27
+ };
28
+ /** Defaults shallow-merged with config.protection; the status context is always required. */
29
+ export function protectionPayloads(config) {
30
+ const override = config.protection.branch ?? {};
31
+ const checks = isRecord(override.required_status_checks) ? override.required_status_checks : {};
32
+ const extra = Array.isArray(checks.contexts)
33
+ ? checks.contexts.filter((c) => typeof c === 'string')
34
+ : [];
35
+ return {
36
+ branch: {
37
+ ...DEFAULT_BRANCH_PROTECTION,
38
+ ...override,
39
+ required_status_checks: {
40
+ strict: true,
41
+ ...checks,
42
+ contexts: [...new Set([config.statusContext, ...extra])]
43
+ }
44
+ },
45
+ repo: { ...DEFAULT_REPO_SETTINGS, ...(config.protection.repo ?? {}) }
46
+ };
47
+ }
48
+ /** Idempotently require the signoff status on the base branch and set squash-only merges. */
49
+ export function runProtect(config, opts, overrides = {}) {
50
+ const d = {
51
+ exec: defaultExec,
52
+ log: (m) => console.log(m),
53
+ repo: () => repoInfo(defaultExec),
54
+ ...overrides
55
+ };
56
+ let repo = opts.repo;
57
+ if (repo === null) {
58
+ try {
59
+ repo = d.repo();
60
+ }
61
+ catch (err) {
62
+ if (!opts.dryRun)
63
+ throw err;
64
+ d.log('[chreode] gh repo view failed; showing payloads for <owner>/<repo>');
65
+ repo = { owner: '<owner>', name: '<repo>' };
66
+ }
67
+ }
68
+ const { branch, repo: settings } = protectionPayloads(config);
69
+ const apply = (method, path, body) => {
70
+ if (opts.dryRun) {
71
+ d.log(`[dry-run] ${method} ${path}\n${JSON.stringify(body, null, 2)}`);
72
+ return;
73
+ }
74
+ const dir = mkdtempSync(join(tmpdir(), 'chreode-protect-'));
75
+ const file = join(dir, 'payload.json');
76
+ writeFileSync(file, JSON.stringify(body));
77
+ try {
78
+ d.exec('gh', ['api', '-X', method, path, '--input', file]);
79
+ }
80
+ finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ };
84
+ const target = `${repo.owner}/${repo.name}`;
85
+ d.log(`[chreode] protecting ${target} ${config.baseBranch} (dry-run=${opts.dryRun})`);
86
+ apply('PUT', `repos/${target}/branches/${config.baseBranch}/protection`, branch);
87
+ apply('PATCH', `repos/${target}`, settings);
88
+ d.log(`[chreode] ${config.baseBranch} now requires ${config.statusContext}`);
89
+ return 0;
90
+ }
@@ -0,0 +1,24 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ /** GitHub rejects longer descriptions with a 422; truncate rather than fail the post. */
3
+ export declare const STATUS_DESCRIPTION_MAX = 140;
4
+ /** What made a run less than a full signoff; every part is named in the status. */
5
+ export interface Disclosure {
6
+ /** Steps dropped by --skip. */
7
+ skipped?: readonly string[];
8
+ /** Policed steps that passed only because --allow-stderr waived their stderr. */
9
+ allowedStderr?: readonly string[];
10
+ /** The --base ref, when the diff did not start from the last signoff. */
11
+ base?: string | null;
12
+ }
13
+ /** Honest about --skip, --allow-stderr and --base, so a partial run cannot wear the same
14
+ * green as a full one. */
15
+ export declare function statusDescription(signer: string, disclosure: Disclosure): string;
16
+ export interface RepoInfo {
17
+ owner: string;
18
+ name: string;
19
+ }
20
+ export declare function parseRepo(s: string): RepoInfo;
21
+ export declare function repoInfo(exec?: Exec): RepoInfo;
22
+ export declare function statusArgs(repo: RepoInfo, sha: string, context: string, description: string): string[];
23
+ export declare function postStatus(repo: RepoInfo, sha: string, context: string, description: string, exec?: Exec): void;
24
+ export declare function prUrl(exec?: Exec): string | null;
@@ -0,0 +1,54 @@
1
+ import { defaultExec } from '../run/exec.js';
2
+ /** GitHub rejects longer descriptions with a 422; truncate rather than fail the post. */
3
+ export const STATUS_DESCRIPTION_MAX = 140;
4
+ /** Honest about --skip, --allow-stderr and --base, so a partial run cannot wear the same
5
+ * green as a full one. */
6
+ export function statusDescription(signer, disclosure) {
7
+ const { skipped = [], allowedStderr = [], base = null } = disclosure;
8
+ const parts = [
9
+ ...(skipped.length > 0 ? [`skipped: ${skipped.join(', ')}`] : []),
10
+ ...(allowedStderr.length > 0 ? [`allowed stderr: ${allowedStderr.join(', ')}`] : []),
11
+ ...(base !== null ? [`base=${base}`] : [])
12
+ ];
13
+ const desc = parts.length === 0
14
+ ? `signed off by ${signer}`
15
+ : `signed off by ${signer} (${parts.join('; ')})`;
16
+ if (desc.length <= STATUS_DESCRIPTION_MAX)
17
+ return desc;
18
+ return `${desc.slice(0, STATUS_DESCRIPTION_MAX - 3)}...`;
19
+ }
20
+ export function parseRepo(s) {
21
+ const m = /^([\w.-]+)\/([\w.-]+)$/.exec(s);
22
+ if (!m)
23
+ throw new Error(`repo must look like owner/name, got "${s}"`);
24
+ return { owner: m[1], name: m[2] };
25
+ }
26
+ export function repoInfo(exec = defaultExec) {
27
+ const parsed = JSON.parse(exec('gh', ['repo', 'view', '--json', 'owner,name']));
28
+ return { owner: parsed.owner.login, name: parsed.name };
29
+ }
30
+ export function statusArgs(repo, sha, context, description) {
31
+ return [
32
+ 'api',
33
+ '-X',
34
+ 'POST',
35
+ `repos/${repo.owner}/${repo.name}/statuses/${sha}`,
36
+ '-f',
37
+ 'state=success',
38
+ '-f',
39
+ `context=${context}`,
40
+ '-f',
41
+ `description=${description}`
42
+ ];
43
+ }
44
+ export function postStatus(repo, sha, context, description, exec = defaultExec) {
45
+ exec('gh', statusArgs(repo, sha, context, description));
46
+ }
47
+ export function prUrl(exec = defaultExec) {
48
+ try {
49
+ return JSON.parse(exec('gh', ['pr', 'view', '--json', 'url'])).url;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ export { defineConfig } from './config/types.js';
2
+ export type { AllowlistEntry, ChreodeConfig, Matcher, ProtectionConfig, RuleConfig, StepConfig, StepContext } from './config/types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { defineConfig } from './config/types.js';
@@ -0,0 +1,40 @@
1
+ export declare const LOCK_STALE_MS = 30000;
2
+ export declare const LOCK_UPDATE_MS = 10000;
3
+ export declare const LOCK_POLL_MS = 2000;
4
+ export declare const LOCK_MAX_WAIT_MS: number;
5
+ export type Release = () => Promise<void>;
6
+ export interface MachineLock {
7
+ /** Resolves to a release fn once held; while held elsewhere, logs once and polls. */
8
+ acquire(): Promise<Release>;
9
+ }
10
+ export interface RetryingLockDeps {
11
+ /** One attempt; must reject with an `ELOCKED`-coded error when held. */
12
+ tryLock: () => Promise<Release>;
13
+ label: string;
14
+ lockPath: string;
15
+ pollMs: number;
16
+ maxWaitMs: number;
17
+ now: () => number;
18
+ sleep: (ms: number) => Promise<void>;
19
+ log: (msg: string) => void;
20
+ /** One-line remedy appended to the timeout error. */
21
+ hint?: string;
22
+ }
23
+ /** Poll-until-free around a one-shot lock. All I/O injected so the retry/log/timeout logic
24
+ * is testable without the filesystem or real timers. */
25
+ export declare function createRetryingLock(deps: RetryingLockDeps): MachineLock;
26
+ /** proper-lockfile at a fixed path, with stale-steal. */
27
+ export declare function fileLock(opts: {
28
+ label: string;
29
+ lockPath: string;
30
+ staleMs?: number;
31
+ updateMs?: number;
32
+ pollMs?: number;
33
+ maxWaitMs?: number;
34
+ hint?: string;
35
+ log?: (msg: string) => void;
36
+ }): MachineLock;
37
+ /** `CHREODE_LOCK_PATH` exists for test isolation: chreode's own suite runs `chreode signoff`
38
+ * while an outer signoff holds the real lock. */
39
+ export declare function defaultLockPath(env?: NodeJS.ProcessEnv): string;
40
+ export declare function signoffLock(env?: NodeJS.ProcessEnv): MachineLock;
@@ -0,0 +1,76 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import lockfile from 'proper-lockfile';
4
+ // Steps hold gigabytes. Several agents in parallel worktrees running a suite at once swap
5
+ // the machine to death and produce spurious failures, and the contended resource is the
6
+ // machine, not the repo, so every chreode repo on the box shares ONE lock.
7
+ //
8
+ // A live holder refreshes the lock mtime every UPDATE; a holder that dies stops refreshing,
9
+ // so the lock goes stale after STALE and the next waiter steals it.
10
+ export const LOCK_STALE_MS = 30_000;
11
+ export const LOCK_UPDATE_MS = 10_000;
12
+ export const LOCK_POLL_MS = 2_000;
13
+ export const LOCK_MAX_WAIT_MS = 60 * 60_000;
14
+ /** Poll-until-free around a one-shot lock. All I/O injected so the retry/log/timeout logic
15
+ * is testable without the filesystem or real timers. */
16
+ export function createRetryingLock(deps) {
17
+ return {
18
+ async acquire() {
19
+ const deadline = deps.now() + deps.maxWaitMs;
20
+ let logged = false;
21
+ for (;;) {
22
+ try {
23
+ return await deps.tryLock();
24
+ }
25
+ catch (err) {
26
+ if (err.code !== 'ELOCKED')
27
+ throw err;
28
+ if (!logged) {
29
+ deps.log(`[chreode] another run holds the ${deps.label} lock; waiting…`);
30
+ logged = true;
31
+ }
32
+ if (deps.now() >= deadline) {
33
+ const hint = deps.hint ? ` ${deps.hint}` : '';
34
+ throw new Error(`[chreode] timed out after ${Math.round(deps.maxWaitMs / 60_000)}m waiting for ` +
35
+ `the ${deps.label} lock (${deps.lockPath}).${hint}`, { cause: err });
36
+ }
37
+ await deps.sleep(deps.pollMs);
38
+ }
39
+ }
40
+ }
41
+ };
42
+ }
43
+ /** proper-lockfile at a fixed path, with stale-steal. */
44
+ export function fileLock(opts) {
45
+ return createRetryingLock({
46
+ tryLock: () => lockfile.lock(opts.lockPath, {
47
+ realpath: false, // the target need not exist; the lock is the `${lockPath}.lock` dir
48
+ stale: opts.staleMs ?? LOCK_STALE_MS,
49
+ update: opts.updateMs ?? LOCK_UPDATE_MS,
50
+ retries: 0, // we own the retry loop so we can log once and bound the wait
51
+ // The default handler throws from a timer: a stalled mtime refresh (laptop sleep) would crash
52
+ // chreode uncaught and orphan the detached step group. Log and carry on instead.
53
+ onCompromised: (err) => console.error(`[chreode] lock compromised: ${err.message}`)
54
+ }),
55
+ label: opts.label,
56
+ lockPath: opts.lockPath,
57
+ pollMs: opts.pollMs ?? LOCK_POLL_MS,
58
+ maxWaitMs: opts.maxWaitMs ?? LOCK_MAX_WAIT_MS,
59
+ now: () => Date.now(),
60
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
61
+ log: opts.log ?? ((msg) => console.log(msg)),
62
+ hint: opts.hint
63
+ });
64
+ }
65
+ /** `CHREODE_LOCK_PATH` exists for test isolation: chreode's own suite runs `chreode signoff`
66
+ * while an outer signoff holds the real lock. */
67
+ export function defaultLockPath(env = process.env) {
68
+ return env.CHREODE_LOCK_PATH || path.join(os.tmpdir(), 'chreode-signoff.lock');
69
+ }
70
+ export function signoffLock(env = process.env) {
71
+ return fileLock({
72
+ label: 'signoff',
73
+ lockPath: defaultLockPath(env),
74
+ hint: 'The holder is another chreode signoff on this machine (any repo). Do not kill it; wait it out.'
75
+ });
76
+ }
@@ -0,0 +1,8 @@
1
+ /** Run a program (no shell) and return its stdout. Throws on non-zero exit; the error
2
+ * message carries the program's stderr. */
3
+ export type Exec = (file: string, args: readonly string[]) => string;
4
+ export declare const defaultExec: Exec;
5
+ /** Run a shell command with inherited stdio (for long, chatty commands). */
6
+ export type Run = (command: string, env?: NodeJS.ProcessEnv) => void;
7
+ export declare const defaultRun: Run;
8
+ export declare function lines(out: string): string[];
@@ -0,0 +1,15 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ export const defaultExec = (file, args) => execFileSync(file, [...args], {
3
+ encoding: 'utf8',
4
+ stdio: ['ignore', 'pipe', 'pipe'],
5
+ maxBuffer: 64 * 1024 * 1024
6
+ });
7
+ export const defaultRun = (command, env) => {
8
+ execSync(command, { stdio: 'inherit', env: env ?? process.env });
9
+ };
10
+ export function lines(out) {
11
+ return out
12
+ .split('\n')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean);
15
+ }
@@ -0,0 +1,21 @@
1
+ import type { AllowlistEntry } from '../config/types.js';
2
+ /** Classify captured stderr line by line: blank and allowlisted lines are benign,
3
+ * everything else is returned ANSI-stripped. */
4
+ export declare function policeStderr(buffer: string, allowlist: readonly AllowlistEntry[]): {
5
+ offendingLines: string[];
6
+ };
7
+ export type PolicedOutcome = {
8
+ kind: 'pass';
9
+ } | {
10
+ kind: 'fail-exit';
11
+ rawStderr: string;
12
+ } | {
13
+ kind: 'fail-stderr';
14
+ offendingLines: string[];
15
+ };
16
+ /** A non-zero/null exit is a real failure (surface everything). A clean exit with
17
+ * non-allowlisted stderr is a policy failure (surface just the offending lines). */
18
+ export declare function decidePolicedOutcome(result: {
19
+ code: number | null;
20
+ stderr?: string;
21
+ }, allowlist: readonly AllowlistEntry[]): PolicedOutcome;
@@ -0,0 +1,27 @@
1
+ // ANSI SGR color codes must not defeat allowlist matching. The escape character is built at
2
+ // runtime so no control character appears in source (keeps no-control-regex quiet).
3
+ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
4
+ /** Classify captured stderr line by line: blank and allowlisted lines are benign,
5
+ * everything else is returned ANSI-stripped. */
6
+ export function policeStderr(buffer, allowlist) {
7
+ const offendingLines = buffer
8
+ .split(/\r?\n/)
9
+ .map((line) => line.replace(ANSI_SGR, ''))
10
+ .filter((line) => line.trim() !== '')
11
+ .filter((line) => !allowlist.some((entry) => {
12
+ entry.pattern.lastIndex = 0;
13
+ return entry.pattern.test(line);
14
+ }));
15
+ return { offendingLines };
16
+ }
17
+ /** A non-zero/null exit is a real failure (surface everything). A clean exit with
18
+ * non-allowlisted stderr is a policy failure (surface just the offending lines). */
19
+ export function decidePolicedOutcome(result, allowlist) {
20
+ const buffer = result.stderr ?? '';
21
+ if (result.code !== 0)
22
+ return { kind: 'fail-exit', rawStderr: buffer };
23
+ const { offendingLines } = policeStderr(buffer, allowlist);
24
+ if (offendingLines.length > 0)
25
+ return { kind: 'fail-stderr', offendingLines };
26
+ return { kind: 'pass' };
27
+ }
@@ -0,0 +1,55 @@
1
+ import type { AllowlistEntry } from '../config/types.js';
2
+ import type { MachineLock } from '../lock/machine-lock.js';
3
+ import type { PlannedStep } from '../signoff/plan.js';
4
+ export interface RunResult {
5
+ /** Exit code, or null when killed by a signal. */
6
+ code: number | null;
7
+ timedOut: boolean;
8
+ interrupted: boolean;
9
+ /** Present only when captureStderr was set. */
10
+ stderr?: string;
11
+ }
12
+ export interface SignalSource {
13
+ on(event: 'SIGINT' | 'SIGTERM', fn: () => void): unknown;
14
+ off(event: 'SIGINT' | 'SIGTERM', fn: () => void): unknown;
15
+ }
16
+ export interface RunOptions {
17
+ /** stdout stays live; stderr is piped, buffered and returned. */
18
+ captureStderr?: boolean;
19
+ env?: NodeJS.ProcessEnv;
20
+ cwd?: string;
21
+ /** Where SIGINT/SIGTERM arrive; defaults to the process. Injectable for tests. */
22
+ signals?: SignalSource;
23
+ }
24
+ /** After forwarding SIGINT/SIGTERM, how long a step gets to exit before its group is SIGKILLed. */
25
+ export declare const INTERRUPT_GRACE_MS = 3000;
26
+ /**
27
+ * Run a shell command with a hard wall-clock timeout. The command runs in its OWN process
28
+ * group (detached) so a timeout can SIGKILL the whole tree, not just the wrapping shell.
29
+ * Being in its own group, it no longer receives the terminal's Ctrl-C, so SIGINT/SIGTERM
30
+ * delivered to us are forwarded to the group. Never rejects.
31
+ */
32
+ export declare function runWithTimeout(cmd: string, timeoutMs: number, opts?: RunOptions): Promise<RunResult>;
33
+ export declare class StepFailure extends Error {
34
+ readonly label: string;
35
+ constructor(label: string, message: string);
36
+ }
37
+ export declare function timeoutMessage(label: string, timeoutMs: number): string;
38
+ export interface StepRunOptions {
39
+ allowStderr: boolean;
40
+ allowlist: readonly AllowlistEntry[];
41
+ env?: NodeJS.ProcessEnv;
42
+ log?: (m: string) => void;
43
+ error?: (m: string) => void;
44
+ writeStderr?: (s: string) => void;
45
+ runImpl?: (cmd: string, timeoutMs: number, opts: RunOptions) => Promise<RunResult>;
46
+ }
47
+ /** How a step passed: 'allowed-stderr' means it would have failed stderr policing but for
48
+ * --allow-stderr, which makes the run partial. */
49
+ export type StepOutcome = 'passed' | 'allowed-stderr';
50
+ /** Run one planned step. Every failure throws StepFailure so the caller's `finally`
51
+ * releases the lock before the run exits. */
52
+ export declare function runStep(step: PlannedStep, opts: StepRunOptions): Promise<StepOutcome>;
53
+ /** Run the plan holding the machine lock; an empty plan touches no lock. The lock is
54
+ * released on every exit path. Returns the labels whose stderr --allow-stderr waived. */
55
+ export declare function runSteps(steps: readonly PlannedStep[], lock: MachineLock, run: (step: PlannedStep) => Promise<StepOutcome>): Promise<string[]>;