chore: wire oracle cli via pnpm link

This commit is contained in:
Peter Steinberger 2025-11-14 20:50:37 +01:00
parent dc6123adfa
commit 227d0e6800
4 changed files with 460 additions and 84 deletions

View File

@ -32,9 +32,8 @@
"poltergeist:stop": "./scripts/poltergeist-wrapper.sh stop",
"poltergeist:rest": "./scripts/poltergeist-wrapper.sh rest",
"poltergeist:status": "./scripts/poltergeist-wrapper.sh status",
"poltergeist:panel": "./scripts/poltergeist-wrapper.sh status panel",
"poltergeist:logs": "./scripts/poltergeist-wrapper.sh logs",
"oracle": "oracle"
"oracle": "pnpm exec -- oracle"
},
"repository": {
"type": "git",
@ -46,7 +45,8 @@
"url": "https://github.com/steipete/peekaboo/issues"
},
"homepage": "https://github.com/steipete/peekaboo#readme",
"devDependencies": {
"@steipete/oracle": "file:../oracle"
"dependencies": {
"@steipete/oracle": "link:../oracle",
"oracle": "link:../oracle"
}
}

6
pnpm-lock.yaml generated
View File

@ -5,12 +5,16 @@ settings:
excludeLinksFromLockfile: false
overrides:
oracle: link:../oracle
'@steipete/oracle': link:../oracle
importers:
.:
devDependencies:
dependencies:
'@steipete/oracle':
specifier: link:../oracle
version: link:../oracle
oracle:
specifier: link:../oracle
version: link:../oracle

View File

@ -1,2 +1,5 @@
packages:
- '.'
overrides:
'@steipete/oracle': link:../oracle
oracle: link:../oracle

View File

@ -1,4 +1,8 @@
#!/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';
@ -20,6 +24,7 @@ const LONG_TIMEOUT_MS = 25 * 60 * 1000; // Build + full-suite commands (Next.js
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 MAX_SLEEP_SECONDS = 30;
const WRAPPER_COMMANDS = new Set([
'sudo',
@ -33,12 +38,21 @@ const WRAPPER_COMMANDS = new Set([
]);
const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
type SummaryStyle = 'compact' | 'minimal' | 'verbose';
const SUMMARY_STYLE = resolveSummaryStyle(process.env.RUNNER_SUMMARY_STYLE);
// 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 LONG_SCRIPT_KEYWORDS = [
'build',
'test:all',
'test:browser',
'test:e2e',
'test:e2e:headed',
'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 SINGLE_TEST_FLAGS = new Set(['--run', '--filter']);
const TEST_BINARIES = new Set(['vitest', 'playwright', 'jest']);
const LINT_BINARIES = new Set(['eslint', 'biome', 'oxlint', 'knip']);
@ -95,6 +109,7 @@ let cachedTrashCliCommand: string | null | undefined;
process.exit(1);
});
// Parses the runner CLI args and rejects unsupported flags early.
function parseArgs(argv: string[]): string[] {
const commandArgs: string[] = [];
let parsingOptions = true;
@ -164,6 +179,7 @@ function findPolterPeekabooInvocation(commandArgs: string[]): { polterIndex: num
return null;
}
// Computes the timeout tier for the provided command tokens.
function determineEffectiveTimeoutMs(commandArgs: string[]): number {
const strippedTokens = stripWrappersAndAssignments(commandArgs);
if (isTestRunnerSuiteInvocation(strippedTokens, 'integration')) {
@ -184,6 +200,7 @@ function determineEffectiveTimeoutMs(commandArgs: string[]): number {
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) {
@ -191,52 +208,71 @@ function shouldExtendTimeout(commandArgs: string[]): boolean {
}
const [first, ...rest] = tokens;
if (!first) {
return false;
}
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;
}
return shouldExtendViaPnpm(rest);
}
if (first === 'bun') {
return shouldExtendViaBun(rest);
}
if (shouldExtendForScript(first) || TEST_BINARIES.has(first.toLowerCase())) {
return true;
}
for (const token of rest) {
if (shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase())) {
return rest.some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
}
function shouldExtendViaPnpm(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
return typeof script === 'string' && shouldExtendForScript(script);
}
if (subcommand === 'exec') {
const execTarget = rest[1];
if (execTarget && (shouldExtendForScript(execTarget) || TEST_BINARIES.has(execTarget.toLowerCase()))) {
return true;
}
return rest.slice(1).some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
}
return shouldExtendForScript(subcommand);
}
function shouldExtendViaBun(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
return typeof script === 'string' && shouldExtendForScript(script);
}
if (subcommand === 'test') {
return true;
}
if (subcommand === 'x' || subcommand === 'bunx') {
const execTarget = rest[1];
if (execTarget && TEST_BINARIES.has(execTarget.toLowerCase())) {
return true;
}
}
return false;
return shouldExtendForScript(subcommand);
}
// Checks script names for long-running markers (lint/test/build/etc.).
function shouldExtendForScript(script: string): boolean {
if (SINGLE_TEST_SCRIPTS.has(script)) {
return false;
@ -244,6 +280,7 @@ function shouldExtendForScript(script: string): boolean {
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) {
@ -251,61 +288,134 @@ function shouldUseLintTimeout(commandArgs: string[]): boolean {
}
const [first, ...rest] = tokens;
if (!first) {
return false;
}
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;
}
}
return shouldUseLintTimeoutViaPnpm(rest);
}
if (first === 'bun') {
return shouldUseLintTimeoutViaBun(rest);
}
if (LINT_BINARIES.has(first.toLowerCase())) {
return true;
}
return false;
return LINT_BINARIES.has(first.toLowerCase());
}
function shouldUseLintTimeoutViaPnpm(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
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;
}
return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
}
return LINT_BINARIES.has(subcommand.toLowerCase());
}
function shouldUseLintTimeoutViaBun(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
return typeof script === 'string' && script.startsWith('lint');
}
if (subcommand === 'x' || subcommand === 'bunx') {
return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
}
return LINT_BINARIES.has(subcommand.toLowerCase());
}
// 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;
}
if (tokens.some((token) => 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;
}
if (!first) {
return false;
}
if (first === 'pnpm') {
return isSingleTestViaPnpm(rest);
}
if (first === 'bun') {
return isSingleTestViaBun(rest);
}
if (first === 'vitest') {
return rest.some((token) => SINGLE_TEST_FLAGS.has(token));
}
return SINGLE_TEST_SCRIPTS.has(first);
}
function isSingleTestViaPnpm(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
}
if (subcommand === 'exec') {
return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
}
return SINGLE_TEST_SCRIPTS.has(subcommand);
}
function isSingleTestViaBun(rest: string[]): boolean {
if (rest.length === 0) {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
}
if (subcommand === 'test') {
return true;
}
if (subcommand === 'x' || subcommand === 'bunx') {
return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
}
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/')) {
@ -318,9 +428,13 @@ function tokenReferencesIntegrationTest(token: string): boolean {
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) {
continue;
}
if (token === '--run' || token === '--include') {
const next = tokens[index + 1];
if (next && tokenReferencesIntegrationTest(next)) {
@ -334,21 +448,44 @@ function referencesIntegrationSpec(tokens: string[]): boolean {
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])) {
while (tokens.length > 0) {
const candidate = tokens[0];
if (!candidate) {
break;
}
if (!isEnvAssignment(candidate)) {
break;
}
tokens.shift();
}
while (tokens.length > 0 && WRAPPER_COMMANDS.has(tokens[0])) {
while (tokens.length > 0) {
const wrapper = tokens[0];
if (!wrapper) {
break;
}
if (!WRAPPER_COMMANDS.has(wrapper)) {
break;
}
tokens.shift();
while (tokens.length > 0 && isEnvAssignment(tokens[0])) {
while (tokens.length > 0) {
const assignment = tokens[0];
if (!assignment) {
break;
}
if (!isEnvAssignment(assignment)) {
break;
}
tokens.shift();
}
}
@ -356,10 +493,12 @@ function stripWrappersAndAssignments(args: string[]): string[] {
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:<suite>` style calls regardless of wrappers.
function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean {
if (tokens.length === 0) {
return false;
@ -368,6 +507,9 @@ function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean {
const normalizedSuite = suite.toLowerCase();
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
const normalizedToken = token.replace(/^[./\\]+/, '');
if (normalizedToken === 'scripts/test-runner.ts' || normalizedToken.endsWith('/scripts/test-runner.ts')) {
const suiteToken = tokens[index + 1]?.toLowerCase();
@ -380,13 +522,18 @@ function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean {
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 first = tokens[0];
if (!first) {
return false;
}
const rest = tokens.slice(1);
const matches = (token: string): boolean => matchesScriptKeyword(token, LONG_SCRIPT_KEYWORDS);
if (first === 'pnpm') {
@ -394,6 +541,9 @@ function shouldUseLongTimeout(commandArgs: string[]): boolean {
return false;
}
const subcommand = rest[0];
if (!subcommand) {
return false;
}
if (subcommand === 'run') {
const script = rest[1];
if (script && matches(script)) {
@ -423,6 +573,7 @@ function shouldUseLongTimeout(commandArgs: string[]): boolean {
return false;
}
// Kicks off the requested command with logging, timeouts, and monitoring.
async function runCommand(context: RunnerExecutionContext): Promise<void> {
const { command, args, env } = buildExecutionParams(context.commandArgs);
const commandLabel = formatDisplayCommand(context.commandArgs);
@ -499,7 +650,7 @@ async function runCommand(context: RunnerExecutionContext): Promise<void> {
`[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).`
formatCompletionSummary({ exitCode, elapsedMs, timedOut: true, commandLabel })
);
process.exit(124);
}
@ -510,7 +661,7 @@ async function runCommand(context: RunnerExecutionContext): Promise<void> {
);
}
console.error(`[runner] Finished ${commandLabel} (exit ${exitCode}, elapsed ${formatDuration(elapsedMs)}).`);
console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
process.exit(exitCode);
} catch (error) {
console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
@ -519,6 +670,40 @@ async function runCommand(context: RunnerExecutionContext): Promise<void> {
}
}
async function runCommandWithoutTimeout(context: RunnerExecutionContext): Promise<void> {
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',
});
const removeSignalHandlers = registerSignalForwarding(child);
try {
const exitCode = await new Promise<number>((resolve, reject) => {
child.once('error', (error) => {
removeSignalHandlers();
reject(error);
});
child.once('exit', (code, signal) => {
removeSignalHandlers();
resolve(code ?? exitCodeFromSignal(signal));
});
});
const elapsedMs = Date.now() - startTime;
console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
process.exit(exitCode);
} catch (error) {
console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// 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[] = [];
@ -527,21 +712,25 @@ function buildExecutionParams(commandArgs: string[]): { command: string; args: s
for (const token of commandArgs) {
if (!commandStarted && isEnvAssignment(token)) {
const [key, ...rest] = token.split('=');
env[key] = rest.join('=');
if (key) {
env[key] = rest.join('=');
}
continue;
}
commandStarted = true;
args.push(token);
}
if (args.length === 0) {
if (args.length === 0 || !args[0]) {
printUsage('Missing command to execute.');
process.exit(1);
}
return { command: args[0], args: args.slice(1), env };
const [command, ...restArgs] = args;
return { command, args: restArgs, 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<NodeJS.Signals, () => void>();
@ -563,6 +752,7 @@ function registerSignalForwarding(child: ChildProcess): () => void {
};
}
// Maps a terminating signal to the exit code conventions bash expects.
function exitCodeFromSignal(signal: NodeJS.Signals | null): number {
if (!signal) {
return 0;
@ -574,11 +764,14 @@ function exitCodeFromSignal(signal: NodeJS.Signals | null): number {
return 1;
}
// Gives policy interceptors a chance to fully handle a command before exec.
async function resolveCommandInterception(context: RunnerExecutionContext): Promise<CommandInterceptionResult> {
const interceptors: Array<(ctx: RunnerExecutionContext) => Promise<boolean>> = [
maybeInjectSwiftPackagePath,
maybeHandleTmuxInvocation,
maybeHandleFindInvocation,
maybeHandleRmInvocation,
maybeHandleSleepInvocation,
];
for (const interceptor of interceptors) {
@ -596,6 +789,7 @@ async function resolveCommandInterception(context: RunnerExecutionContext): Prom
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';
@ -635,6 +829,7 @@ function enforceGitPolicies(gitContext: GitExecutionContext) {
}
}
// Handles guarded `find` invocations that may delete files outright.
async function maybeHandleFindInvocation(context: RunnerExecutionContext): Promise<boolean> {
const findInvocation = extractFindInvocation(context.commandArgs);
if (!findInvocation) {
@ -685,6 +880,7 @@ async function maybeInjectSwiftPackagePath(context: RunnerExecutionContext): Pro
return false;
}
// Intercepts plain `rm` commands to route them through trash safeguards.
async function maybeHandleRmInvocation(context: RunnerExecutionContext): Promise<boolean> {
const rmInvocation = extractRmInvocation(context.commandArgs);
if (!rmInvocation) {
@ -713,6 +909,7 @@ async function maybeHandleRmInvocation(context: RunnerExecutionContext): Promise
return true;
}
// Applies git-specific rm protections before the command executes.
async function maybeHandleGitRm(gitContext: GitExecutionContext): Promise<boolean> {
if (gitContext.command?.name !== 'rm' || !gitContext.invocation) {
return false;
@ -748,6 +945,93 @@ async function maybeHandleGitRm(gitContext: GitExecutionContext): Promise<boolea
return true;
}
// Blocks `sleep` calls longer than the AGENTS.md ceiling so scripts cannot stall the runner.
async function maybeHandleSleepInvocation(context: RunnerExecutionContext): Promise<boolean> {
const tokens = stripWrappersAndAssignments(context.commandArgs);
if (tokens.length === 0) {
return false;
}
const [first, ...rest] = tokens;
if (!first || !isSleepBinary(first) || rest.length === 0) {
return false;
}
const commandIndex = context.commandArgs.length - tokens.length;
if (commandIndex < 0) {
return false;
}
const adjustedArgs = [...context.commandArgs];
const adjustments: string[] = [];
for (let offset = 0; offset < rest.length; offset += 1) {
const token = rest[offset];
const durationSeconds = parseSleepDurationSeconds(token);
if (durationSeconds == null || durationSeconds <= MAX_SLEEP_SECONDS) {
continue;
}
adjustments.push(`${token}${formatSleepDuration(MAX_SLEEP_SECONDS)}`);
adjustedArgs[commandIndex + 1 + offset] = formatSleepArgument(MAX_SLEEP_SECONDS);
}
if (adjustments.length === 0) {
return false;
}
console.error(
`[runner] sleep arguments exceed ${MAX_SLEEP_SECONDS}s; clamping (${adjustments.join(', ')}).`
);
context.commandArgs = adjustedArgs;
return false;
}
async function maybeHandleTmuxInvocation(context: RunnerExecutionContext): Promise<boolean> {
const tokens = stripWrappersAndAssignments(context.commandArgs);
if (tokens.length === 0) {
return false;
}
const candidate = tokens[0];
if (!candidate) {
return false;
}
if (basename(candidate) !== 'tmux') {
return false;
}
console.error('[runner] Detected tmux invocation; executing command without runner timeout guardrails.');
await runCommandWithoutTimeout(context);
return true;
}
function parseSleepDurationSeconds(token: string): number | null {
const match = /^(\d+(?:\.\d+)?)([smhdSMHD]?)$/.exec(token);
if (!match) {
return null;
}
const value = Number(match[1]);
if (!Number.isFinite(value)) {
return null;
}
const unit = match[2]?.toLowerCase() ?? '';
const multiplier = unit === 'm' ? 60 : unit === 'h' ? 60 * 60 : unit === 'd' ? 60 * 60 * 24 : 1;
return value * multiplier;
}
function formatSleepArgument(seconds: number): string {
return Number.isInteger(seconds) ? `${seconds}` : seconds.toString();
}
function formatSleepDuration(seconds: number): string {
if (Number.isInteger(seconds)) {
return `${seconds}s`;
}
return `${seconds.toFixed(2)}s`;
}
function isSleepBinary(token: string): boolean {
return token === 'sleep' || token.endsWith('/sleep');
}
// 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')) {
@ -757,11 +1041,23 @@ function extractFindInvocation(commandArgs: string[]): GitInvocation | null {
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];
@ -772,7 +1068,7 @@ function extractRmInvocation(commandArgs: string[]): GitInvocation | null {
index += 1;
continue;
}
if (WRAPPER_COMMANDS.has(token)) {
if (wrappers.has(token)) {
index += 1;
continue;
}
@ -851,6 +1147,7 @@ function determineSwiftPackagePath(workspaceDir: string): string | null {
return null;
}
// 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;
@ -895,7 +1192,7 @@ async function buildFindDeletePlan(findArgs: string[], workspaceDir: string): Pr
process.exit(exitCode);
}
const matches = stdoutBuf.split('\0').filter((entry) => entry.length > 0);
const matches = stdoutBuf.split('\0').filter((entry: string) => entry.length > 0);
if (matches.length === 0) {
return { paths: [] };
}
@ -918,6 +1215,7 @@ async function buildFindDeletePlan(findArgs: string[], workspaceDir: string): Pr
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;
@ -929,6 +1227,9 @@ function parseRmArguments(argv: string[]): { targets: string[]; force: boolean;
let index = 1;
while (index < argv.length) {
const token = argv[index];
if (token === undefined) {
break;
}
if (!treatAsTarget && token === '--') {
treatAsTarget = true;
index += 1;
@ -959,6 +1260,7 @@ function parseRmArguments(argv: string[]): { targets: string[]; force: boolean;
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[] = [];
@ -969,6 +1271,9 @@ function parseGitRmArguments(argv: string[], command: GitCommandInfo): GitRmPlan
let index = command.index + 1;
while (index < argv.length) {
const token = argv[index];
if (token === undefined) {
break;
}
if (!treatAsPath && token === '--') {
treatAsPath = true;
index += 1;
@ -1034,6 +1339,7 @@ function parseGitRmArguments(argv: string[], command: GitCommandInfo): GitRmPlan
};
}
// Emits actionable messaging when git rm targets are already gone.
function reportMissingForRm(missing: string[], forced: boolean) {
if (missing.length === 0 || forced) {
return;
@ -1044,6 +1350,7 @@ function reportMissingForRm(missing: string[], forced: boolean) {
process.exit(1);
}
// Attempts to move the provided paths into trash instead of deleting in place.
async function movePathsToTrash(
paths: string[],
baseDir: string,
@ -1075,7 +1382,7 @@ async function movePathsToTrash(
stdout: 'ignore',
stderr: 'pipe',
});
const [exitCode, stderrText] = await Promise.all([proc.exited, proc.stderr?.text() ?? Promise.resolve('')]);
const [exitCode, stderrText] = await Promise.all([proc.exited, readProcessStream(proc.stderr)]);
if (exitCode === 0) {
return { missing, errors: [] };
}
@ -1120,6 +1427,7 @@ async function movePathsToTrash(
return { missing, errors };
}
// Resolves a potentially relative path against the workspace root.
function resolvePath(baseDir: string, input: string): string {
if (input.startsWith('/')) {
return input;
@ -1127,6 +1435,7 @@ function resolvePath(baseDir: string, input: string): string {
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) {
@ -1139,6 +1448,7 @@ function getTrashDirectory(): string | 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();
@ -1151,10 +1461,12 @@ function buildTrashTarget(trashDir: string, absolutePath: string): string {
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;
@ -1162,6 +1474,7 @@ function formatTrashError(error: unknown): string {
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;
@ -1178,6 +1491,7 @@ async function stageGitRm(workDir: string, plan: GitRmPlan) {
}
}
// Locates a usable trash CLI binary, caching the lookup per runner process.
async function findTrashCliCommand(): Promise<string | null> {
if (cachedTrashCliCommand !== undefined) {
return cachedTrashCliCommand;
@ -1228,6 +1542,7 @@ async function findTrashCliCommand(): Promise<string | null> {
return null;
}
// Consumes a child process stream to completion for logging/error output.
async function readProcessStream(stream: unknown): Promise<string> {
if (!stream) {
return '';
@ -1253,6 +1568,7 @@ async function readProcessStream(stream: unknown): Promise<string> {
return '';
}
// Shows CLI usage plus optional error messaging.
function printUsage(message?: string) {
if (message) {
console.error(`[runner] ${message}`);
@ -1266,6 +1582,7 @@ function printUsage(message?: string) {
);
}
// Pretty-prints a millisecond duration for logs.
function formatDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs}ms`;
@ -1290,10 +1607,62 @@ function formatDuration(durationMs: number): string {
return `${hours}h ${remainingMinutes}m`;
}
function resolveSummaryStyle(rawValue: string | undefined | null): SummaryStyle {
if (!rawValue) {
return 'compact';
}
const normalized = rawValue.trim().toLowerCase();
switch (normalized) {
case 'minimal':
return 'minimal';
case 'verbose':
return 'verbose';
case 'compact':
case 'short':
default:
return 'compact';
}
}
function formatCompletionSummary(options: {
exitCode: number;
elapsedMs?: number;
timedOut?: boolean;
commandLabel: string;
}): string {
const { exitCode, elapsedMs, timedOut, commandLabel } = options;
const durationText = typeof elapsedMs === 'number' ? formatDuration(elapsedMs) : null;
switch (SUMMARY_STYLE) {
case 'minimal': {
const parts = [`${exitCode}`];
if (durationText) {
parts.push(durationText);
}
if (timedOut) {
parts.push('timeout');
}
return `[runner] ${parts.join(' · ')}`;
}
case 'verbose': {
const elapsedPart = durationText ? `, elapsed ${durationText}` : '';
const timeoutPart = timedOut ? '; timed out' : '';
return `[runner] Finished ${commandLabel} (exit ${exitCode}${elapsedPart}${timeoutPart}).`;
}
case 'compact':
default: {
const elapsedPart = durationText ? ` in ${durationText}` : '';
const timeoutPart = timedOut ? ' (timeout)' : '';
return `[runner] exit ${exitCode}${elapsedPart}${timeoutPart}`;
}
}
}
// 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) {