diff --git a/packages/adapter-cursor/src/index.ts b/packages/adapter-cursor/src/index.ts index 5f85e5c..dd436fe 100644 --- a/packages/adapter-cursor/src/index.ts +++ b/packages/adapter-cursor/src/index.ts @@ -1,7 +1,5 @@ import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; -import { type Result, ok } from "@uncaged/nerve-core"; - -import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js"; +import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core"; export type CursorAgentMode = "plan" | "ask" | "default"; diff --git a/packages/adapter-cursor/src/spawn-safe.ts b/packages/adapter-cursor/src/spawn-safe.ts deleted file mode 100644 index dd86463..0000000 --- a/packages/adapter-cursor/src/spawn-safe.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { spawn } from "node:child_process"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { type Result, err, ok } from "@uncaged/nerve-core"; - -/** Compatible with `process.env` for `child_process.spawn`. */ -export type SpawnEnv = Record; - -export type SpawnResult = { - stdout: string; - stderr: string; - exitCode: number; - /** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */ - signal: string | null; -}; - -export type SpawnError = - | { - kind: "non_zero_exit"; - stdout: string; - stderr: string; - exitCode: number; - signal: string | null; - } - | { kind: "timeout"; stdout: string; stderr: string } - | { kind: "spawn_failed"; message: string } - | { kind: "aborted" }; - -export type SpawnSafeOptions = { - cwd: string | null; - /** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */ - env: SpawnEnv | null; - timeoutMs: number | null; - dryRun: boolean; - /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ - abortSignal: AbortSignal | null; -}; - -type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; - -const DEFAULT_TIMEOUT_MS = 300_000; - -export function nerveCommandEnv(): SpawnEnv { - const home = homedir(); - const pnpmHome = join(home, ".local/share/pnpm"); - return { - ...process.env, - PNPM_HOME: pnpmHome, - PATH: `${pnpmHome}:${process.env.PATH ?? ""}`, - }; -} - -function mergeEnv(user: SpawnEnv | null): SpawnEnv { - const base = nerveCommandEnv(); - if (user === null) { - return base; - } - return { ...base, ...user }; -} - -function resolveWallClockMs( - timeoutMs: number | null, - abortSignal: AbortSignal | null, -): number | null { - if (timeoutMs === null) { - if (abortSignal !== null) { - return null; - } - return DEFAULT_TIMEOUT_MS; - } - return timeoutMs; -} - -function resolveDryRun(options: SpawnSafeOptionsInput): boolean { - return "dryRun" in options ? options.dryRun : false; -} - -function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null { - return "abortSignal" in options ? options.abortSignal : null; -} - -export function spawnSafe( - command: string, - args: ReadonlyArray, - options: SpawnSafeOptionsInput, -): Promise> { - const dryRun = resolveDryRun(options); - if (dryRun) { - return Promise.resolve( - ok({ - stdout: "[dryRun] skipped", - stderr: "", - exitCode: 0, - signal: null, - }), - ); - } - - const abortSignal = normalizeAbortSignal(options); - if (abortSignal?.aborted) { - return Promise.resolve(err({ kind: "aborted" })); - } - - return new Promise((resolve) => { - const cwd = options.cwd === null ? process.cwd() : options.cwd; - const env = mergeEnv(options.env); - const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal); - - const child = spawn(command, args, { - cwd, - env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - - let timer: ReturnType | undefined; - const finish = (outcome: Result) => { - if (settled) { - return; - } - settled = true; - if (timer !== undefined) { - clearTimeout(timer); - } - if (abortSignal !== null) { - abortSignal.removeEventListener("abort", onAbort); - } - resolve(outcome); - }; - - function onAbort() { - child.kill("SIGTERM"); - finish(err({ kind: "aborted" })); - } - - if (abortSignal !== null) { - abortSignal.addEventListener("abort", onAbort); - } - - if (wallClockMs !== null) { - timer = setTimeout(() => { - child.kill("SIGTERM"); - finish(err({ kind: "timeout", stdout, stderr })); - }, wallClockMs); - } - - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); - }); - - child.on("error", (cause: Error) => { - finish(err({ kind: "spawn_failed", message: cause.message })); - }); - - child.on("close", (code, signal) => { - const exitCode = code ?? 1; - const sig = signal === undefined || signal === null ? null : String(signal); - const result: SpawnResult = { - stdout: stdout.trimEnd(), - stderr: stderr.trimEnd(), - exitCode, - signal: sig, - }; - if (exitCode !== 0) { - finish( - err({ - kind: "non_zero_exit", - stdout: result.stdout, - stderr: result.stderr, - exitCode, - signal: sig, - }), - ); - return; - } - finish(ok(result)); - }); - }); -} diff --git a/packages/adapter-hermes/src/index.ts b/packages/adapter-hermes/src/index.ts index 66c8cd8..03fa9ad 100644 --- a/packages/adapter-hermes/src/index.ts +++ b/packages/adapter-hermes/src/index.ts @@ -1,7 +1,5 @@ import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; -import { type Result, ok } from "@uncaged/nerve-core"; - -import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js"; +import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core"; /** * Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only diff --git a/packages/adapter-hermes/src/spawn-safe.ts b/packages/adapter-hermes/src/spawn-safe.ts deleted file mode 100644 index dd86463..0000000 --- a/packages/adapter-hermes/src/spawn-safe.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { spawn } from "node:child_process"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { type Result, err, ok } from "@uncaged/nerve-core"; - -/** Compatible with `process.env` for `child_process.spawn`. */ -export type SpawnEnv = Record; - -export type SpawnResult = { - stdout: string; - stderr: string; - exitCode: number; - /** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */ - signal: string | null; -}; - -export type SpawnError = - | { - kind: "non_zero_exit"; - stdout: string; - stderr: string; - exitCode: number; - signal: string | null; - } - | { kind: "timeout"; stdout: string; stderr: string } - | { kind: "spawn_failed"; message: string } - | { kind: "aborted" }; - -export type SpawnSafeOptions = { - cwd: string | null; - /** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */ - env: SpawnEnv | null; - timeoutMs: number | null; - dryRun: boolean; - /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ - abortSignal: AbortSignal | null; -}; - -type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; - -const DEFAULT_TIMEOUT_MS = 300_000; - -export function nerveCommandEnv(): SpawnEnv { - const home = homedir(); - const pnpmHome = join(home, ".local/share/pnpm"); - return { - ...process.env, - PNPM_HOME: pnpmHome, - PATH: `${pnpmHome}:${process.env.PATH ?? ""}`, - }; -} - -function mergeEnv(user: SpawnEnv | null): SpawnEnv { - const base = nerveCommandEnv(); - if (user === null) { - return base; - } - return { ...base, ...user }; -} - -function resolveWallClockMs( - timeoutMs: number | null, - abortSignal: AbortSignal | null, -): number | null { - if (timeoutMs === null) { - if (abortSignal !== null) { - return null; - } - return DEFAULT_TIMEOUT_MS; - } - return timeoutMs; -} - -function resolveDryRun(options: SpawnSafeOptionsInput): boolean { - return "dryRun" in options ? options.dryRun : false; -} - -function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null { - return "abortSignal" in options ? options.abortSignal : null; -} - -export function spawnSafe( - command: string, - args: ReadonlyArray, - options: SpawnSafeOptionsInput, -): Promise> { - const dryRun = resolveDryRun(options); - if (dryRun) { - return Promise.resolve( - ok({ - stdout: "[dryRun] skipped", - stderr: "", - exitCode: 0, - signal: null, - }), - ); - } - - const abortSignal = normalizeAbortSignal(options); - if (abortSignal?.aborted) { - return Promise.resolve(err({ kind: "aborted" })); - } - - return new Promise((resolve) => { - const cwd = options.cwd === null ? process.cwd() : options.cwd; - const env = mergeEnv(options.env); - const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal); - - const child = spawn(command, args, { - cwd, - env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - - let timer: ReturnType | undefined; - const finish = (outcome: Result) => { - if (settled) { - return; - } - settled = true; - if (timer !== undefined) { - clearTimeout(timer); - } - if (abortSignal !== null) { - abortSignal.removeEventListener("abort", onAbort); - } - resolve(outcome); - }; - - function onAbort() { - child.kill("SIGTERM"); - finish(err({ kind: "aborted" })); - } - - if (abortSignal !== null) { - abortSignal.addEventListener("abort", onAbort); - } - - if (wallClockMs !== null) { - timer = setTimeout(() => { - child.kill("SIGTERM"); - finish(err({ kind: "timeout", stdout, stderr })); - }, wallClockMs); - } - - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); - }); - - child.on("error", (cause: Error) => { - finish(err({ kind: "spawn_failed", message: cause.message })); - }); - - child.on("close", (code, signal) => { - const exitCode = code ?? 1; - const sig = signal === undefined || signal === null ? null : String(signal); - const result: SpawnResult = { - stdout: stdout.trimEnd(), - stderr: stderr.trimEnd(), - exitCode, - signal: sig, - }; - if (exitCode !== 0) { - finish( - err({ - kind: "non_zero_exit", - stdout: result.stdout, - stderr: result.stderr, - exitCode, - signal: sig, - }), - ); - return; - } - finish(ok(result)); - }); - }); -} diff --git a/packages/workflow-utils/src/__tests__/spawn-safe.test.ts b/packages/core/src/__tests__/spawn-safe.test.ts similarity index 96% rename from packages/workflow-utils/src/__tests__/spawn-safe.test.ts rename to packages/core/src/__tests__/spawn-safe.test.ts index 9a6313d..bca7ae3 100644 --- a/packages/workflow-utils/src/__tests__/spawn-safe.test.ts +++ b/packages/core/src/__tests__/spawn-safe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { spawnSafe } from "../shared/spawn-safe.js"; +import { spawnSafe } from "../spawn-safe.js"; describe("spawnSafe", () => { it("passes argv literally without shell interpretation (injection-safe)", async () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1fbad12..7145aa9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,14 @@ export type { Schema, ExtractFn } from "./extract-layer.js"; export { ExtractError } from "./extract-layer.js"; export type { Result } from "./result.js"; export { ok, err } from "./result.js"; +export { + nerveCommandEnv, + spawnSafe, + type SpawnEnv, + type SpawnError, + type SpawnResult, + type SpawnSafeOptions, +} from "./spawn-safe.js"; export { parseNerveConfig } from "./parse-nerve-config.js"; export type { KnowledgeConfig } from "./knowledge-config.js"; export { parseKnowledgeYaml } from "./knowledge-config.js"; diff --git a/packages/workflow-utils/src/shared/spawn-safe.ts b/packages/core/src/spawn-safe.ts similarity index 98% rename from packages/workflow-utils/src/shared/spawn-safe.ts rename to packages/core/src/spawn-safe.ts index 3451ad1..d2c17ce 100644 --- a/packages/workflow-utils/src/shared/spawn-safe.ts +++ b/packages/core/src/spawn-safe.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; -import { type Result, err, ok } from "@uncaged/nerve-core"; +import { type Result, err, ok } from "./result.js"; /** Compatible with `process.env` for `child_process.spawn`. */ export type SpawnEnv = Record; diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 478da45..6df3169 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -25,7 +25,7 @@ export { type SpawnError, type SpawnResult, type SpawnSafeOptions, -} from "./shared/spawn-safe.js"; +} from "@uncaged/nerve-core"; export type { LlmError, LlmProvider } from "./shared/llm-extract.js"; export type { CliPromptFn, diff --git a/packages/workflow-utils/src/role-cursor.ts b/packages/workflow-utils/src/role-cursor.ts index 9bee572..a827260 100644 --- a/packages/workflow-utils/src/role-cursor.ts +++ b/packages/workflow-utils/src/role-cursor.ts @@ -1,11 +1,10 @@ import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor"; -import type { Role } from "@uncaged/nerve-core"; +import type { Role, SpawnEnv } from "@uncaged/nerve-core"; import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js"; import { isDryRun } from "./role-types.js"; import { formatLlmError } from "./shared/format-error.js"; import { llmExtract } from "./shared/llm-extract.js"; -import type { SpawnEnv } from "./shared/spawn-safe.js"; const CURSOR_DEFAULTS: CursorRoleDefaults = { mode: "default", diff --git a/packages/workflow-utils/src/role-types.ts b/packages/workflow-utils/src/role-types.ts index 6e97f0b..63503f9 100644 --- a/packages/workflow-utils/src/role-types.ts +++ b/packages/workflow-utils/src/role-types.ts @@ -1,8 +1,7 @@ -import type { StartStep } from "@uncaged/nerve-core"; +import type { SpawnEnv, StartStep } from "@uncaged/nerve-core"; import type { z } from "zod"; import type { LlmProvider } from "./shared/llm-extract.js"; -import type { SpawnEnv } from "./shared/spawn-safe.js"; /** Returns the thread-level dry-run flag from the workflow start frame. */ export function isDryRun(start: StartStep): boolean {