From f73796565ccc4111dffe0ff5e888f43882d8bec1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Nov 2025 03:17:56 +0000 Subject: [PATCH] chore: adopt guardrail scripts --- AGENTS.md | 7 + bin/git | 180 ++++++ git | 3 + runner | 13 + scripts/committer | 53 ++ scripts/docs-list.ts | 130 +++++ scripts/git-policy.ts | 159 ++++++ scripts/runner.ts | 1242 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1787 insertions(+) create mode 100755 bin/git create mode 100755 git create mode 100755 runner create mode 100755 scripts/committer create mode 100644 scripts/docs-list.ts create mode 100644 scripts/git-policy.ts create mode 100644 scripts/runner.ts diff --git a/AGENTS.md b/AGENTS.md index 58f5f22..7dfb00f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,16 @@ If you are unsure about sth, just google it. - `pnpm test`: Executes the full Vitest suite once. - `pnpm dev`: Watches and incrementally rebuilds the library with TypeScript. - `pnpm clean`: Removes `dist/` so you can verify fresh builds. +- `pnpm run docs:list`: Lists required rule summaries via `scripts/docs-list.ts`; run this at the start of every session and reopen any referenced doc before writing code. - `tmux new-session -- pnpm mcporter:list`: Exercise the CLI in a resilient terminal; tmux makes it easy to spot stalls or hung servers. - `gh run list --limit 1 --watch`: Stream CI status in real time; use `gh run view --log` on the returned run id to inspect failures quickly. +## Guardrail Tooling (runner/git wrappers) +- Use `./runner ` for every non-trivial shell command (tests, builds, npm, node, bun, etc.). The Bun-backed runner enforces timeouts, blocks risky subcommands, and keeps logs consistent. Only simple read-only tools (e.g., `cat`, `ls`, `rg`) may bypass it. +- When you must run git, invoke it through the wrapper: `./runner git status -sb`, `./runner git diff`, or `./runner git log`. Those are the only git subcommands permitted. Never run `git push` unless the user asks explicitly, and even then go through `./runner git push`. +- Never call `git add` / `git commit` directly. To create a commit, list the exact paths via `./scripts/committer "type: summary" path/to/file1 path/to/file2`. +- If you need to run the Bun-based git policy helper directly, you can use `./git ...`, but prefer `./runner git ...` so logging stays uniform. + ## Coding Style & Naming Conventions - TypeScript files use 2-space indentation, modern ES module syntax, and `strict` compiler settings. - Imports stay sorted logically; prefer relative paths within `src/`. diff --git a/bin/git b/bin/git new file mode 100755 index 0000000..d8c13d9 --- /dev/null +++ b/bin/git @@ -0,0 +1,180 @@ +#!/usr/bin/env bun + +import { accessSync, constants, existsSync, realpathSync } from 'node:fs'; +import { dirname, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { analyzeGitExecution, evaluateGitPolicies } from '../scripts/git-policy'; + +const commandArgs = ['git', ...process.argv.slice(2)]; +const context = analyzeGitExecution(commandArgs, process.cwd()); +const gitBinary = findRealGitBinary(); +if (!gitBinary) { + console.error('Unable to locate the system git binary outside the Sweetistics git shim.'); + process.exit(1); +} + +if (!shouldEnforcePolicies(context)) { + process.exit(await runGitPassthrough(gitBinary)); +} + +const evaluation = evaluateGitPolicies(context); + +if (evaluation.requiresCommitHelper) { + console.error( + 'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.' + ); + process.exit(1); +} + +if (evaluation.requiresExplicitConsent) { + if (process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1') { + if (process.env.RUNNER_DEBUG === '1') { + console.error('[git-shim] Proceeding with guarded git command because RUNNER_THE_USER_GAVE_ME_CONSENT=1.'); + } + } else { + console.error( + `Using git ${context.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.` + ); + process.exit(1); + } +} + +if (evaluation.isDestructive) { + console.error( + 'Destructive git commands require explicit user consent. If you are trying to revert an individual file, carefully undo your changes from memory. Other agents work in this repository and a blanket reset would risk overriding their non-committed work.' + ); + process.exit(1); +} + +process.exit(await runGitPassthrough(gitBinary)); + +function findRealGitBinary(): string | null { + const scriptDir = normalize(dirname(fileURLToPath(import.meta.url))); + + const pathCandidates = (process.env.PATH ?? '') + .split(':') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + for (const segment of pathCandidates) { + const normalized = normalize(resolve(segment)); + if (normalized === scriptDir) { + continue; + } + const candidate = join(normalized, 'git'); + if (isExecutable(candidate)) { + return candidate; + } + } + + for (const fallback of ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git']) { + if (isExecutable(fallback)) { + return fallback; + } + } + + return null; +} + +function isExecutable(candidate: string): boolean { + if (!existsSync(candidate)) { + return false; + } + try { + accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +async function runGitPassthrough(binary: string): Promise { + const proc = Bun.spawn([binary, ...process.argv.slice(2)], { + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + env: process.env, + }); + return proc.exited; +} + +function shouldEnforcePolicies(context: ReturnType): boolean { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + let repoRoot: string | null = null; + try { + repoRoot = realpathSync(resolve(scriptDir, '..')); + } catch { + return false; + } + + const candidatePaths = collectCandidatePaths(context); + + for (const path of candidatePaths) { + if (path && isPathInsideRepo(path, repoRoot)) { + return true; + } + } + + return false; +} + +function collectCandidatePaths(context: ReturnType): Set { + const candidates = new Set(); + + if (context.workDir) { + candidates.add(context.workDir); + } + + const argv = toArray(context.invocation?.argv); + for (let index = 1; index < argv.length; index += 1) { + const token = argv[index]; + + if (token === '-C' || token === '--git-dir' || token === '--work-tree') { + const value = argv[index + 1]; + if (value) { + candidates.add(resolve(context.workDir, value)); + } + index += 1; + continue; + } + + if (token.startsWith('-C') && token.length > 2) { + candidates.add(resolve(context.workDir, token.slice(2))); + continue; + } + + if (token.startsWith('--git-dir=')) { + candidates.add(resolve(context.workDir, token.slice('--git-dir='.length))); + continue; + } + + if (token.startsWith('--work-tree=')) { + candidates.add(resolve(context.workDir, token.slice('--work-tree='.length))); + } + } + + return candidates; +} + +function isPathInsideRepo(candidate: string, repoRoot: string): boolean { + try { + const realCandidate = realpathSync(candidate); + if (realCandidate === repoRoot) { + return true; + } + return realCandidate.startsWith(repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`); + } catch { + return false; + } +} + +function toArray(value: T | T[] | null | undefined): T[] { + if (value == null) { + return []; + } + if (Array.isArray(value)) { + return value; + } + return [value]; +} diff --git a/git b/git new file mode 100755 index 0000000..2eee14c --- /dev/null +++ b/git @@ -0,0 +1,3 @@ +#!/usr/bin/env bun + +await import('./bin/git'); diff --git a/runner b/runner new file mode 100755 index 0000000..6705fd2 --- /dev/null +++ b/runner @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Guardrail shim that wraps every command with the Bun runner; when behavior changes, record the note via ./scripts/committer \"docs: update AGENTS for runner\" \"AGENTS.md\" so fellow agents keep pace. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v bun >/dev/null 2>&1; then + echo "[runner] bun is required but was not found on PATH." >&2 + echo "[runner] Install Bun from https://bun.sh and retry." >&2 + exit 1 +fi + +exec bun "$ROOT_DIR/scripts/runner.ts" "$@" diff --git a/scripts/committer b/scripts/committer new file mode 100755 index 0000000..afe275f --- /dev/null +++ b/scripts/committer @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail +# Disable glob expansion to handle brackets in file paths +set -f +usage() { + printf 'Usage: %s "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2 + exit 2 +} + +if [ "$#" -lt 2 ]; then + usage +fi + +commit_message=$1 +shift + +if [[ "$commit_message" != *[![:space:]]* ]]; then + printf 'Error: commit message must not be empty\n' >&2 + exit 1 +fi + +if [ -e "$commit_message" ]; then + printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2 + exit 1 +fi + +if [ "$#" -eq 0 ]; then + usage +fi + +files=("$@") + +for file in "${files[@]}"; do + if [ ! -e "$file" ]; then + if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then + printf 'Error: file not found: %s\n' "$file" >&2 + exit 1 + fi + fi +done + +git restore --staged :/ +git add --force -- "${files[@]}" + +if git diff --staged --quiet; then + printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2 + exit 1 +fi + +git commit -m "$commit_message" -- "${files[@]}" + +printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}" diff --git a/scripts/docs-list.ts b/scripts/docs-list.ts new file mode 100644 index 0000000..3fc9680 --- /dev/null +++ b/scripts/docs-list.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env tsx + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compact } from 'es-toolkit'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const DOCS_DIR = join(__dirname, '..', 'docs'); + +const EXCLUDED_DIRS = new Set(['archive', 'research']); + +function walkMarkdownFiles(dir: string, base: string = dir): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.has(entry.name)) { + continue; + } + files.push(...walkMarkdownFiles(fullPath, base)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(relative(base, fullPath)); + } + } + return files.sort((a, b) => a.localeCompare(b)); +} + +function extractMetadata(fullPath: string): { + summary: string | null; + readWhen: string[]; + error?: string; +} { + const content = readFileSync(fullPath, 'utf8'); + + if (!content.startsWith('---')) { + return { summary: null, readWhen: [], error: 'missing front matter' }; + } + + const endIndex = content.indexOf('\n---', 3); + if (endIndex === -1) { + return { summary: null, readWhen: [], error: 'unterminated front matter' }; + } + + const frontMatter = content.slice(3, endIndex).trim(); + const lines = frontMatter.split('\n'); + + let summaryLine: string | null = null; + const readWhen: string[] = []; + let collectingField: 'read_when' | null = null; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (line.startsWith('summary:')) { + summaryLine = line; + collectingField = null; + continue; + } + + if (line.startsWith('read_when:')) { + collectingField = 'read_when'; + const inline = line.slice('read_when:'.length).trim(); + if (inline.startsWith('[') && inline.endsWith(']')) { + try { + const parsed = JSON.parse(inline.replace(/'/g, '"')) as unknown; + if (Array.isArray(parsed)) { + readWhen.push(...compact(parsed.map((item) => String(item).trim()))); + } + } catch { + // ignore malformed inline arrays + } + } + continue; + } + + if (collectingField === 'read_when') { + if (line.startsWith('- ')) { + const hint = line.slice(2).trim(); + if (hint) { + readWhen.push(hint); + } + } else if (line === '') { + } else { + collectingField = null; + } + } + } + + if (!summaryLine) { + return { summary: null, readWhen, error: 'summary key missing' }; + } + + const summaryValue = summaryLine.slice('summary:'.length).trim(); + const normalized = summaryValue + .replace(/^['"]|['"]$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) { + return { summary: null, readWhen, error: 'summary is empty' }; + } + + return { summary: normalized, readWhen }; +} + +console.log('Listing all markdown files in docs folder:'); + +const markdownFiles = walkMarkdownFiles(DOCS_DIR); + +for (const relativePath of markdownFiles) { + const fullPath = join(DOCS_DIR, relativePath); + const { summary, readWhen, error } = extractMetadata(fullPath); + if (summary) { + console.log(`${relativePath} - ${summary}`); + if (readWhen.length > 0) { + console.log(` Read when: ${readWhen.join('; ')}`); + } + } else { + const reason = error ? ` - [${error}]` : ''; + console.log(`${relativePath}${reason}`); + } +} + +console.log( + '\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.' +); diff --git a/scripts/git-policy.ts b/scripts/git-policy.ts new file mode 100644 index 0000000..0e3b359 --- /dev/null +++ b/scripts/git-policy.ts @@ -0,0 +1,159 @@ +import { resolve } from 'node:path'; + +export type GitInvocation = { + index: number; + argv: string[]; +}; + +export type GitCommandInfo = { + name: string; + index: number; +}; + +export type GitExecutionContext = { + invocation: GitInvocation | null; + command: GitCommandInfo | null; + subcommand: string | null; + workDir: string; +}; + +export type GitPolicyEvaluation = { + requiresCommitHelper: boolean; + requiresExplicitConsent: boolean; + isDestructive: boolean; +}; + +const COMMIT_HELPER_SUBCOMMANDS = new Set(['add', 'commit']); +const GUARDED_SUBCOMMANDS = new Set(['push', 'pull', 'merge', 'rebase', 'cherry-pick']); +const DESTRUCTIVE_SUBCOMMANDS = new Set([ + 'reset', + 'checkout', + 'clean', + 'restore', + 'switch', + 'stash', + 'branch', + 'filter-branch', + 'fast-import', +]); + +export function extractGitInvocation(commandArgs: string[]): GitInvocation | null { + for (const [index, token] of commandArgs.entries()) { + if (token === 'git' || token.endsWith('/git')) { + return { index, argv: commandArgs.slice(index) }; + } + } + return null; +} + +export function findGitSubcommand(commandArgs: string[]): GitCommandInfo | null { + if (commandArgs.length <= 1) { + return null; + } + + const optionsWithValue = new Set(['-C', '--git-dir', '--work-tree', '-c']); + let index = 1; + + while (index < commandArgs.length) { + const token = commandArgs[index]; + if (token === '--') { + const next = commandArgs[index + 1]; + return next ? { name: next, index: index + 1 } : null; + } + if (!token.startsWith('-')) { + return { name: token, index }; + } + if (token.includes('=')) { + index += 1; + continue; + } + if (optionsWithValue.has(token)) { + index += 2; + continue; + } + index += 1; + } + return null; +} + +export function determineGitWorkdir(baseDir: string, gitArgs: string[], command: GitCommandInfo | null): string { + let workDir = baseDir; + const limit = command ? command.index : gitArgs.length; + let index = 1; + + while (index < limit) { + const token = gitArgs[index]; + if (token === '-C') { + const next = gitArgs[index + 1]; + if (next) { + workDir = resolve(workDir, next); + } + index += 2; + continue; + } + if (token.startsWith('-C')) { + const pathSegment = token.slice(2); + if (pathSegment.length > 0) { + workDir = resolve(workDir, pathSegment); + } + } + index += 1; + } + + return workDir; +} + +export function analyzeGitExecution(commandArgs: string[], workspaceDir: string): GitExecutionContext { + const invocation = extractGitInvocation(commandArgs); + const command = invocation ? findGitSubcommand(invocation.argv) : null; + const workDir = invocation ? determineGitWorkdir(workspaceDir, invocation.argv, command) : workspaceDir; + + return { + invocation, + command, + subcommand: command?.name ?? null, + workDir, + }; +} + +export function requiresCommitHelper(subcommand: string | null): boolean { + if (!subcommand) { + return false; + } + return COMMIT_HELPER_SUBCOMMANDS.has(subcommand); +} + +export function requiresExplicitGitConsent(subcommand: string | null): boolean { + if (!subcommand) { + return false; + } + return GUARDED_SUBCOMMANDS.has(subcommand); +} + +export function isDestructiveGitSubcommand(command: GitCommandInfo | null, gitArgv: string[]): boolean { + if (!command) { + return false; + } + + const subcommand = command.name; + if (DESTRUCTIVE_SUBCOMMANDS.has(subcommand)) { + return true; + } + + if (subcommand === 'bisect') { + const action = gitArgv[command.index + 1] ?? ''; + return action === 'reset'; + } + + return false; +} + +export function evaluateGitPolicies(context: GitExecutionContext): GitPolicyEvaluation { + const invocationArgv = context.invocation?.argv; + const normalizedArgv = Array.isArray(invocationArgv) ? invocationArgv : []; + return { + requiresCommitHelper: requiresCommitHelper(context.subcommand), + requiresExplicitConsent: requiresExplicitGitConsent(context.subcommand), + isDestructive: isDestructiveGitSubcommand(context.command, normalizedArgv), + }; +} diff --git a/scripts/runner.ts b/scripts/runner.ts new file mode 100644 index 0000000..c332cc3 --- /dev/null +++ b/scripts/runner.ts @@ -0,0 +1,1242 @@ +#!/usr/bin/env bun +/** + * Sweetistics runner wrapper: enforces timeouts, git policy, and trash-safe deletes before dispatching any repo command. + * When you tweak its behavior, add a short note to AGENTS.md via `./scripts/committer "docs: update AGENTS for runner" "AGENTS.md"` so other agents know the new expectations. + */ + +import { type ChildProcess, spawn } from 'node:child_process'; +import { cpSync, existsSync, renameSync, rmSync } from 'node:fs'; +import { constants as osConstants } from 'node:os'; +import { basename, isAbsolute, join, normalize, resolve } from 'node:path'; +import process from 'node:process'; + +import { + analyzeGitExecution, + evaluateGitPolicies, + type GitCommandInfo, + type GitExecutionContext, + type GitInvocation, +} from './git-policy'; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const EXTENDED_TIMEOUT_MS = 20 * 60 * 1000; +const LONG_TIMEOUT_MS = 25 * 60 * 1000; // Build + full-suite commands (Next.js build, test:all) routinely spike past 20 minutes—give them explicit headroom before tmux escalation. +const LINT_TIMEOUT_MS = 30 * 60 * 1000; +const LONG_RUN_REPORT_THRESHOLD_MS = 60 * 1000; +const ENABLE_DEBUG_LOGS = process.env.RUNNER_DEBUG === '1'; + +const WRAPPER_COMMANDS = new Set([ + 'sudo', + '/usr/bin/sudo', + 'env', + '/usr/bin/env', + 'command', + '/bin/command', + 'nohup', + '/usr/bin/nohup', +]); + +// biome-ignore format: keep each keyword on its own line for grep-friendly diffs. +const LONG_SCRIPT_KEYWORDS = ['build', 'test:all', 'test:browser', 'vitest.browser', 'vitest.browser.config.ts']; +const EXTENDED_SCRIPT_KEYWORDS = ['lint', 'test', 'playwright', 'check', 'docker']; +const SINGLE_TEST_SCRIPTS = new Set(['test:file']); +const SINGLE_TEST_FLAGS = new Set(['--run']); +const TEST_BINARIES = new Set(['vitest', 'playwright', 'jest']); +const LINT_BINARIES = new Set(['eslint', 'biome', 'oxlint', 'knip']); + +type RunnerExecutionContext = { + commandArgs: string[]; + workspaceDir: string; + timeoutMs: number; +}; + +type CommandInterceptionResult = { handled: true } | { handled: false; gitContext: GitExecutionContext }; + +type GitRmPlan = { + paths: string[]; + stagingOptions: string[]; + allowMissing: boolean; + shouldIntercept: boolean; +}; + +type MoveResult = { + missing: string[]; + errors: string[]; +}; + +let cachedTrashCliCommand: string | null | undefined; + +(async () => { + const commandArgs = parseArgs(process.argv.slice(2)); + + if (commandArgs.length === 0) { + printUsage('Missing command to execute.'); + process.exit(1); + } + + const workspaceDir = process.cwd(); + const timeoutMs = determineEffectiveTimeoutMs(commandArgs); + const context: RunnerExecutionContext = { + commandArgs, + workspaceDir, + timeoutMs, + }; + + const interception = await resolveCommandInterception(context); + if (interception.handled) { + return; + } + + enforceGitPolicies(interception.gitContext); + + await runCommand(context); +})().catch((error) => { + console.error('[runner] Unexpected failure:', error instanceof Error ? error.message : String(error)); + process.exit(1); +}); + +// Parses the runner CLI args and rejects unsupported flags early. +function parseArgs(argv: string[]): string[] { + const commandArgs: string[] = []; + let parsingOptions = true; + + for (const token of argv) { + if (!parsingOptions) { + commandArgs.push(token); + continue; + } + + if (token === '--') { + parsingOptions = false; + continue; + } + + if (token === '--help' || token === '-h') { + printUsage(); + process.exit(0); + } + + if (token === '--timeout' || token.startsWith('--timeout=')) { + console.error('[runner] --timeout is no longer supported; rely on the automatic timeouts.'); + process.exit(1); + } + + parsingOptions = false; + commandArgs.push(token); + } + + return commandArgs; +} + +// Computes the timeout tier for the provided command tokens. +function determineEffectiveTimeoutMs(commandArgs: string[]): number { + const strippedTokens = stripWrappersAndAssignments(commandArgs); + if (isTestRunnerSuiteInvocation(strippedTokens, 'integration')) { + return EXTENDED_TIMEOUT_MS; + } + if (referencesIntegrationSpec(strippedTokens)) { + return EXTENDED_TIMEOUT_MS; + } + if (shouldUseLintTimeout(commandArgs)) { + return LINT_TIMEOUT_MS; + } + if (shouldUseLongTimeout(commandArgs)) { + return LONG_TIMEOUT_MS; + } + if (shouldExtendTimeout(commandArgs) && !isSingleTestInvocation(commandArgs)) { + return EXTENDED_TIMEOUT_MS; + } + return DEFAULT_TIMEOUT_MS; +} + +// Determines whether the command matches any keyword requiring extra time. +function shouldExtendTimeout(commandArgs: string[]): boolean { + const tokens = stripWrappersAndAssignments(commandArgs); + if (tokens.length === 0) { + return false; + } + + const [first, ...rest] = tokens; + + if (first === 'pnpm') { + if (rest.length === 0) { + return false; + } + const subcommand = rest[0]; + if (subcommand === 'run') { + const script = rest[1]; + if (!script) { + return false; + } + return shouldExtendForScript(script); + } + if (subcommand === 'exec') { + const execTarget = rest[1]; + if (!execTarget) { + return false; + } + if (shouldExtendForScript(execTarget) || TEST_BINARIES.has(execTarget.toLowerCase())) { + return true; + } + for (const token of rest.slice(1)) { + if (shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase())) { + return true; + } + } + return false; + } + if (shouldExtendForScript(subcommand)) { + return true; + } + } + + if (shouldExtendForScript(first) || TEST_BINARIES.has(first.toLowerCase())) { + return true; + } + + for (const token of rest) { + if (shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase())) { + return true; + } + } + + return false; +} + +// Checks script names for long-running markers (lint/test/build/etc.). +function shouldExtendForScript(script: string): boolean { + if (SINGLE_TEST_SCRIPTS.has(script)) { + return false; + } + return matchesScriptKeyword(script, EXTENDED_SCRIPT_KEYWORDS); +} + +// Gives lint invocations the dedicated timeout bucket. +function shouldUseLintTimeout(commandArgs: string[]): boolean { + const tokens = stripWrappersAndAssignments(commandArgs); + if (tokens.length === 0) { + return false; + } + + const [first, ...rest] = tokens; + + if (first === 'pnpm') { + if (rest.length === 0) { + return false; + } + const subcommand = rest[0]; + if (subcommand === 'run') { + const script = rest[1]; + return typeof script === 'string' && script.startsWith('lint'); + } + if (subcommand === 'exec') { + const execTarget = rest[1]; + if (execTarget && LINT_BINARIES.has(execTarget.toLowerCase())) { + return true; + } + } + } + + if (LINT_BINARIES.has(first.toLowerCase())) { + return true; + } + + return false; +} + +// Detects when a user is running a single spec so we can keep the shorter timeout. +function isSingleTestInvocation(commandArgs: string[]): boolean { + const tokens = stripWrappersAndAssignments(commandArgs); + if (tokens.length === 0) { + return false; + } + + for (const token of tokens) { + if (SINGLE_TEST_FLAGS.has(token)) { + return true; + } + } + + const [first, ...rest] = tokens; + if (first === 'pnpm') { + if (rest[0] === 'test:file') { + return true; + } + } else if (first === 'vitest') { + if (rest.some((token) => SINGLE_TEST_FLAGS.has(token))) { + return true; + } + } + + return false; +} + +// Normalizes potential file paths/flags to aid comparison across shells. +function normalizeForPathComparison(token: string): string { + return token.replaceAll('\\', '/'); +} + +// Heuristically checks if a CLI token references an integration spec. +function tokenReferencesIntegrationTest(token: string): boolean { + const normalized = normalizeForPathComparison(token); + if (normalized.includes('tests/integration/')) { + return true; + } + if (normalized.startsWith('--run=') || normalized.startsWith('--include=')) { + const value = normalized.split('=', 2)[1] ?? ''; + return value.includes('tests/integration/'); + } + return false; +} + +// Scans the entire command for integration spec references. +function referencesIntegrationSpec(tokens: string[]): boolean { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === '--run' || token === '--include') { + const next = tokens[index + 1]; + if (next && tokenReferencesIntegrationTest(next)) { + return true; + } + } + if (tokenReferencesIntegrationTest(token)) { + return true; + } + } + return false; +} + +// Helper that matches a script token against a keyword allowlist. +function matchesScriptKeyword(script: string, keywords: readonly string[]): boolean { + const lowered = script.toLowerCase(); + return keywords.some((keyword) => lowered === keyword || lowered.startsWith(`${keyword}:`)); +} + +// Removes wrapper binaries/env assignments so heuristics see the real command. +function stripWrappersAndAssignments(args: string[]): string[] { + const tokens = [...args]; + + while (tokens.length > 0 && isEnvAssignment(tokens[0])) { + tokens.shift(); + } + + while (tokens.length > 0 && WRAPPER_COMMANDS.has(tokens[0])) { + tokens.shift(); + while (tokens.length > 0 && isEnvAssignment(tokens[0])) { + tokens.shift(); + } + } + + return tokens; +} + +// Checks whether a token is an inline environment variable assignment. +function isEnvAssignment(token: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); +} + +// Detects `pnpm test:` style calls regardless of wrappers. +function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean { + if (tokens.length === 0) { + return false; + } + + const normalizedSuite = suite.toLowerCase(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const normalizedToken = token.replace(/^[./\\]+/, ''); + if (normalizedToken === 'scripts/test-runner.ts' || normalizedToken.endsWith('/scripts/test-runner.ts')) { + const suiteToken = tokens[index + 1]?.toLowerCase(); + if (suiteToken === normalizedSuite) { + return true; + } + } + } + + return false; +} + +// Grants the longest timeout to explicitly tagged long-running scripts. +function shouldUseLongTimeout(commandArgs: string[]): boolean { + const tokens = stripWrappersAndAssignments(commandArgs); + if (tokens.length === 0) { + return false; + } + + const [first, ...rest] = tokens; + const matches = (token: string): boolean => matchesScriptKeyword(token, LONG_SCRIPT_KEYWORDS); + + if (first === 'pnpm') { + if (rest.length === 0) { + return false; + } + const subcommand = rest[0]; + if (subcommand === 'run') { + const script = rest[1]; + if (script && matches(script)) { + return true; + } + } else if (matches(subcommand)) { + return true; + } + for (const token of rest.slice(1)) { + if (matches(token)) { + return true; + } + } + return false; + } + + if (matches(first)) { + return true; + } + + for (const token of rest) { + if (matches(token)) { + return true; + } + } + + return false; +} + +// Kicks off the requested command with logging, timeouts, and monitoring. +async function runCommand(context: RunnerExecutionContext): Promise { + const { command, args, env } = buildExecutionParams(context.commandArgs); + const commandLabel = formatDisplayCommand(context.commandArgs); + + const startTime = Date.now(); + + const child = spawn(command, args, { + cwd: context.workspaceDir, + env, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + if (isRunnerTmuxSession()) { + const childPidInfo = typeof child.pid === 'number' ? ` (pid ${child.pid})` : ''; + console.error(`[runner] Watching ${commandLabel}${childPidInfo}. Wait for the closing sentinel before moving on.`); + } + + const removeSignalHandlers = registerSignalForwarding(child); + + if (child.stdout) { + child.stdout.on('data', (chunk: Buffer) => { + process.stdout.write(chunk); + }); + } + + if (child.stderr) { + child.stderr.on('data', (chunk: Buffer) => { + process.stderr.write(chunk); + }); + } + + let killTimer: NodeJS.Timeout | null = null; + try { + const result = await new Promise<{ exitCode: number; timedOut: boolean }>((resolve, reject) => { + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + if (ENABLE_DEBUG_LOGS) { + console.error(`[runner] Command exceeded ${formatDuration(context.timeoutMs)}; sending SIGTERM.`); + } + if (!child.killed) { + child.kill('SIGTERM'); + killTimer = setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5_000); + } + }, context.timeoutMs); + + child.once('error', (error) => { + clearTimeout(timeout); + if (killTimer) { + clearTimeout(killTimer); + } + removeSignalHandlers(); + reject(error); + }); + + child.once('exit', (code, signal) => { + clearTimeout(timeout); + if (killTimer) { + clearTimeout(killTimer); + } + removeSignalHandlers(); + resolve({ exitCode: code ?? exitCodeFromSignal(signal), timedOut }); + }); + }); + const { exitCode, timedOut } = result; + + const elapsedMs = Date.now() - startTime; + if (timedOut) { + console.error( + `[runner] Command terminated after ${formatDuration(context.timeoutMs)}. Re-run inside tmux for long-lived work.` + ); + console.error( + `[runner] Finished ${commandLabel} (exit ${exitCode}, elapsed ${formatDuration(elapsedMs)}; timed out).` + ); + process.exit(124); + } + + if (elapsedMs >= LONG_RUN_REPORT_THRESHOLD_MS) { + console.error( + `[runner] Completed in ${formatDuration(elapsedMs)}. For long-running tasks, prefer tmux directly.` + ); + } + + console.error(`[runner] Finished ${commandLabel} (exit ${exitCode}, elapsed ${formatDuration(elapsedMs)}).`); + process.exit(exitCode); + } catch (error) { + console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error)); + process.exit(1); + return; + } +} + +// Prepares the executable, args, and sanitized env for the child process. +function buildExecutionParams(commandArgs: string[]): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + const env = { ...process.env }; + const args: string[] = []; + let commandStarted = false; + + for (const token of commandArgs) { + if (!commandStarted && isEnvAssignment(token)) { + const [key, ...rest] = token.split('='); + env[key] = rest.join('='); + continue; + } + commandStarted = true; + args.push(token); + } + + if (args.length === 0) { + printUsage('Missing command to execute.'); + process.exit(1); + } + + return { command: args[0], args: args.slice(1), env }; +} + +// Forwards termination signals to the child and returns an unregister hook. +function registerSignalForwarding(child: ChildProcess): () => void { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + const handlers = new Map void>(); + + for (const signal of signals) { + const handler = () => { + if (!child.killed) { + child.kill(signal); + } + }; + handlers.set(signal, handler); + process.on(signal, handler); + } + + return () => { + for (const [signal, handler] of handlers) { + process.off(signal, handler); + } + }; +} + +// Maps a terminating signal to the exit code conventions bash expects. +function exitCodeFromSignal(signal: NodeJS.Signals | null): number { + if (!signal) { + return 0; + } + const code = (osConstants.signals as Record)[signal]; + if (typeof code === 'number') { + return 128 + code; + } + return 1; +} + +// Gives policy interceptors a chance to fully handle a command before exec. +async function resolveCommandInterception(context: RunnerExecutionContext): Promise { + const interceptors: Array<(ctx: RunnerExecutionContext) => Promise> = [ + maybeHandleFindInvocation, + maybeHandleRmInvocation, + ]; + + for (const interceptor of interceptors) { + if (await interceptor(context)) { + return { handled: true }; + } + } + + const gitContext = analyzeGitExecution(context.commandArgs, context.workspaceDir); + + if (await maybeHandleGitRm(gitContext)) { + return { handled: true }; + } + + return { handled: false, gitContext }; +} + +// Runs the shared git policy analyzers before dispatching the command. +function enforceGitPolicies(gitContext: GitExecutionContext) { + const evaluation = evaluateGitPolicies(gitContext); + const hasConsentOverride = process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1'; + + if (gitContext.subcommand === 'rebase' && !hasConsentOverride) { + console.error( + 'git rebase requires the user to explicitly type "rebase" in chat. Once they do, rerun with RUNNER_THE_USER_GAVE_ME_CONSENT=1 in the same command (e.g. RUNNER_THE_USER_GAVE_ME_CONSENT=1 ./runner git rebase --continue).' + ); + process.exit(1); + } + + if (evaluation.requiresCommitHelper) { + console.error( + 'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.' + ); + process.exit(1); + } + + if (evaluation.requiresExplicitConsent || evaluation.isDestructive) { + if (hasConsentOverride) { + if (ENABLE_DEBUG_LOGS) { + const reason = evaluation.isDestructive ? 'destructive git command' : 'guarded git command'; + console.error(`[runner] Proceeding with ${reason} because RUNNER_THE_USER_GAVE_ME_CONSENT=1.`); + } + } else { + if (evaluation.isDestructive) { + console.error( + `git ${gitContext.subcommand ?? ''} can overwrite or discard work. Confirm with the user first, then re-run with RUNNER_THE_USER_GAVE_ME_CONSENT=1 if they approve.` + ); + } else { + console.error( + `Using git ${gitContext.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.` + ); + } + process.exit(1); + } + } +} + +// Handles guarded `find` invocations that may delete files outright. +async function maybeHandleFindInvocation(context: RunnerExecutionContext): Promise { + const findInvocation = extractFindInvocation(context.commandArgs); + if (!findInvocation) { + return false; + } + + const findPlan = await buildFindDeletePlan(findInvocation.argv, context.workspaceDir); + if (!findPlan) { + return false; + } + + const moveResult = await movePathsToTrash(findPlan.paths, context.workspaceDir, { allowMissing: false }); + if (moveResult.missing.length > 0) { + for (const path of moveResult.missing) { + console.error(`find: ${path}: No such file or directory`); + } + process.exit(1); + } + if (moveResult.errors.length > 0) { + for (const error of moveResult.errors) { + console.error(error); + } + process.exit(1); + } + process.exit(0); + return true; +} + +// Intercepts plain `rm` commands to route them through trash safeguards. +async function maybeHandleRmInvocation(context: RunnerExecutionContext): Promise { + const rmInvocation = extractRmInvocation(context.commandArgs); + if (!rmInvocation) { + return false; + } + + const rmPlan = parseRmArguments(rmInvocation.argv); + if (!rmPlan?.shouldIntercept) { + return false; + } + + try { + const moveResult = await movePathsToTrash(rmPlan.targets, context.workspaceDir, { allowMissing: rmPlan.force }); + reportMissingForRm(moveResult.missing, rmPlan.force); + if (moveResult.errors.length > 0) { + for (const error of moveResult.errors) { + console.error(error); + } + process.exit(1); + } + process.exit(0); + } catch (error) { + console.error(formatTrashError(error)); + process.exit(1); + } + return true; +} + +// Applies git-specific rm protections before the command executes. +async function maybeHandleGitRm(gitContext: GitExecutionContext): Promise { + if (gitContext.command?.name !== 'rm' || !gitContext.invocation) { + return false; + } + + const gitRmPlan = parseGitRmArguments(gitContext.invocation.argv, gitContext.command); + if (!gitRmPlan?.shouldIntercept) { + return false; + } + + try { + const moveResult = await movePathsToTrash(gitRmPlan.paths, gitContext.workDir, { + allowMissing: gitRmPlan.allowMissing, + }); + if (!gitRmPlan.allowMissing && moveResult.missing.length > 0) { + for (const path of moveResult.missing) { + console.error(`git rm: ${path}: No such file or directory`); + } + process.exit(1); + } + if (moveResult.errors.length > 0) { + for (const error of moveResult.errors) { + console.error(error); + } + process.exit(1); + } + await stageGitRm(gitContext.workDir, gitRmPlan); + process.exit(0); + } catch (error) { + console.error(formatTrashError(error)); + process.exit(1); + } + return true; +} + +// Detects `git find` invocations that need policy enforcement. +function extractFindInvocation(commandArgs: string[]): GitInvocation | null { + for (const [index, token] of commandArgs.entries()) { + if (token === 'find' || token.endsWith('/find')) { + return { index, argv: commandArgs.slice(index) }; + } + } + return null; +} + +// Detects `git rm` variants so we can intercept destructive operations. +function extractRmInvocation(commandArgs: string[]): GitInvocation | null { + if (commandArgs.length === 0) { + return null; + } + + const wrappers = new Set([ + 'sudo', + '/usr/bin/sudo', + 'env', + '/usr/bin/env', + 'command', + '/bin/command', + 'nohup', + '/usr/bin/nohup', + ]); + + let index = 0; + while (index < commandArgs.length) { + const token = commandArgs[index]; + if (!token) { + break; + } + if (token.includes('=') && !token.startsWith('-')) { + index += 1; + continue; + } + if (wrappers.has(token)) { + index += 1; + continue; + } + break; + } + + const commandToken = commandArgs[index]; + if (!commandToken) { + return null; + } + + const isRmCommand = + commandToken === 'rm' || + commandToken.endsWith('/rm') || + commandToken === 'rm.exe' || + commandToken.endsWith('\\rm.exe'); + + if (!isRmCommand) { + return null; + } + + return { index, argv: commandArgs.slice(index) }; +} + +// Expands guarded find expressions into an explicit delete plan for review. +async function buildFindDeletePlan(findArgs: string[], workspaceDir: string): Promise<{ paths: string[] } | null> { + if (!findArgs.some((token) => token === '-delete')) { + return null; + } + + if (findArgs.some((token) => token === '-exec' || token === '-execdir' || token === '-ok' || token === '-okdir')) { + console.error( + 'Runner cannot safely translate find invocations that combine -delete with -exec/-ok. Run the command manually after reviewing the paths.' + ); + process.exit(1); + } + + const printableArgs: string[] = []; + for (const token of findArgs) { + if (token === '-delete') { + continue; + } + printableArgs.push(token); + } + printableArgs.push('-print0'); + + const proc = Bun.spawn(printableArgs, { + cwd: workspaceDir, + stdout: 'pipe', + stderr: 'pipe', + }); + + const [exitCode, stdoutBuf, stderrBuf] = await Promise.all([ + proc.exited, + readProcessStream(proc.stdout), + readProcessStream(proc.stderr), + ]); + + if (exitCode !== 0) { + const stderrText = stderrBuf.trim(); + const stdoutText = stdoutBuf.trim(); + if (stderrText.length > 0) { + console.error(stderrText); + } else if (stdoutText.length > 0) { + console.error(stdoutText); + } + process.exit(exitCode); + } + + const matches = stdoutBuf.split('\0').filter((entry) => entry.length > 0); + if (matches.length === 0) { + return { paths: [] }; + } + + const uniquePaths = new Map(); + const workspaceCanonical = normalize(workspaceDir); + + for (const match of matches) { + const absolute = isAbsolute(match) ? match : resolve(workspaceDir, match); + const canonical = normalize(absolute); + if (canonical === workspaceCanonical) { + console.error('Refusing to trash the current workspace via find -delete. Narrow your find predicate.'); + process.exit(1); + } + if (!uniquePaths.has(canonical)) { + uniquePaths.set(canonical, match); + } + } + + return { paths: Array.from(uniquePaths.values()) }; +} + +// Parses rm flags/targets to decide whether the runner should intervene. +function parseRmArguments(argv: string[]): { targets: string[]; force: boolean; shouldIntercept: boolean } | null { + if (argv.length <= 1) { + return null; + } + const targets: string[] = []; + let force = false; + let treatAsTarget = false; + + let index = 1; + while (index < argv.length) { + const token = argv[index]; + if (!treatAsTarget && token === '--') { + treatAsTarget = true; + index += 1; + continue; + } + if (!treatAsTarget && token.startsWith('-') && token.length > 1) { + if (token.includes('f')) { + force = true; + } + if (token.includes('i') || token === '--interactive') { + return null; + } + if (token === '--help' || token === '--version') { + return null; + } + index += 1; + continue; + } + targets.push(token); + index += 1; + } + + const firstTarget = targets[0]; + if (firstTarget === undefined) { + return null; + } + + return { targets, force, shouldIntercept: true }; +} + +// Generates a safe plan for git rm invocations, honoring guarded paths. +function parseGitRmArguments(argv: string[], command: GitCommandInfo): GitRmPlan | null { + const stagingOptions: string[] = []; + const paths: string[] = []; + const optionsExpectingValue = new Set(['--pathspec-from-file']); + let allowMissing = false; + let treatAsPath = false; + + let index = command.index + 1; + while (index < argv.length) { + const token = argv[index]; + if (!treatAsPath && token === '--') { + treatAsPath = true; + index += 1; + continue; + } + if (!treatAsPath && token.startsWith('-') && token.length > 1) { + if (token === '--cached' || token === '--dry-run' || token === '-n') { + return null; + } + if (token === '--ignore-unmatch' || token === '--force' || token === '-f') { + allowMissing = true; + stagingOptions.push(token); + index += 1; + continue; + } + if (optionsExpectingValue.has(token)) { + const value = argv[index + 1]; + if (value) { + stagingOptions.push(token, value); + index += 2; + } else { + index += 1; + } + continue; + } + if (!token.startsWith('--')) { + const flags = token.slice(1).split(''); + const retainedFlags: string[] = []; + for (const flag of flags) { + if (flag === 'n') { + return null; + } + if (flag === 'f') { + allowMissing = true; + continue; + } + retainedFlags.push(flag); + } + if (retainedFlags.length > 0) { + stagingOptions.push(`-${retainedFlags.join('')}`); + } + index += 1; + continue; + } + stagingOptions.push(token); + index += 1; + continue; + } + if (token.length > 0) { + paths.push(token); + } + index += 1; + } + + if (paths.length === 0) { + return null; + } + return { + paths, + stagingOptions, + allowMissing, + shouldIntercept: true, + }; +} + +// Emits actionable messaging when git rm targets are already gone. +function reportMissingForRm(missing: string[], forced: boolean) { + if (missing.length === 0 || forced) { + return; + } + for (const path of missing) { + console.error(`rm: ${path}: No such file or directory`); + } + process.exit(1); +} + +// Attempts to move the provided paths into trash instead of deleting in place. +async function movePathsToTrash( + paths: string[], + baseDir: string, + options: { allowMissing: boolean } +): Promise { + const missing: string[] = []; + const existing: { raw: string; absolute: string }[] = []; + + for (const rawPath of paths) { + const absolute = resolvePath(baseDir, rawPath); + if (!existsSync(absolute)) { + if (!options.allowMissing) { + missing.push(rawPath); + } + continue; + } + existing.push({ raw: rawPath, absolute }); + } + + if (existing.length === 0) { + return { missing, errors: [] }; + } + + const trashCliCommand = await findTrashCliCommand(); + if (trashCliCommand) { + try { + const cliArgs = [trashCliCommand, ...existing.map((item) => item.absolute)]; + const proc = Bun.spawn(cliArgs, { + stdout: 'ignore', + stderr: 'pipe', + }); + const [exitCode, stderrText] = await Promise.all([proc.exited, proc.stderr?.text() ?? Promise.resolve('')]); + if (exitCode === 0) { + return { missing, errors: [] }; + } + if (ENABLE_DEBUG_LOGS && stderrText.trim().length > 0) { + console.error(`[runner] trash-cli error (${trashCliCommand}): ${stderrText.trim()}`); + } + } catch (error) { + if (ENABLE_DEBUG_LOGS) { + console.error(`[runner] trash-cli invocation failed: ${formatTrashError(error)}`); + } + } + } + + const trashDir = getTrashDirectory(); + if (!trashDir) { + return { + missing, + errors: ['Unable to locate macOS Trash directory (HOME/.Trash).'], + }; + } + + const errors: string[] = []; + + for (const item of existing) { + try { + const target = buildTrashTarget(trashDir, item.absolute); + try { + renameSync(item.absolute, target); + } catch (error) { + if (isCrossDeviceError(error)) { + cpSync(item.absolute, target, { recursive: true }); + rmSync(item.absolute, { recursive: true, force: true }); + } else { + throw error; + } + } + } catch (error) { + errors.push(`Failed to move ${item.raw} to Trash: ${formatTrashError(error)}`); + } + } + + return { missing, errors }; +} + +// Resolves a potentially relative path against the workspace root. +function resolvePath(baseDir: string, input: string): string { + if (input.startsWith('/')) { + return input; + } + return resolve(baseDir, input); +} + +// Returns the trash CLI directory if available so deletes can be safe. +function getTrashDirectory(): string | null { + const home = process.env.HOME; + if (!home) { + return null; + } + const trash = join(home, '.Trash'); + if (!existsSync(trash)) { + return null; + } + return trash; +} + +// Builds the destination path inside the trash directory for a file. +function buildTrashTarget(trashDir: string, absolutePath: string): string { + const baseName = basename(absolutePath); + const timestamp = Date.now(); + let attempt = 0; + let candidate = join(trashDir, baseName); + while (existsSync(candidate)) { + candidate = join(trashDir, `${baseName}-${timestamp}${attempt > 0 ? `-${attempt}` : ''}`); + attempt += 1; + } + return candidate; +} + +// Determines whether a rename failed because the devices differ. +function isCrossDeviceError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EXDEV'; +} + +// Normalizes trash/rename errors into a readable string. +function formatTrashError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +// Replays a git rm plan via spawn so we can surface errors consistently. +async function stageGitRm(workDir: string, plan: GitRmPlan) { + if (plan.paths.length === 0) { + return; + } + const args = ['git', 'rm', '--cached', '--quiet', ...plan.stagingOptions, '--', ...plan.paths]; + const proc = Bun.spawn(args, { + cwd: workDir, + stdout: 'inherit', + stderr: 'inherit', + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git rm --cached exited with status ${exitCode}.`); + } +} + +// Locates a usable trash CLI binary, caching the lookup per runner process. +async function findTrashCliCommand(): Promise { + if (cachedTrashCliCommand !== undefined) { + return cachedTrashCliCommand; + } + + const candidateNames = ['trash-put', 'trash']; + const searchDirs = new Set(); + + if (process.env.PATH) { + for (const segment of process.env.PATH.split(':')) { + if (segment && segment.length > 0) { + searchDirs.add(segment); + } + } + } + + const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? '/opt/homebrew'; + searchDirs.add(join(homebrewPrefix, 'opt', 'trash', 'bin')); + searchDirs.add('/usr/local/opt/trash/bin'); + + const candidatePaths = new Set(); + for (const name of candidateNames) { + candidatePaths.add(name); + for (const dir of searchDirs) { + candidatePaths.add(join(dir, name)); + } + } + + for (const candidate of candidatePaths) { + try { + const proc = Bun.spawn([candidate, '--help'], { + stdout: 'ignore', + stderr: 'ignore', + }); + const exitCode = await proc.exited; + if (exitCode === 0 || exitCode === 1) { + cachedTrashCliCommand = candidate; + return candidate; + } + } catch (error) { + if (ENABLE_DEBUG_LOGS) { + console.error(`[runner] trash-cli probe failed for ${candidate}: ${formatTrashError(error)}`); + } + } + } + + cachedTrashCliCommand = null; + return null; +} + +// Consumes a child process stream to completion for logging/error output. +async function readProcessStream(stream: unknown): Promise { + if (!stream) { + return ''; + } + try { + const candidate = stream as { text?: () => Promise }; + if (candidate.text) { + return (await candidate.text()) ?? ''; + } + } catch { + // ignore + } + try { + if (stream instanceof ReadableStream) { + return await new Response(stream).text(); + } + if (typeof stream === 'object' && stream !== null) { + return await new Response(stream as BodyInit).text(); + } + } catch { + // ignore errors and return empty string + } + return ''; +} + +// Shows CLI usage plus optional error messaging. +function printUsage(message?: string) { + if (message) { + console.error(`[runner] ${message}`); + } + console.error('Usage: runner [--] '); + console.error(''); + console.error( + `Defaults: ${formatDuration(DEFAULT_TIMEOUT_MS)} timeout for most commands, ${formatDuration( + EXTENDED_TIMEOUT_MS + )} when lint/test suites are detected.` + ); +} + +// Pretty-prints a millisecond duration for logs. +function formatDuration(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms`; + } + const seconds = durationMs / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + if (minutes < 60) { + if (remainingSeconds === 0) { + return `${minutes}m`; + } + return `${minutes}m ${remainingSeconds}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) { + return `${hours}h`; + } + return `${hours}h ${remainingMinutes}m`; +} + +// Joins the command args in a shell-friendly way for log display. +function formatDisplayCommand(commandArgs: string[]): string { + return commandArgs.map((token) => (token.includes(' ') ? `"${token}"` : token)).join(' '); +} + +// Tells whether the runner is already executing inside the tmux guard. +function isRunnerTmuxSession(): boolean { + const value = process.env.RUNNER_TMUX; + if (value) { + return value !== '0' && value.toLowerCase() !== 'false'; + } + return Boolean(process.env.TMUX); +}