File size: 3,095 Bytes
ff34739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import {
  execFile,
  spawn,
  type ChildProcess,
  type ProcessEnvOptions,
} from "node:child_process";
import { promisify } from "node:util";
import { closeSync, openSync } from "node:fs";

const execFileP = promisify(execFile);

export interface ProbeResult {
  ok: boolean;
  stdout: string;
  stderr: string;
  code: number | null;
}

/**
 * Fire a short read-only command (e.g. `claude --version`) and capture output.
 * NEVER runs through a shell — cmd + args only, so user input can't be injected.
 */
export async function probe(
  cmd: string,
  args: string[],
  opts: { cwd?: string; timeoutMs?: number } = {},
): Promise<ProbeResult> {
  try {
    const { stdout, stderr } = await execFileP(cmd, args, {
      cwd: opts.cwd,
      timeout: opts.timeoutMs ?? 6000,
      maxBuffer: 4 * 1024 * 1024,
    });
    return { ok: true, stdout, stderr, code: 0 };
  } catch (e) {
    const err = e as { stdout?: string; stderr?: string; code?: number; message?: string };
    return {
      ok: false,
      stdout: err.stdout ?? "",
      stderr: err.stderr ?? err.message ?? String(e),
      code: typeof err.code === "number" ? err.code : null,
    };
  }
}

export interface SpawnedStep {
  pid: number;
  child: ChildProcess;
  /** resolves with the exit code once the process ends (or -1 on spawn error) */
  done: Promise<number>;
}

/**
 * Spawn a long-running step DETACHED (its own process group) with stdout+stderr
 * appended to a log file. Detached so cancel can kill the whole group — the
 * python step spawns Rust/claude children that a bare SIGTERM to the pid misses.
 */
export function spawnLogged(opts: {
  cmd: string;
  args: string[];
  cwd: string;
  logFile: string;
  env?: NodeJS.ProcessEnv;
}): SpawnedStep {
  const fd = openSync(opts.logFile, "a");
  const spawnOpts: ProcessEnvOptions & {
    detached: boolean;
    stdio: ["ignore", number, number];
  } = {
    cwd: opts.cwd,
    detached: true,
    stdio: ["ignore", fd, fd],
    env: { ...process.env, ...opts.env },
  };
  const child = spawn(opts.cmd, opts.args, spawnOpts);

  const done = new Promise<number>((resolve) => {
    let settled = false;
    const finish = (code: number) => {
      if (settled) return;
      settled = true;
      try {
        closeSync(fd);
      } catch {
        /* already closed */
      }
      resolve(code);
    };
    child.on("exit", (code) => finish(code ?? -1));
    child.on("error", () => finish(-1));
  });

  return { pid: child.pid ?? -1, child, done };
}

/** Is a pid still alive? Used to reconcile run state after a dev-server reload. */
export function isAlive(pid: number): boolean {
  if (!pid || pid <= 0) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

/** Kill an entire detached process group (pid was the group leader). */
export function killGroup(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
  if (!pid || pid <= 0) return;
  try {
    process.kill(-pid, signal);
  } catch {
    try {
      process.kill(pid, signal);
    } catch {
      /* already gone */
    }
  }
}