@john-ezra/openralph 0.1.1

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.
package/src/cli.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { readOptionsFromEnv, runOpenRalphLauncher, type RunLauncherInput } from "./launcher.ts"
2
+ import { DEFAULT_DOCKER_IMAGE, validateDockerImageReference, type LoopPhase } from "./args.ts"
3
+ import { buildLocalDockerImage } from "./docker.ts"
4
+ import type { CommandResult } from "./exec.ts"
5
+
6
+ export interface CliDeps {
7
+ cwd?: string
8
+ env?: NodeJS.ProcessEnv
9
+ stderr?: Pick<NodeJS.WritableStream, "write">
10
+ stdout?: Pick<NodeJS.WritableStream, "write">
11
+ runLauncher?: typeof runOpenRalphLauncher
12
+ buildDockerImage?: typeof buildLocalDockerImage
13
+ }
14
+
15
+ export async function runCli(argv = process.argv.slice(2), deps: CliDeps = {}): Promise<number> {
16
+ const stdout = deps.stdout ?? process.stdout
17
+ const stderr = deps.stderr ?? process.stderr
18
+ const command = argv[0]
19
+
20
+ if (command === "docker") return runDockerCli(argv.slice(1), deps)
21
+
22
+ if (command !== "plan" && command !== "build") {
23
+ stderr.write(`${usage()}\n`)
24
+ return 2
25
+ }
26
+
27
+ try {
28
+ const phase = command as LoopPhase
29
+ const options = readOptionsFromEnv(deps.env)
30
+ const input: RunLauncherInput = {
31
+ phase,
32
+ rawArgs: argv.slice(1).join(" "),
33
+ cwd: deps.cwd ?? process.cwd(),
34
+ options,
35
+ streamOutput: true,
36
+ captureOutput: true,
37
+ }
38
+ const result = await (deps.runLauncher ?? runOpenRalphLauncher)(input, { env: deps.env })
39
+ stdout.write(`${result.summary}\n`)
40
+ return result.status === "failed" ? 1 : 0
41
+ } catch (error) {
42
+ stderr.write(`OpenRalph failed: ${formatError(error)}\n`)
43
+ return 1
44
+ }
45
+ }
46
+
47
+ async function runDockerCli(argv: string[], deps: CliDeps): Promise<number> {
48
+ const stdout = deps.stdout ?? process.stdout
49
+ const stderr = deps.stderr ?? process.stderr
50
+ const subcommand = argv[0]
51
+
52
+ if (subcommand !== "build") {
53
+ stderr.write(`${usage()}\n`)
54
+ return 2
55
+ }
56
+
57
+ try {
58
+ const input = parseDockerBuildArgs(argv.slice(1))
59
+ const result = await (deps.buildDockerImage ?? buildLocalDockerImage)({
60
+ tag: input.tag,
61
+ noCache: input.noCache,
62
+ streamOutput: true,
63
+ captureOutput: true,
64
+ })
65
+
66
+ if (result.exitCode !== 0) {
67
+ stderr.write(`${formatDockerBuildFailure(result)}\n`)
68
+ return 1
69
+ }
70
+
71
+ stdout.write(`OpenRalph Docker image built: ${input.tag}\n`)
72
+ return 0
73
+ } catch (error) {
74
+ stderr.write(`OpenRalph failed: ${formatError(error)}\n`)
75
+ return 1
76
+ }
77
+ }
78
+
79
+ function parseDockerBuildArgs(argv: string[]): { tag: string; noCache: boolean } {
80
+ const parsed = { tag: DEFAULT_DOCKER_IMAGE, noCache: false }
81
+
82
+ for (let index = 0; index < argv.length; index += 1) {
83
+ const token = argv[index]
84
+
85
+ if (token === "--tag") {
86
+ const value = argv[index + 1]
87
+ if (!value || value.startsWith("-")) throw new Error("--tag requires an image tag")
88
+ parsed.tag = validateDockerImageReference(value, "--tag")
89
+ index += 1
90
+ continue
91
+ }
92
+
93
+ if (token === "--no-cache") {
94
+ parsed.noCache = true
95
+ continue
96
+ }
97
+
98
+ if (token.startsWith("-")) throw new Error(`Unknown flag: ${token}`)
99
+ throw new Error(`Unexpected argument: ${token}`)
100
+ }
101
+
102
+ return parsed
103
+ }
104
+
105
+ function formatDockerBuildFailure(result: CommandResult): string {
106
+ const reason = result.exitCode === null ? `signal ${result.signal ?? "unknown"}` : `exit code ${result.exitCode}`
107
+ return `OpenRalph Docker image build failed: Docker exited with ${reason}`
108
+ }
109
+
110
+ function usage(): string {
111
+ return [
112
+ "Usage:",
113
+ " openralph plan [max] [--model <model>] [--no-docker]",
114
+ " openralph build [max] [--model <model>] [--push] [--no-docker]",
115
+ " openralph docker build [--tag <image>] [--no-cache]",
116
+ ].join("\n")
117
+ }
118
+
119
+ function formatError(error: unknown): string {
120
+ return error instanceof Error ? error.message : String(error)
121
+ }
122
+
123
+ if (import.meta.main) {
124
+ process.exit(await runCli())
125
+ }
package/src/design.ts ADDED
@@ -0,0 +1,24 @@
1
+ export const DESIGN_SYSTEM_PROMPT = `# Ralph Design Requirements
2
+
3
+ You are running Phase 1: Design Requirements for Ralph.
4
+
5
+ Your goal is to turn early ideation into planning-ready \`specs/*.md\` artifacts for the Ralph planning phase. The next phase, Ralph Plan, will compare \`specs/*\` against the current codebase and create or refine \`IMPLEMENTATION_PLAN.md\`.
6
+
7
+ Guide the user through requirements discovery when the idea is vague. Clarify Jobs to Be Done, users, workflows, constraints, risks, edge cases, acceptance criteria, validation expectations, and non-goals. Ask targeted questions only when the answer changes the required behavior or planning readiness.
8
+
9
+ Break each Job to Be Done into topics of concern. Apply the one-sentence-without-and topic scope test: if a topic requires "and" to describe unrelated capabilities, split it.
10
+
11
+ Write or refine one \`specs/*.md\` file per topic of concern. Keep specs behavioral rather than implementation-prescriptive unless an implementation detail is a hard constraint. Capture assumptions and unresolved questions explicitly.
12
+
13
+ Do not create or edit \`IMPLEMENTATION_PLAN.md\`. Do not implement code. Do not commit. Do not proceed to planning or building.
14
+
15
+ When specs are planning-ready, tell the user they are ready to run OpenRalph: Plan. If specs are not ready, list the unresolved questions or blockers.`
16
+
17
+ export function buildDesignUserPrompt(initialIdea: string): string {
18
+ const idea = initialIdea.trim()
19
+ if (!idea) {
20
+ return "Begin Ralph Design. The user did not provide an initial idea, so ask what we are working on before drafting or changing specs."
21
+ }
22
+
23
+ return [`Begin Ralph Design from this initial idea:`, "", idea].join("\n")
24
+ }
package/src/docker.ts ADDED
@@ -0,0 +1,403 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { access, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"
3
+ import { homedir, tmpdir } from "node:os"
4
+ import { basename, join, relative, sep } from "node:path"
5
+ import {
6
+ DEFAULT_DOCKER_IMAGE,
7
+ formatLoopArgsForReplay,
8
+ parseLoopArgs,
9
+ resolveDockerOptions,
10
+ type LoopPhase,
11
+ type OpenRalphOptions,
12
+ type ParsedLoopArgs,
13
+ type ResolvedDockerOptions,
14
+ } from "./args.ts"
15
+ import { startCommand, type CommandOutputEvent, type CommandResult } from "./exec.ts"
16
+ import { runGitCommand } from "./git.ts"
17
+ import { DOCKER_TOKEN_PATH } from "./trust.ts"
18
+
19
+ export const CONTAINER_WORKSPACE = "/workspace"
20
+ export const CONTAINER_HOME = "/home/opencode"
21
+ export const IMAGE_PLUGIN_PATH = "file:///opt/openralph/src/plugin.ts"
22
+ export const CHROME_DEVTOOLS_MCP_VERSION = "1.1.1"
23
+ export const CHROME_DEVTOOLS_MCP_PACKAGE = `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`
24
+ export const CHROME_DEVTOOLS_MCP_WRAPPER = "/opt/openralph/bin/chrome-devtools-mcp-wrapper"
25
+ export const CHROME_DEVTOOLS_MCP_COMMAND = [
26
+ CHROME_DEVTOOLS_MCP_WRAPPER,
27
+ "--no-usage-statistics",
28
+ "--no-performance-crux",
29
+ "--experimental-vision",
30
+ ] as const
31
+
32
+ const ENV_EXAMPLE_FILES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"])
33
+ const ENV_SCAN_SKIP_DIRS = new Set([".git", "node_modules", "dist", "coverage"])
34
+ const CONTAINER_GIT_CONFIG = [
35
+ ["safe.directory", CONTAINER_WORKSPACE],
36
+ ["commit.gpgsign", "false"],
37
+ ["tag.gpgsign", "false"],
38
+ ] as const
39
+
40
+ export interface RunDockerLoopInput {
41
+ phase: LoopPhase
42
+ rawArgs: string
43
+ projectRoot: string
44
+ options: OpenRalphOptions
45
+ streamOutput?: boolean
46
+ captureOutput?: boolean
47
+ onOutput?: (event: CommandOutputEvent) => void
48
+ signal?: AbortSignal
49
+ }
50
+
51
+ export interface BuildLocalDockerImageInput {
52
+ tag?: string
53
+ noCache?: boolean
54
+ packageRoot?: string
55
+ streamOutput?: boolean
56
+ captureOutput?: boolean
57
+ onOutput?: (event: CommandOutputEvent) => void
58
+ signal?: AbortSignal
59
+ }
60
+
61
+ export interface BuildDockerImageArgsInput {
62
+ tag?: string
63
+ noCache?: boolean
64
+ packageRoot?: string
65
+ }
66
+
67
+ export interface DockerMount {
68
+ source: string
69
+ target: string
70
+ readonly?: boolean
71
+ }
72
+
73
+ export interface BuildDockerArgsInput {
74
+ phase: LoopPhase
75
+ replayArgs: string
76
+ projectRoot: string
77
+ authPath: string
78
+ options: OpenRalphOptions
79
+ docker: ResolvedDockerOptions
80
+ envMasks: DockerMount[]
81
+ uid?: number
82
+ gid?: number
83
+ gitIdentity?: GitIdentity
84
+ imagePluginPath?: string
85
+ dockerToken: DockerToken
86
+ }
87
+
88
+ export interface GitIdentity {
89
+ name: string
90
+ email: string
91
+ }
92
+
93
+ export interface PreparedEnvMasks {
94
+ mounts: DockerMount[]
95
+ cleanup: () => Promise<void>
96
+ }
97
+
98
+ export interface DockerToken {
99
+ token: string
100
+ file: string
101
+ }
102
+
103
+ export async function runDockerLoop(input: RunDockerLoopInput): Promise<CommandResult> {
104
+ const parsed = parseLoopArgs(input.phase, input.rawArgs)
105
+ const docker = resolveDockerOptions(input.options)
106
+ const authPath = defaultAuthPath()
107
+ await requireReadableFile(authPath, "OpenCode auth file")
108
+
109
+ const envMasks = docker.maskEnv ? await prepareEnvMasks(input.projectRoot) : emptyEnvMasks()
110
+ const dockerToken = await prepareDockerToken()
111
+ let activeChild: ReturnType<typeof startCommand>["child"] | undefined
112
+ let stopRequested = false
113
+
114
+ const onSigint = () => {
115
+ stopRequested = true
116
+ activeChild?.kill("SIGINT")
117
+ process.stderr.write("\nOpenRalph Docker stop requested. Waiting for container to exit...\n")
118
+ }
119
+
120
+ const onAbort = () => {
121
+ stopRequested = true
122
+ activeChild?.kill("SIGINT")
123
+ }
124
+
125
+ process.on("SIGINT", onSigint)
126
+ if (input.signal?.aborted) onAbort()
127
+ else input.signal?.addEventListener("abort", onAbort, { once: true })
128
+
129
+ try {
130
+ const user = hostUser()
131
+ const gitIdentity = input.phase === "build" ? await requireGitIdentity(input.projectRoot) : undefined
132
+ const running = startCommand(
133
+ "docker",
134
+ buildDockerArgs({
135
+ phase: input.phase,
136
+ replayArgs: formatLoopArgsForReplay(parsed),
137
+ projectRoot: input.projectRoot,
138
+ authPath,
139
+ options: input.options,
140
+ docker,
141
+ envMasks: envMasks.mounts,
142
+ uid: user.uid,
143
+ gid: user.gid,
144
+ gitIdentity,
145
+ dockerToken,
146
+ }),
147
+ {
148
+ cwd: input.projectRoot,
149
+ streamOutput: input.streamOutput ?? true,
150
+ captureOutput: input.captureOutput ?? true,
151
+ onOutput: input.onOutput,
152
+ signal: input.signal,
153
+ },
154
+ )
155
+ activeChild = running.child
156
+ const result = await running.result
157
+ if (stopRequested) throw new Error("Docker execution stopped by user")
158
+ return result
159
+ } catch (error) {
160
+ if (stopRequested) throw new Error(`Docker execution stopped: ${formatError(error)}`)
161
+ throw error
162
+ } finally {
163
+ activeChild = undefined
164
+ process.off("SIGINT", onSigint)
165
+ input.signal?.removeEventListener("abort", onAbort)
166
+ await dockerToken.cleanup()
167
+ await envMasks.cleanup()
168
+ }
169
+ }
170
+
171
+ export function buildDockerImageArgs(input: BuildDockerImageArgsInput = {}): string[] {
172
+ const packageRoot = input.packageRoot ?? defaultPackageRoot()
173
+ const args = ["build", "--file", join(packageRoot, "container", "Dockerfile"), "--tag", input.tag ?? DEFAULT_DOCKER_IMAGE]
174
+ if (input.noCache) args.push("--no-cache")
175
+ args.push(packageRoot)
176
+ return args
177
+ }
178
+
179
+ export async function buildLocalDockerImage(input: BuildLocalDockerImageInput = {}): Promise<CommandResult> {
180
+ const packageRoot = input.packageRoot ?? defaultPackageRoot()
181
+ return startCommand("docker", buildDockerImageArgs({ tag: input.tag, noCache: input.noCache, packageRoot }), {
182
+ cwd: packageRoot,
183
+ streamOutput: input.streamOutput ?? true,
184
+ captureOutput: input.captureOutput ?? true,
185
+ onOutput: input.onOutput,
186
+ signal: input.signal,
187
+ }).result
188
+ }
189
+
190
+ export function buildDockerArgs(input: BuildDockerArgsInput): string[] {
191
+ const configContent = buildContainerConfig(input.options, input.docker, input.imagePluginPath)
192
+ const args = [
193
+ "run",
194
+ "--pull=never",
195
+ "--rm",
196
+ "--shm-size=1g",
197
+ "--workdir",
198
+ CONTAINER_WORKSPACE,
199
+ "--security-opt",
200
+ "no-new-privileges:true",
201
+ ]
202
+
203
+ appendEnv(args, "OPENRALPH_IN_DOCKER", "1")
204
+ appendEnv(args, "OPENRALPH_DOCKER_TOKEN", input.dockerToken.token)
205
+ appendEnv(args, "OPENRALPH_OPTIONS_JSON", JSON.stringify(input.options))
206
+ appendEnv(args, "OPENCODE_DISABLE_PROJECT_CONFIG", "1")
207
+ appendEnv(args, "OPENCODE_CONFIG_CONTENT", configContent)
208
+ appendEnv(args, "HOME", CONTAINER_HOME)
209
+ appendGitConfigEnv(args)
210
+
211
+ if (input.gitIdentity) appendGitIdentityEnv(args, input.gitIdentity)
212
+
213
+ if (input.uid !== undefined && input.gid !== undefined) {
214
+ args.push("--user", `${input.uid}:${input.gid}`)
215
+ }
216
+
217
+ args.push("--mount", bindMount(input.projectRoot, CONTAINER_WORKSPACE))
218
+ args.push("--mount", bindMount(input.authPath, `${CONTAINER_HOME}/.local/share/opencode/auth.json`, true))
219
+ args.push("--mount", bindMount(input.dockerToken.file, DOCKER_TOKEN_PATH, true))
220
+
221
+ for (const mask of input.envMasks) {
222
+ args.push("--mount", bindMount(mask.source, mask.target, mask.readonly ?? true))
223
+ }
224
+
225
+ args.push(input.docker.image, "openralph", input.phase)
226
+ if (input.replayArgs) args.push(input.replayArgs)
227
+ return args
228
+ }
229
+
230
+ export function buildContainerConfig(
231
+ options: OpenRalphOptions,
232
+ docker: ResolvedDockerOptions = resolveDockerOptions(options),
233
+ imagePluginPath = IMAGE_PLUGIN_PATH,
234
+ ): string {
235
+ const pluginOptions: OpenRalphOptions = {
236
+ ...(options.defineModel ? { defineModel: options.defineModel } : {}),
237
+ ...(options.planModel ? { planModel: options.planModel } : {}),
238
+ ...(options.buildModel ? { buildModel: options.buildModel } : {}),
239
+ docker: {
240
+ enabled: docker.enabled,
241
+ image: docker.image,
242
+ maskEnv: docker.maskEnv,
243
+ },
244
+ }
245
+
246
+ return JSON.stringify({
247
+ $schema: "https://opencode.ai/config.json",
248
+ plugin: [[imagePluginPath, pluginOptions]],
249
+ mcp: {
250
+ "chrome-devtools": {
251
+ type: "local",
252
+ command: [...CHROME_DEVTOOLS_MCP_COMMAND],
253
+ environment: {
254
+ CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: "1",
255
+ },
256
+ enabled: true,
257
+ },
258
+ },
259
+ })
260
+ }
261
+
262
+ export async function readGitIdentity(cwd: string): Promise<GitIdentity | undefined> {
263
+ const [name, email] = await Promise.all([readGitConfigValue(cwd, "user.name"), readGitConfigValue(cwd, "user.email")])
264
+ if (!name || !email) return undefined
265
+ return { name, email }
266
+ }
267
+
268
+ export async function requireGitIdentity(cwd: string): Promise<GitIdentity> {
269
+ const identity = await readGitIdentity(cwd)
270
+ if (!identity) {
271
+ throw new Error(
272
+ "Dockerized OpenRalph Build requires Git user.name and user.email in host or project Git config. Configure them before rerunning Dockerized builds.",
273
+ )
274
+ }
275
+ return identity
276
+ }
277
+
278
+ export async function detectMaskableEnvFiles(root: string): Promise<string[]> {
279
+ const files: string[] = []
280
+ await collectMaskableEnvFiles(root, files)
281
+ return files.sort()
282
+ }
283
+
284
+ export async function prepareEnvMasks(root: string): Promise<PreparedEnvMasks> {
285
+ const envFiles = await detectMaskableEnvFiles(root)
286
+ if (envFiles.length === 0) return emptyEnvMasks()
287
+
288
+ const tempDir = await mkdtemp(join(tmpdir(), "openralph-env-"))
289
+ const mounts: DockerMount[] = []
290
+
291
+ for (let index = 0; index < envFiles.length; index += 1) {
292
+ const source = join(tempDir, `env-${index}`)
293
+ await writeFile(source, "")
294
+ mounts.push({ source, target: containerPath(root, envFiles[index]), readonly: true })
295
+ }
296
+
297
+ return {
298
+ mounts,
299
+ cleanup: () => rm(tempDir, { recursive: true, force: true }),
300
+ }
301
+ }
302
+
303
+ export function shouldMaskEnvFile(path: string): boolean {
304
+ const name = basename(path)
305
+ return name.startsWith(".env") && !ENV_EXAMPLE_FILES.has(name)
306
+ }
307
+
308
+ export function containerPath(root: string, path: string): string {
309
+ const rel = relative(root, path)
310
+ return `${CONTAINER_WORKSPACE}/${rel.split(sep).join("/")}`
311
+ }
312
+
313
+ function bindMount(source: string, target: string, readonly = false): string {
314
+ validateMountPath(source, "source")
315
+ validateMountPath(target, "target")
316
+ return `type=bind,source=${source},target=${target}${readonly ? ",readonly" : ""}`
317
+ }
318
+
319
+ function validateMountPath(value: string, label: "source" | "target"): void {
320
+ if (value === "" || value.includes(",") || value.includes("\n") || value.includes("\r")) {
321
+ throw new Error(`Docker mount ${label} must not be empty or contain commas/newlines: ${value}`)
322
+ }
323
+ if (!value.startsWith("/")) {
324
+ throw new Error(`Docker mount ${label} must be an absolute path: ${value}`)
325
+ }
326
+ }
327
+
328
+ function appendEnv(args: string[], name: string, value: string): void {
329
+ args.push("--env", `${name}=${value}`)
330
+ }
331
+
332
+ function appendGitConfigEnv(args: string[]): void {
333
+ appendEnv(args, "GIT_CONFIG_COUNT", String(CONTAINER_GIT_CONFIG.length))
334
+ CONTAINER_GIT_CONFIG.forEach(([key, value], index) => {
335
+ appendEnv(args, `GIT_CONFIG_KEY_${index}`, key)
336
+ appendEnv(args, `GIT_CONFIG_VALUE_${index}`, value)
337
+ })
338
+ }
339
+
340
+ function appendGitIdentityEnv(args: string[], identity: GitIdentity): void {
341
+ appendEnv(args, "GIT_AUTHOR_NAME", identity.name)
342
+ appendEnv(args, "GIT_AUTHOR_EMAIL", identity.email)
343
+ appendEnv(args, "GIT_COMMITTER_NAME", identity.name)
344
+ appendEnv(args, "GIT_COMMITTER_EMAIL", identity.email)
345
+ }
346
+
347
+ async function readGitConfigValue(cwd: string, key: string): Promise<string | undefined> {
348
+ const result = await runGitCommand(["config", "--get", key], cwd)
349
+ if (result.exitCode !== 0) return undefined
350
+ const value = result.stdout.trim()
351
+ return value || undefined
352
+ }
353
+
354
+ function defaultAuthPath(): string {
355
+ return join(homedir(), ".local", "share", "opencode", "auth.json")
356
+ }
357
+
358
+ function defaultPackageRoot(): string {
359
+ return join(import.meta.dir, "..")
360
+ }
361
+
362
+ async function requireReadableFile(path: string, label: string): Promise<void> {
363
+ try {
364
+ await access(path)
365
+ } catch {
366
+ throw new Error(`${label} not found at ${path}`)
367
+ }
368
+ }
369
+
370
+ async function collectMaskableEnvFiles(dir: string, files: string[]): Promise<void> {
371
+ const entries = await readdir(dir, { withFileTypes: true })
372
+
373
+ for (const entry of entries) {
374
+ const path = join(dir, entry.name)
375
+ if (entry.isDirectory()) {
376
+ if (!ENV_SCAN_SKIP_DIRS.has(entry.name)) await collectMaskableEnvFiles(path, files)
377
+ continue
378
+ }
379
+
380
+ if (entry.isFile() && shouldMaskEnvFile(path)) files.push(path)
381
+ }
382
+ }
383
+
384
+ function emptyEnvMasks(): PreparedEnvMasks {
385
+ return { mounts: [], cleanup: async () => {} }
386
+ }
387
+
388
+ async function prepareDockerToken(): Promise<DockerToken & { cleanup: () => Promise<void> }> {
389
+ const tempDir = await mkdtemp(join(tmpdir(), "openralph-docker-"))
390
+ const token = randomBytes(32).toString("hex")
391
+ const file = join(tempDir, "docker-token")
392
+ await writeFile(file, token)
393
+ return { token, file, cleanup: () => rm(tempDir, { recursive: true, force: true }) }
394
+ }
395
+
396
+ function hostUser(): { uid?: number; gid?: number } {
397
+ if (typeof process.getuid !== "function" || typeof process.getgid !== "function") return {}
398
+ return { uid: process.getuid(), gid: process.getgid() }
399
+ }
400
+
401
+ function formatError(error: unknown): string {
402
+ return error instanceof Error ? error.message : String(error)
403
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+
3
+ export interface CommandResult {
4
+ exitCode: number | null
5
+ signal: NodeJS.Signals | null
6
+ stdout: string
7
+ stderr: string
8
+ }
9
+
10
+ export type CommandOutputStream = "stdout" | "stderr"
11
+
12
+ export interface CommandOutputEvent {
13
+ stream: CommandOutputStream
14
+ chunk: string
15
+ }
16
+
17
+ export interface RunningCommand {
18
+ child: ChildProcess
19
+ result: Promise<CommandResult>
20
+ }
21
+
22
+ export interface StartCommandOptions {
23
+ cwd: string
24
+ env?: Record<string, string | undefined>
25
+ streamOutput?: boolean
26
+ captureOutput?: boolean
27
+ onOutput?: (event: CommandOutputEvent) => void
28
+ signal?: AbortSignal
29
+ }
30
+
31
+ export function runCommand(command: string, args: string[], cwd: string): Promise<CommandResult> {
32
+ return startCommand(command, args, { cwd, streamOutput: false }).result
33
+ }
34
+
35
+ export async function commandExists(command: string, cwd = process.cwd()): Promise<boolean> {
36
+ try {
37
+ await runCommand(command, ["--version"], cwd)
38
+ return true
39
+ } catch (error) {
40
+ return !isMissingCommandError(error)
41
+ }
42
+ }
43
+
44
+ export function startCommand(command: string, args: string[], options: StartCommandOptions): RunningCommand {
45
+ const env = buildEnv(options.env)
46
+ const child = spawn(command, args, {
47
+ cwd: options.cwd,
48
+ env,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ })
51
+
52
+ const streamOutput = options.streamOutput ?? false
53
+ const captureOutput = options.captureOutput ?? true
54
+ let stdout = ""
55
+ let stderr = ""
56
+
57
+ const abort = () => child.kill("SIGINT")
58
+ if (options.signal?.aborted) abort()
59
+ else options.signal?.addEventListener("abort", abort, { once: true })
60
+
61
+ child.stdout.setEncoding("utf8")
62
+ child.stderr.setEncoding("utf8")
63
+
64
+ child.stdout.on("data", (chunk: string) => {
65
+ if (captureOutput) stdout += chunk
66
+ options.onOutput?.({ stream: "stdout", chunk })
67
+ if (streamOutput) process.stdout.write(chunk)
68
+ })
69
+ child.stderr.on("data", (chunk: string) => {
70
+ if (captureOutput) stderr += chunk
71
+ options.onOutput?.({ stream: "stderr", chunk })
72
+ if (streamOutput) process.stderr.write(chunk)
73
+ })
74
+
75
+ const result = new Promise<CommandResult>((resolve, reject) => {
76
+ child.on("error", (error) => {
77
+ options.signal?.removeEventListener("abort", abort)
78
+ reject(error)
79
+ })
80
+ child.on("close", (exitCode, signal) => {
81
+ options.signal?.removeEventListener("abort", abort)
82
+ resolve({ exitCode, signal, stdout, stderr })
83
+ })
84
+ })
85
+
86
+ return { child, result }
87
+ }
88
+
89
+ function buildEnv(overrides: Record<string, string | undefined> | undefined): NodeJS.ProcessEnv {
90
+ const env: NodeJS.ProcessEnv = { ...process.env }
91
+ if (!overrides) return env
92
+
93
+ for (const [key, value] of Object.entries(overrides)) {
94
+ if (value === undefined) delete env[key]
95
+ else env[key] = value
96
+ }
97
+
98
+ return env
99
+ }
100
+
101
+ function isMissingCommandError(error: unknown): boolean {
102
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
103
+ }