diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index cbf4b76063..5402612887 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,7 +37,7 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } -function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { +export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { if (process.platform !== "win32") return false; if (code === 9009) return true; return /is not recognized as an internal or external command/i.test(stderr); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b67b90e879..75e5a46258 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -13,13 +13,13 @@ import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, - collectStreamAsString, DEFAULT_TIMEOUT_MS, detailFromResult, extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, + spawnAndCollect, type CommandResult, } from "../providerSnapshot"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; @@ -198,7 +198,6 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { const runClaudeCommand = (args: ReadonlyArray) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( Effect.flatMap((service) => service.getSettings), Effect.map((settings) => settings.providers.claudeAgent), @@ -206,19 +205,8 @@ const runClaudeCommand = (args: ReadonlyArray) => const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { shell: process.platform === "win32", }); - - const child = yield* spawner.spawn(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); + return yield* spawnAndCollect(claudeSettings.binaryPath, command); + }); export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( function* (): Effect.fn.Return< diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6497469a2a..3335a59dab 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -14,13 +14,13 @@ import { resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, - collectStreamAsString, DEFAULT_TIMEOUT_MS, detailFromResult, extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, + spawnAndCollect, type CommandResult, } from "../providerSnapshot"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; @@ -292,7 +292,6 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( const runCodexCommand = (args: ReadonlyArray) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const settingsService = yield* ServerSettingsService; const codexSettings = yield* settingsService.getSettings.pipe( Effect.map((settings) => settings.providers.codex), @@ -304,19 +303,8 @@ const runCodexCommand = (args: ReadonlyArray) => ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), }, }); - - const child = yield* spawner.spawn(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); + return yield* spawnAndCollect(codexSettings.binaryPath, command); + }); export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( function* (): Effect.fn.Return< diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 19111b0485..65f820bd8e 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -5,7 +5,9 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import { Effect, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { isWindowsCommandNotFound } from "../processRunner"; export const DEFAULT_TIMEOUT_MS = 4_000; @@ -35,6 +37,26 @@ export function isCommandMissingCause(error: unknown): boolean { return lower.includes("enoent") || lower.includes("notfound"); } +export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + const result: CommandResult = { stdout, stderr, code: exitCode }; + if (isWindowsCommandNotFound(exitCode, stderr)) { + return yield* Effect.fail(new Error(`spawn ${binaryPath} ENOENT`)); + } + return result; + }).pipe(Effect.scoped); + export function detailFromResult( result: CommandResult & { readonly timedOut?: boolean }, ): string | undefined {