diff --git a/lint/no-try-catch.grit b/lint/no-try-catch.grit new file mode 100644 index 00000000..9f09d5c0 --- /dev/null +++ b/lint/no-try-catch.grit @@ -0,0 +1,23 @@ +// Bans try/catch (with or without finally) across the codebase. +// +// $_ is an AST wildcard — it matches any subtree regardless of how many lines +// it spans, so single-line and multiline try blocks are both caught. +// +// shared/result.ts and shared/parse.ts are excluded because that is where +// tryCatch/asyncTryCatch are implemented, using actual try/catch internally. +language js(typescript) + +or { + `try { $_ } catch ($err) { $_ }`, + `try { $_ } catch { $_ }`, + `try { $_ } catch ($err) { $_ } finally { $_ }`, + `try { $_ } catch { $_ } finally { $_ }` +} as $expr where { + $filename <: not includes "shared/result.ts", + $filename <: not includes "shared/parse.ts", + register_diagnostic( + span = $expr, + message = "Avoid try/catch — use tryCatch / asyncTryCatch from @openrouter/spawn-shared. Sync: const r = tryCatch(() => expr); if (!r.ok) { ... }. Async: const r = await asyncTryCatch(() => fn()); if (!r.ok) { ... }.", + severity = "error" + ) +} diff --git a/lint/no-try-finally.grit b/lint/no-try-finally.grit new file mode 100644 index 00000000..157c4853 --- /dev/null +++ b/lint/no-try-finally.grit @@ -0,0 +1,21 @@ +// Bans bare try/finally (no catch clause) across the codebase. +// +// $_ is an AST wildcard — it matches any subtree regardless of how many lines +// it spans, so single-line and multiline try blocks are both caught. +// +// Guidance: asyncTryCatch() never throws (it returns a Result), so cleanup +// code can simply run sequentially on the next line — no nesting needed. +// +// shared/result.ts and shared/parse.ts are excluded because that is where +// tryCatch/asyncTryCatch are implemented, using actual try/catch internally. +language js(typescript) + +`try { $_ } finally { $_ }` as $expr where { + $filename <: not includes "shared/result.ts", + $filename <: not includes "shared/parse.ts", + register_diagnostic( + span = $expr, + message = "Avoid try/finally — asyncTryCatch() from @openrouter/spawn-shared never throws, so cleanup just runs sequentially. Before: try { await fn(); } finally { cleanup(); }. After: await asyncTryCatch(() => fn()); cleanup();.", + severity = "error" + ) +} diff --git a/packages/cli/biome.json b/packages/cli/biome.json index a6d9784a..bd6d0e63 100644 --- a/packages/cli/biome.json +++ b/packages/cli/biome.json @@ -30,5 +30,10 @@ } } ], - "plugins": ["../../lint/no-type-assertion.grit", "../../lint/no-typeof-string-number.grit"] + "plugins": [ + "../../lint/no-type-assertion.grit", + "../../lint/no-typeof-string-number.grit", + "../../lint/no-try-catch.grit", + "../../lint/no-try-finally.grit" + ] } diff --git a/packages/cli/src/__tests__/cmd-interactive.test.ts b/packages/cli/src/__tests__/cmd-interactive.test.ts index 990bb38d..4b5d186d 100644 --- a/packages/cli/src/__tests__/cmd-interactive.test.ts +++ b/packages/cli/src/__tests__/cmd-interactive.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; @@ -129,11 +130,7 @@ describe("cmdInteractive", () => { CANCEL_SYMBOL, ]); - try { - await cmdInteractive(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdInteractive()); const outroOutput = mockOutro.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n"); expect(outroOutput.toLowerCase()).toContain("cancelled"); @@ -161,11 +158,7 @@ describe("cmdInteractive", () => { CANCEL_SYMBOL, ]); - try { - await cmdInteractive(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdInteractive()); const outroOutput = mockOutro.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n"); expect(outroOutput.toLowerCase()).toContain("cancelled"); @@ -180,11 +173,7 @@ describe("cmdInteractive", () => { CANCEL_SYMBOL, ]); - try { - await cmdInteractive(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdInteractive()); const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); const launchMsg = stepCalls.find((msg: string) => msg.includes("Launching")); @@ -239,11 +228,7 @@ describe("cmdInteractive", () => { "sprite", ]; - try { - await cmdInteractive(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdInteractive()); const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" ")); expect(errorCalls.some((msg: string) => msg.includes("Codex"))).toBe(true); @@ -268,11 +253,7 @@ describe("cmdInteractive", () => { "sprite", ]; - try { - await cmdInteractive(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdInteractive()); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("spawn matrix"))).toBe(true); diff --git a/packages/cli/src/__tests__/cmdlast.test.ts b/packages/cli/src/__tests__/cmdlast.test.ts index e9239c08..d7706c9b 100644 --- a/packages/cli/src/__tests__/cmdlast.test.ts +++ b/packages/cli/src/__tests__/cmdlast.test.ts @@ -3,6 +3,7 @@ import type { SpawnRecord } from "../history"; import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; /** @@ -165,11 +166,7 @@ describe("cmdLast", () => { // We need to mock cmdRun to prevent actual execution // For now, just verify the message is shown - try { - await cmdLast(); - } catch { - // cmdRun might throw in test environment - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); expect(step).toContain("Last spawn"); @@ -180,11 +177,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected to throw when cmdRun is called - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // The most recent is claude/hetzner from 2026-01-03 @@ -197,11 +190,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // Should use display names from manifest @@ -215,11 +204,7 @@ describe("cmdLast", () => { _resetCacheForTesting(); global.fetch = mock(() => Promise.reject(new Error("Network error"))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // Should use raw keys since manifest is unavailable @@ -237,11 +222,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); expect(step).toContain("Claude Code"); @@ -264,11 +245,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // Should show relative time indicator @@ -287,11 +264,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); expect(step).toContain("Claude Code"); @@ -397,11 +370,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // Should handle old dates gracefully @@ -420,11 +389,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); expect(step).toContain("Last spawn"); @@ -452,11 +417,7 @@ describe("cmdLast", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest)))); - try { - await cmdLast(); - } catch { - // Expected - } + await asyncTryCatch(() => cmdLast()); const step = logStepOutput(); // filterHistory().reverse() means the last item in the array becomes first (index 0) diff --git a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts index 8396804e..9b052486 100644 --- a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts +++ b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { HISTORY_SCHEMA_VERSION } from "../history.js"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; @@ -324,11 +325,7 @@ describe("cmdRun happy-path pipeline", () => { }); await loadManifest(true); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected - process.exit from reportScriptFailure - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const historyPath = join(historyDir, "history.json"); expect(existsSync(historyPath)).toBe(true); @@ -566,11 +563,7 @@ describe("cmdRun happy-path pipeline", () => { }); await loadManifest(true); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected - validateScriptContent rejects scripts without shebang - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const clackErrors = mockLogError.mock.calls.map((c: unknown[]) => c.join(" ")); const errOutput = [ @@ -587,11 +580,7 @@ describe("cmdRun happy-path pipeline", () => { }); await loadManifest(true); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const clackErrors = mockLogError.mock.calls.map((c: unknown[]) => c.join(" ")); const errOutput = [ diff --git a/packages/cli/src/__tests__/commands-error-paths.test.ts b/packages/cli/src/__tests__/commands-error-paths.test.ts index 9c861757..acaed157 100644 --- a/packages/cli/src/__tests__/commands-error-paths.test.ts +++ b/packages/cli/src/__tests__/commands-error-paths.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; @@ -257,11 +258,7 @@ describe("Commands Error Paths", () => { // cmdRun should pass validation and attempt to download + run the script. // It will fail at validateScriptContent because "not a valid script" lacks shebang. - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected - either process.exit from validateScriptContent or Error thrown - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); // The log.step should have been called with the launch message // (meaning validation passed and it attempted to download) @@ -279,11 +276,7 @@ describe("Commands Error Paths", () => { await loadManifest(true); - try { - await cmdRun("claude", "sprite", "Fix all bugs"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("claude", "sprite", "Fix all bugs")); const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(true); @@ -317,11 +310,7 @@ describe("Commands Error Paths", () => { }); it("should only call process.exit once even with multiple errors", async () => { - try { - await cmdRun("badagent", "badcloud"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("badagent", "badcloud")); // process.exit should be called exactly once (not twice, once per error) expect(processExitSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/__tests__/commands-resolve-run.test.ts b/packages/cli/src/__tests__/commands-resolve-run.test.ts index 4f49f16f..0e4d5893 100644 --- a/packages/cli/src/__tests__/commands-resolve-run.test.ts +++ b/packages/cli/src/__tests__/commands-resolve-run.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; @@ -200,11 +201,7 @@ describe("cmdRun - display name resolution", () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("Claude Code", "sprite"); - } catch { - // May throw from script execution or process.exit - } + await asyncTryCatch(() => cmdRun("Claude Code", "sprite")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("Resolved") && msg.includes("claude"))).toBe(true); @@ -213,11 +210,7 @@ describe("cmdRun - display name resolution", () => { it("should resolve cloud display name and log resolution message", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "Hetzner Cloud"); - } catch { - // May throw from script execution or process.exit - } + await asyncTryCatch(() => cmdRun("claude", "Hetzner Cloud")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("Resolved") && msg.includes("hetzner"))).toBe(true); @@ -226,11 +219,7 @@ describe("cmdRun - display name resolution", () => { it("should resolve both agent and cloud display names simultaneously", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("Claude Code", "Hetzner Cloud"); - } catch { - // May throw from script execution or process.exit - } + await asyncTryCatch(() => cmdRun("Claude Code", "Hetzner Cloud")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); const resolvedAgent = infoCalls.some((msg: string) => msg.includes("Resolved") && msg.includes("claude")); @@ -242,11 +231,7 @@ describe("cmdRun - display name resolution", () => { it("should not log resolution when exact keys are used", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "sprite"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("Resolved"))).toBe(false); @@ -255,11 +240,7 @@ describe("cmdRun - display name resolution", () => { it("should resolve case-insensitive display name", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude code", "sprite"); - } catch { - // May throw - } + await asyncTryCatch(() => cmdRun("claude code", "sprite")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("Resolved") && msg.includes("claude"))).toBe(true); @@ -272,11 +253,7 @@ describe("cmdRun - display name resolution", () => { it("should show correct display names in launch message after resolution", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("Claude Code", "Hetzner Cloud"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("Claude Code", "Hetzner Cloud")); const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); expect(stepCalls.some((msg: string) => msg.includes("Claude Code") && msg.includes("Hetzner Cloud"))).toBe(true); @@ -285,11 +262,7 @@ describe("cmdRun - display name resolution", () => { it("should show 'with prompt' in launch message when prompt is provided", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "sprite", "Fix all bugs"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("claude", "sprite", "Fix all bugs")); const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(true); @@ -298,11 +271,7 @@ describe("cmdRun - display name resolution", () => { it("should not show 'with prompt' when no prompt given", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "sprite"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(false); @@ -323,11 +292,7 @@ describe("cmdRun - display name resolution", () => { }; await setManifestAndScript(partialManifest); - try { - await cmdRun("claude", "digitalocean"); - } catch { - // Expected: process.exit from validateImplementation - } + await asyncTryCatch(() => cmdRun("claude", "digitalocean")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); // Should show the "see all N options" message since claude has 4 implemented clouds @@ -345,11 +310,7 @@ describe("cmdRun - display name resolution", () => { // We need a missing combo: hetzner/codex is missing await setManifestAndScript(mockManifest); - try { - await cmdRun("codex", "hetzner"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("codex", "hetzner")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); // codex has 1 implemented cloud (sprite), so no "see all" hint @@ -363,11 +324,7 @@ describe("cmdRun - display name resolution", () => { it("should show 'no implemented cloud providers' and suggest 'spawn matrix'", async () => { await setManifestAndScript(noCloudManifest); - try { - await cmdRun("codex", "sprite"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("codex", "sprite")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("no implemented cloud providers"))).toBe(true); @@ -381,11 +338,7 @@ describe("cmdRun - display name resolution", () => { it("should not log resolution for completely unknown agent display name", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("Unknown Agent Name", "sprite"); - } catch { - // Expected: will fail at validateIdentifier (spaces) - } + await asyncTryCatch(() => cmdRun("Unknown Agent Name", "sprite")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("Resolved"))).toBe(false); @@ -394,11 +347,7 @@ describe("cmdRun - display name resolution", () => { it("should not log resolution for completely unknown cloud display name", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "Unknown Cloud"); - } catch { - // Expected - } + await asyncTryCatch(() => cmdRun("claude", "Unknown Cloud")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); // No cloud resolution message should appear diff --git a/packages/cli/src/__tests__/commands-swap-resolve.test.ts b/packages/cli/src/__tests__/commands-swap-resolve.test.ts index d6f493c0..39742776 100644 --- a/packages/cli/src/__tests__/commands-swap-resolve.test.ts +++ b/packages/cli/src/__tests__/commands-swap-resolve.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; @@ -79,12 +80,8 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should detect and fix swapped agent/cloud args", async () => { await setManifestAndScript(mockManifest); - try { - // "sprite" is a cloud, "claude" is an agent - they're swapped - await cmdRun("sprite", "claude"); - } catch { - // May throw from script execution - } + // "sprite" is a cloud, "claude" is an agent - they're swapped + await asyncTryCatch(() => cmdRun("sprite", "claude")); const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); expect(infoCalls.some((msg: string) => msg.includes("swapped"))).toBe(true); @@ -94,11 +91,7 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should proceed correctly after swapping args", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("sprite", "claude"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("sprite", "claude")); // After swap, should launch with correct names const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" ")); @@ -108,11 +101,7 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should not swap when args are in correct order", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("claude", "sprite"); - } catch { - // May throw from script execution - } + await asyncTryCatch(() => cmdRun("claude", "sprite")); const warnCalls = mockLogWarn.mock.calls.map((c: unknown[]) => c.join(" ")); expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false); @@ -121,12 +110,8 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should not swap when first arg is not a cloud key", async () => { await setManifestAndScript(mockManifest); - try { - // "unknown" is not a cloud, so no swap should occur - await cmdRun("unknown", "sprite"); - } catch { - // Expected: will fail validation - } + // "unknown" is not a cloud, so no swap should occur + await asyncTryCatch(() => cmdRun("unknown", "sprite")); const warnCalls = mockLogWarn.mock.calls.map((c: unknown[]) => c.join(" ")); expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false); @@ -135,12 +120,8 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should not swap when second arg is not an agent key", async () => { await setManifestAndScript(mockManifest); - try { - // "sprite" is a cloud but "unknown" is not an agent - await cmdRun("sprite", "unknown"); - } catch { - // Expected: will fail validation - } + // "sprite" is a cloud but "unknown" is not an agent + await asyncTryCatch(() => cmdRun("sprite", "unknown")); const warnCalls = mockLogWarn.mock.calls.map((c: unknown[]) => c.join(" ")); expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false); @@ -149,12 +130,8 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should not swap when both args are agents", async () => { await setManifestAndScript(mockManifest); - try { - // Both are agents, not a cloud+agent swap - await cmdRun("claude", "codex"); - } catch { - // Expected: will fail since codex is not a cloud - } + // Both are agents, not a cloud+agent swap + await asyncTryCatch(() => cmdRun("claude", "codex")); const warnCalls = mockLogWarn.mock.calls.map((c: unknown[]) => c.join(" ")); expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false); @@ -163,11 +140,7 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should not swap when both args are clouds", async () => { await setManifestAndScript(mockManifest); - try { - await cmdRun("sprite", "hetzner"); - } catch { - // Expected: sprite is not an agent - } + await asyncTryCatch(() => cmdRun("sprite", "hetzner")); // sprite IS a cloud and hetzner is NOT an agent, so the swap condition // (!manifest.agents[agent] && manifest.clouds[agent] && manifest.agents[cloud]) @@ -183,13 +156,9 @@ describe("detectAndFixSwappedArgs via cmdRun", () => { it("should swap args then fail at implementation check for missing combo", async () => { await setManifestAndScript(mockManifest); - try { - // hetzner is a cloud, codex is an agent - swapped - // After swap: cmdRun("codex", "hetzner") - but hetzner/codex is "missing" - await cmdRun("hetzner", "codex"); - } catch { - // Expected: process.exit from validateImplementation - } + // hetzner is a cloud, codex is an agent - swapped + // After swap: cmdRun("codex", "hetzner") - but hetzner/codex is "missing" + await asyncTryCatch(() => cmdRun("hetzner", "codex")); // Should detect the swap const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); @@ -245,12 +214,8 @@ describe("prompt handling with swapped args", () => { it("should swap args and show 'with prompt' when prompt provided", async () => { await setManifestAndScript(mockManifest); - try { - // Swapped: cloud first, agent second, with prompt - await cmdRun("sprite", "claude", "Fix all bugs"); - } catch { - // May throw from script execution - } + // Swapped: cloud first, agent second, with prompt + await asyncTryCatch(() => cmdRun("sprite", "claude", "Fix all bugs")); // Should detect swap const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" ")); @@ -264,12 +229,8 @@ describe("prompt handling with swapped args", () => { it("should validate prompt even when args are swapped", async () => { await setManifestAndScript(mockManifest); - try { - // Swapped args with dangerous prompt - await cmdRun("sprite", "claude", "$(rm -rf /)"); - } catch { - // Expected: prompt validation should reject this - } + // Swapped args with dangerous prompt + await asyncTryCatch(() => cmdRun("sprite", "claude", "$(rm -rf /)")); const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" ")); expect(errorCalls.some((msg: string) => msg.includes("shell syntax") || msg.includes("command substitution"))).toBe( diff --git a/packages/cli/src/__tests__/download-and-failure.test.ts b/packages/cli/src/__tests__/download-and-failure.test.ts index 46b72710..91771381 100644 --- a/packages/cli/src/__tests__/download-and-failure.test.ts +++ b/packages/cli/src/__tests__/download-and-failure.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { asyncTryCatch } from "@openrouter/spawn-shared"; import { loadManifest } from "../manifest"; import { isString } from "../shared/type-guards"; import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers"; @@ -68,10 +69,9 @@ describe("Download and Failure Pipeline", () => { }); }); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected: process.exit(1) from reportDownloadFailure + const r = await asyncTryCatch(() => cmdRun("claude", "sprite")); + if (!r.ok && !r.error.message.includes("process.exit")) { + throw r.error; } expect(processExitSpy).toHaveBeenCalledWith(1); @@ -90,10 +90,9 @@ describe("Download and Failure Pipeline", () => { }), ); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected + const r = await asyncTryCatch(() => cmdRun("claude", "sprite")); + if (!r.ok && !r.error.message.includes("process.exit")) { + throw r.error; } const errorOutput = consoleMocks.error.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n"); @@ -113,10 +112,9 @@ describe("Download and Failure Pipeline", () => { }); }); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected + const r = await asyncTryCatch(() => cmdRun("claude", "sprite")); + if (!r.ok && !r.error.message.includes("process.exit")) { + throw r.error; } // Should show HTTP error codes in console output (not the "script not found" path) @@ -135,10 +133,9 @@ describe("Download and Failure Pipeline", () => { throw new Error("DNS resolution failed"); }); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected + const r = await asyncTryCatch(() => cmdRun("claude", "sprite")); + if (!r.ok && !r.error.message.includes("process.exit")) { + throw r.error; } expect(processExitSpy).toHaveBeenCalledWith(1); @@ -152,10 +149,9 @@ describe("Download and Failure Pipeline", () => { throw new Error("Network timeout"); }); - try { - await cmdRun("claude", "sprite"); - } catch { - // Expected + const r = await asyncTryCatch(() => cmdRun("claude", "sprite")); + if (!r.ok && !r.error.message.includes("process.exit")) { + throw r.error; } const errorOutput = consoleMocks.error.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n"); diff --git a/packages/cli/src/__tests__/fs-sandbox.test.ts b/packages/cli/src/__tests__/fs-sandbox.test.ts index d1b4176c..35ac8680 100644 --- a/packages/cli/src/__tests__/fs-sandbox.test.ts +++ b/packages/cli/src/__tests__/fs-sandbox.test.ts @@ -11,13 +11,14 @@ import { describe, expect, it } from "bun:test"; import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; +import { tryCatch } from "@openrouter/spawn-shared"; // REAL_HOME is the actual home directory captured BEFORE preload runs. // We read it from /etc/passwd because process.env.HOME is already sandboxed. const REAL_HOME = (() => { - try { - // Bun's os.homedir() is patched by preload, and process.env.HOME is - // sandboxed. Read the real home from the password database instead. + // Bun's os.homedir() is patched by preload, and process.env.HOME is + // sandboxed. Read the real home from the password database instead. + const r = tryCatch(() => { const proc = Bun.spawnSync([ "sh", "-c", @@ -25,9 +26,8 @@ const REAL_HOME = (() => { ]); const home = new TextDecoder().decode(proc.stdout).trim(); return home || "/home/unknown"; - } catch { - return "/home/unknown"; - } + }); + return r.ok ? r.data : "/home/unknown"; })(); describe("Filesystem sandbox", () => { diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 6e34e88b..297262b8 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { asyncTryCatch, tryCatch } from "@openrouter/spawn-shared"; import { isNumber } from "../shared/type-guards.js"; // ── Mock oauth + tarball (needed to avoid interactive prompts / network) ── @@ -86,14 +87,13 @@ async function runOrchestrationSafe( agentName: string, opts: OrchestrationOptions = defaultOpts, ): Promise { - try { - await runOrchestration(cloud, agent, agentName, opts); - } catch (e) { + const r = await asyncTryCatch(async () => runOrchestration(cloud, agent, agentName, opts)); + if (!r.ok) { // process.exit mock throws to stop execution — that's expected - if (e instanceof Error && e.message.startsWith("__EXIT_")) { + if (r.error.message.startsWith("__EXIT_")) { return; } - throw e; + throw r.error; } } @@ -137,14 +137,12 @@ describe("runOrchestration", () => { } else { delete process.env.SPAWN_HOME; } - try { + tryCatch(() => rmSync(testDir, { recursive: true, force: true, - }); - } catch { - // best-effort cleanup - } + }), + ); }); it("calls all cloud lifecycle methods in correct order", async () => { diff --git a/packages/cli/src/__tests__/preload.ts b/packages/cli/src/__tests__/preload.ts index e5ad9b43..7251c247 100644 --- a/packages/cli/src/__tests__/preload.ts +++ b/packages/cli/src/__tests__/preload.ts @@ -26,6 +26,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import os, { tmpdir } from "node:os"; import { join } from "node:path"; +import { tryCatch } from "@openrouter/spawn-shared"; // ── Stray test file cleanup ────────────────────────────────────────────────── // @@ -42,7 +43,7 @@ function cleanupStrayTestFiles(): void { if (!REAL_HOME) { return; } - try { + tryCatch(() => { for (const f of readdirSync(REAL_HOME)) { if (f.startsWith("subprocess-test-") && f.endsWith(".txt")) { rmSync(join(REAL_HOME, f), { @@ -50,9 +51,7 @@ function cleanupStrayTestFiles(): void { }); } } - } catch { - // Best-effort - } + }); } cleanupStrayTestFiles(); @@ -110,13 +109,11 @@ mkdirSync(join(TEST_HOME, ".local", "share"), { // ── Cleanup on exit ───────────────────────────────────────────────────────── process.on("exit", () => { - try { + tryCatch(() => rmSync(TEST_HOME, { recursive: true, force: true, - }); - } catch { - // Best-effort cleanup - } + }), + ); cleanupStrayTestFiles(); }); diff --git a/packages/cli/src/__tests__/prompt-file-security.test.ts b/packages/cli/src/__tests__/prompt-file-security.test.ts index aebb40d5..2c1afe4a 100644 --- a/packages/cli/src/__tests__/prompt-file-security.test.ts +++ b/packages/cli/src/__tests__/prompt-file-security.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { tryCatch } from "@openrouter/spawn-shared"; import { validatePromptFilePath, validatePromptFileStats } from "../security.js"; describe("validatePromptFilePath", () => { @@ -81,16 +82,12 @@ describe("validatePromptFilePath", () => { }); it("should include helpful error message about exfiltration risk", () => { - let caught: unknown; - try { - validatePromptFilePath("/home/user/.ssh/id_rsa"); - } catch (e) { - caught = e; + const r = tryCatch(() => validatePromptFilePath("/home/user/.ssh/id_rsa")); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.message).toContain("sent to the agent"); + expect(r.error.message).toContain("plain text file"); } - expect(caught).toBeInstanceOf(Error); - const err = caught instanceof Error ? caught : null; - expect(err?.message).toContain("sent to the agent"); - expect(err?.message).toContain("plain text file"); }); it("should reject SSH key files by filename pattern anywhere in path", () => { @@ -147,15 +144,11 @@ describe("validatePromptFileStats", () => { isFile: () => true, size: 5 * 1024 * 1024, }; - let caught: unknown; - try { - validatePromptFileStats("large.bin", stats); - } catch (e) { - caught = e; + const r = tryCatch(() => validatePromptFileStats("large.bin", stats)); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.message).toContain("5.0MB"); + expect(r.error.message).toContain("maximum is 1MB"); } - expect(caught).toBeInstanceOf(Error); - const err = caught instanceof Error ? caught : null; - expect(err?.message).toContain("5.0MB"); - expect(err?.message).toContain("maximum is 1MB"); }); }); diff --git a/packages/cli/src/__tests__/script-failure-guidance.test.ts b/packages/cli/src/__tests__/script-failure-guidance.test.ts index 45117829..34180ef9 100644 --- a/packages/cli/src/__tests__/script-failure-guidance.test.ts +++ b/packages/cli/src/__tests__/script-failure-guidance.test.ts @@ -190,20 +190,17 @@ describe("getScriptFailureGuidance", () => { const savedHC = process.env.HCLOUD_TOKEN; delete process.env.OPENROUTER_API_KEY; delete process.env.HCLOUD_TOKEN; - try { - const lines = getScriptFailureGuidance(1, "hetzner", "HCLOUD_TOKEN"); - const joined = lines.join("\n"); - expect(joined).toContain("HCLOUD_TOKEN"); - expect(joined).toContain("OPENROUTER_API_KEY"); - expect(joined).toContain("spawn hetzner"); - expect(joined).toContain("setup"); - } finally { - if (savedOR !== undefined) { - process.env.OPENROUTER_API_KEY = savedOR; - } - if (savedHC !== undefined) { - process.env.HCLOUD_TOKEN = savedHC; - } + const lines = getScriptFailureGuidance(1, "hetzner", "HCLOUD_TOKEN"); + const joined = lines.join("\n"); + expect(joined).toContain("HCLOUD_TOKEN"); + expect(joined).toContain("OPENROUTER_API_KEY"); + expect(joined).toContain("spawn hetzner"); + expect(joined).toContain("setup"); + if (savedOR !== undefined) { + process.env.OPENROUTER_API_KEY = savedOR; + } + if (savedHC !== undefined) { + process.env.HCLOUD_TOKEN = savedHC; } }); @@ -219,20 +216,17 @@ describe("getScriptFailureGuidance", () => { const savedDO = process.env.DO_API_TOKEN; delete process.env.OPENROUTER_API_KEY; delete process.env.DO_API_TOKEN; - try { - const lines = getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN"); - const joined = lines.join("\n"); - expect(joined).toContain("DO_API_TOKEN"); - expect(joined).toContain("OPENROUTER_API_KEY"); - expect(joined).toContain("spawn digitalocean"); - expect(joined).toContain("setup"); - } finally { - if (savedOR !== undefined) { - process.env.OPENROUTER_API_KEY = savedOR; - } - if (savedDO !== undefined) { - process.env.DO_API_TOKEN = savedDO; - } + const lines = getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN"); + const joined = lines.join("\n"); + expect(joined).toContain("DO_API_TOKEN"); + expect(joined).toContain("OPENROUTER_API_KEY"); + expect(joined).toContain("spawn digitalocean"); + expect(joined).toContain("setup"); + if (savedOR !== undefined) { + process.env.OPENROUTER_API_KEY = savedOR; + } + if (savedDO !== undefined) { + process.env.DO_API_TOKEN = savedDO; } }); diff --git a/packages/cli/src/__tests__/security.test.ts b/packages/cli/src/__tests__/security.test.ts index 73bf5c0c..6203d9d1 100644 --- a/packages/cli/src/__tests__/security.test.ts +++ b/packages/cli/src/__tests__/security.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { tryCatch } from "@openrouter/spawn-shared"; import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js"; /** @@ -430,16 +431,12 @@ describe("validatePrompt", () => { }); it("should provide helpful error message for command substitution", () => { - let caught: unknown; - try { - validatePrompt("Run $(echo test)"); - } catch (e) { - caught = e; + const r = tryCatch(() => validatePrompt("Run $(echo test)")); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.message).toContain("shell syntax"); + expect(r.error.message).toContain("plain English"); } - expect(caught).toBeInstanceOf(Error); - const err = caught instanceof Error ? caught : null; - expect(err?.message).toContain("shell syntax"); - expect(err?.message).toContain("plain English"); }); it("should detect multiple dangerous patterns", () => { diff --git a/packages/cli/src/__tests__/ssh-keys.test.ts b/packages/cli/src/__tests__/ssh-keys.test.ts index 8947c0d8..010bf8ac 100644 --- a/packages/cli/src/__tests__/ssh-keys.test.ts +++ b/packages/cli/src/__tests__/ssh-keys.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { tryCatch } from "@openrouter/spawn-shared"; import { mockClackPrompts } from "./test-helpers"; mockClackPrompts({ @@ -37,14 +38,12 @@ function setupTmpHome() { function cleanupTmpHome() { process.env.HOME = origHome; - try { + tryCatch(() => rmSync(tmpDir, { recursive: true, force: true, - }); - } catch { - // ignore - } + }), + ); } /** diff --git a/packages/cli/src/__tests__/update-check.test.ts b/packages/cli/src/__tests__/update-check.test.ts index ae4687ab..6e3e4aaa 100644 --- a/packages/cli/src/__tests__/update-check.test.ts +++ b/packages/cli/src/__tests__/update-check.test.ts @@ -1,16 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import fs from "node:fs"; import path from "node:path"; +import { tryCatch } from "@openrouter/spawn-shared"; // ── Test Helpers ─────────────────────────────────────────────────────────────── /** Remove the .update-failed backoff file so it doesn't interfere with tests */ function clearUpdateBackoff() { - try { - fs.unlinkSync(path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed")); - } catch { - // File may not exist - } + tryCatch(() => fs.unlinkSync(path.join(process.env.HOME || "/tmp", ".config", "spawn", ".update-failed"))); } function mockEnv() { diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 80922334..55b603c4 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -915,10 +915,9 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis userData: userdata, }; - try { - await lightsailCreateInstances(createParams); - } catch (err) { - const errMsg = getErrorMessage(err); + const createResult = await asyncTryCatch(() => lightsailCreateInstances(createParams)); + if (!createResult.ok) { + const errMsg = getErrorMessage(createResult.error); logError(`Failed to create Lightsail instance: ${errMsg}`); if (isBillingError("aws", errMsg)) { @@ -939,7 +938,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis `Instance name '${name}' already in use`, ]); } - throw err; + throw createResult.error; } _state.instanceName = name; @@ -1000,7 +999,7 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { logStep("Waiting for cloud-init to complete..."); for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { + const pollResult = await asyncTryCatch(async () => { const proc = Bun.spawn( [ "ssh", @@ -1022,24 +1021,27 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { // can continue and the user isn't left with a hung CLI. const timer = setTimeout(() => killWithTimeout(proc), 30_000); // Drain both pipes before awaiting exit to prevent pipe buffer deadlock - let stdout: string; - let exitCode: number; - try { - [stdout] = await Promise.all([ + const pipeResult = await asyncTryCatch(async () => { + const [stdout] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); - exitCode = await proc.exited; - } finally { - clearTimeout(timer); + const exitCode = await proc.exited; + return { + stdout, + exitCode, + }; + }); + clearTimeout(timer); + if (!pipeResult.ok) { + throw pipeResult.error; } - if (exitCode === 0 && stdout.includes("done")) { - logStepDone(); - logInfo("Cloud-init complete"); - return; - } - } catch { - // ignore + return pipeResult.data; + }); + if (pollResult.ok && pollResult.data.exitCode === 0 && pollResult.data.stdout.includes("done")) { + logStepDone(); + logInfo("Cloud-init complete"); + return; } logStepInline(`Cloud-init still running (${attempt}/${maxAttempts})`); await sleep(5000); @@ -1070,13 +1072,13 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise killWithTimeout(proc), timeout); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); - } - } finally { - clearTimeout(timer); + const runResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!runResult.ok) { + throw runResult.error; + } + if (runResult.data !== 0) { + throw new Error(`run_server failed (exit ${runResult.data}): ${cmd}`); } } @@ -1106,12 +1108,13 @@ export async function uploadFile(localPath: string, remotePath: string): Promise }, ); const timer = setTimeout(() => killWithTimeout(proc), 120_000); - try { - if ((await proc.exited) !== 0) { - throw new Error(`upload_file failed for ${remotePath}`); - } - } finally { - clearTimeout(timer); + const uploadResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!uploadResult.ok) { + throw uploadResult.error; + } + if (uploadResult.data !== 0) { + throw new Error(`upload_file failed for ${remotePath}`); } } diff --git a/packages/cli/src/commands/connect.ts b/packages/cli/src/commands/connect.ts index 424b0048..0a553798 100644 --- a/packages/cli/src/commands/connect.ts +++ b/packages/cli/src/commands/connect.ts @@ -11,6 +11,7 @@ import { validateUsername, } from "../security.js"; import { getHistoryPath } from "../shared/paths.js"; +import { tryCatch } from "../shared/result.js"; import { SSH_INTERACTIVE_OPTS, spawnInteractive } from "../shared/ssh.js"; import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; import { getErrorMessage } from "./shared.js"; @@ -22,17 +23,18 @@ async function runInteractiveCommand( failureMsg: string, manualCmd: string, ): Promise { - let code: number; - try { - code = spawnInteractive([ + const r = tryCatch(() => + spawnInteractive([ cmd, ...args, - ]); - } catch (err) { - p.log.error(`Failed to connect: ${getErrorMessage(err)}`); + ]), + ); + if (!r.ok) { + p.log.error(`Failed to connect: ${getErrorMessage(r.error)}`); p.log.info(`Try manually: ${pc.cyan(manualCmd)}`); - throw err; + throw r.error; } + const code = r.data; if (code !== 0) { throw new Error(`${failureMsg} with exit code ${code}`); } @@ -42,7 +44,7 @@ async function runInteractiveCommand( export async function cmdConnect(connection: VMConnection): Promise { // SECURITY: Validate all connection parameters before use // This prevents command injection if the history file is corrupted or tampered with - try { + const connectValidation = tryCatch(() => { validateConnectionIP(connection.ip); validateUsername(connection.user); if (connection.server_name) { @@ -51,8 +53,9 @@ export async function cmdConnect(connection: VMConnection): Promise { if (connection.server_id) { validateServerIdentifier(connection.server_id); } - } catch (err) { - p.log.error(`Security validation failed: ${getErrorMessage(err)}`); + }); + if (!connectValidation.ok) { + p.log.error(`Security validation failed: ${getErrorMessage(connectValidation.error)}`); p.log.info("Your spawn history file may be corrupted or tampered with."); p.log.info(`Location: ${getHistoryPath()}`); p.log.info("To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'"); @@ -98,7 +101,7 @@ export async function cmdEnterAgent( manifest: Manifest | null, ): Promise { // SECURITY: Validate all connection parameters before use - try { + const enterValidation = tryCatch(() => { validateConnectionIP(connection.ip); validateUsername(connection.user); if (connection.server_name) { @@ -110,8 +113,9 @@ export async function cmdEnterAgent( if (connection.launch_cmd) { validateLaunchCmd(connection.launch_cmd); } - } catch (err) { - p.log.error(`Security validation failed: ${getErrorMessage(err)}`); + }); + if (!enterValidation.ok) { + p.log.error(`Security validation failed: ${getErrorMessage(enterValidation.error)}`); p.log.info("Your spawn history file may be corrupted or tampered with."); p.log.info(`Location: ${getHistoryPath()}`); p.log.info("To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'"); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 7cb08abc..e6bd4f89 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -16,7 +16,7 @@ import { getActiveServers, markRecordDeleted } from "../history.js"; import { loadManifest } from "../manifest.js"; import { validateMetadataValue, validateServerIdentifier } from "../security.js"; import { getHistoryPath } from "../shared/paths.js"; -import { asyncTryCatch, asyncTryCatchIf, isNetworkError } from "../shared/result.js"; +import { asyncTryCatch, asyncTryCatchIf, isNetworkError, tryCatch } from "../shared/result.js"; import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js"; import { activeServerPicker, resolveListFilters } from "./list.js"; import { getErrorMessage, isInteractiveTTY } from "./shared.js"; @@ -78,11 +78,10 @@ async function execDeleteServer(record: SpawnRecord): Promise { // SECURITY: Validate server ID to prevent command injection // This protects against corrupted or tampered history files - try { - validateServerIdentifier(id); - } catch (err) { + const idValidation = tryCatch(() => validateServerIdentifier(id)); + if (!idValidation.ok) { throw new Error( - `Invalid server identifier in history: ${getErrorMessage(err)}\n\n` + + `Invalid server identifier in history: ${getErrorMessage(idValidation.error)}\n\n` + "Your spawn history file may be corrupted or tampered with.\n" + `Location: ${getHistoryPath()}\n` + "To fix: edit the file and remove the invalid entry, or run 'spawn list --clear'", @@ -140,14 +139,14 @@ async function execDeleteServer(record: SpawnRecord): Promise { // Deletion runs under a spinner — suppress interactive prompts const prevNonInteractive = process.env.SPAWN_NON_INTERACTIVE; process.env.SPAWN_NON_INTERACTIVE = "1"; - try { - await gcpResolveProject(); - } finally { - if (prevNonInteractive === undefined) { - delete process.env.SPAWN_NON_INTERACTIVE; - } else { - process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive; - } + const resolveResult = await asyncTryCatch(() => gcpResolveProject()); + if (prevNonInteractive === undefined) { + delete process.env.SPAWN_NON_INTERACTIVE; + } else { + process.env.SPAWN_NON_INTERACTIVE = prevNonInteractive; + } + if (!resolveResult.ok) { + throw resolveResult.error; } await gcpDestroyInstance(id); }); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 4906c9fe..b44a6c83 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -314,9 +314,10 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife } if (action === "enter") { - const r = await asyncTryCatch(() => cmdEnterAgent(selected.connection, selected.agent, manifest)); - if (!r.ok) { - p.log.error(`Connection failed: ${getErrorMessage(r.error)}`); + const enterResult = await asyncTryCatch(() => cmdEnterAgent(selected.connection, selected.agent, manifest)); + if (!enterResult.ok) { + p.log.error(`Connection failed: ${getErrorMessage(enterResult.error)}`); + p.log.info( `VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`, ); @@ -325,9 +326,10 @@ export async function handleRecordAction(selected: SpawnRecord, manifest: Manife } if (action === "reconnect") { - const r = await asyncTryCatch(() => cmdConnect(selected.connection)); - if (!r.ok) { - p.log.error(`Connection failed: ${getErrorMessage(r.error)}`); + const reconnectResult = await asyncTryCatch(() => cmdConnect(selected.connection)); + if (!reconnectResult.ok) { + p.log.error(`Connection failed: ${getErrorMessage(reconnectResult.error)}`); + p.log.info( `VM may no longer be running. Use ${pc.cyan(`spawn ${selected.agent} ${selected.cloud}`)} to start a new one.`, ); diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index a3c1c05e..38d9e881 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -9,7 +9,7 @@ import { buildDashboardHint, EXIT_CODE_GUIDANCE, SIGNAL_GUIDANCE } from "../guid import { generateSpawnId, getActiveServers, saveSpawnRecord } from "../history.js"; import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js"; import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js"; -import { asyncTryCatch, isFileError, tryCatchIf } from "../shared/result.js"; +import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js"; import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js"; import { promptSetupOptions, promptSpawnName } from "./interactive.js"; import { handleRecordAction } from "./list.js"; @@ -563,23 +563,23 @@ function runBashScript( debug?: boolean, spawnName?: string, ): string | undefined { - try { - runBash(script, prompt, debug, spawnName); - return undefined; // success - } catch (err) { - const errMsg = getErrorMessage(err); - handleUserInterrupt(errMsg, dashboardUrl); - - // SSH disconnect after the server was already created — don't retry - if (isRetryableExitCode(errMsg)) { - console.error(); - p.log.warn("SSH connection lost. Your server is likely still running."); - p.log.warn("To reconnect, re-run the same spawn command."); - return undefined; // Don't report as failure — user already has clear guidance - } - - return errMsg; + const r = tryCatch(() => runBash(script, prompt, debug, spawnName)); + if (r.ok) { + return undefined; } + + const errMsg = getErrorMessage(r.error); + handleUserInterrupt(errMsg, dashboardUrl); + + // SSH disconnect after the server was already created — don't retry + if (isRetryableExitCode(errMsg)) { + console.error(); + p.log.warn("SSH connection lost. Your server is likely still running."); + p.log.warn("To reconnect, re-run the same spawn command."); + return undefined; // Don't report as failure — user already has clear guidance + } + + return errMsg; } export async function execScript( @@ -756,23 +756,23 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles const { prompt, debug, outputFormat, spawnName } = opts; // Phase 1: Validate inputs (exit code 3) - try { + const validationResult = tryCatch(() => { validateIdentifier(agent, "Agent name"); validateIdentifier(cloud, "Cloud name"); if (prompt) { validatePrompt(prompt); } - } catch (err) { - headlessError(agent, cloud, "VALIDATION_ERROR", getErrorMessage(err), outputFormat, 3); + }); + if (!validationResult.ok) { + headlessError(agent, cloud, "VALIDATION_ERROR", getErrorMessage(validationResult.error), outputFormat, 3); } // Load manifest (silently - no spinner in headless mode) - let manifest: Manifest; - try { - manifest = await loadManifest(); - } catch (err) { - headlessError(agent, cloud, "MANIFEST_ERROR", getErrorMessage(err), outputFormat, 3); + const manifestResult = await asyncTryCatch(loadManifest); + if (!manifestResult.ok) { + headlessError(agent, cloud, "MANIFEST_ERROR", getErrorMessage(manifestResult.error), outputFormat, 3); } + const manifest = manifestResult.data; // Resolve agent/cloud names const resolvedAgent = resolveAgentKey(manifest, agent) ?? agent; @@ -850,38 +850,39 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles const url = `https://openrouter.ai/labs/spawn/${resolvedCloud}/${resolvedAgent}.sh`; const ghUrl = `${RAW_BASE}/sh/${resolvedCloud}/${resolvedAgent}.sh`; - try { + const fetchResult = await asyncTryCatch(async () => { const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); if (res.ok) { - scriptContent = await res.text(); - } else { - const ghRes = await fetch(ghUrl, { - signal: AbortSignal.timeout(FETCH_TIMEOUT), - }); - if (!ghRes.ok) { - headlessError( - resolvedAgent, - resolvedCloud, - "DOWNLOAD_ERROR", - `Script not found (HTTP ${res.status} primary, ${ghRes.status} fallback)`, - outputFormat, - 2, - ); - } - scriptContent = await ghRes.text(); + return res.text(); } - } catch (err) { + const ghRes = await fetch(ghUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + if (!ghRes.ok) { + headlessError( + resolvedAgent, + resolvedCloud, + "DOWNLOAD_ERROR", + `Script not found (HTTP ${res.status} primary, ${ghRes.status} fallback)`, + outputFormat, + 2, + ); + } + return ghRes.text(); + }); + if (!fetchResult.ok) { headlessError( resolvedAgent, resolvedCloud, "DOWNLOAD_ERROR", - `Failed to download script: ${getErrorMessage(err)}`, + `Failed to download script: ${getErrorMessage(fetchResult.error)}`, outputFormat, 2, ); } + scriptContent = fetchResult.data; } // Phase 3: Execute script (exit code 1) diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 9a23b7ae..69e081b6 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -9,7 +9,7 @@ import { agentKeys, cloudKeys, isStaleCache, loadManifest, matrixStatus } from " import { validateIdentifier, validatePrompt } from "../security.js"; import { PkgVersionSchema } from "../shared/parse.js"; import { getSpawnCloudConfigPath } from "../shared/paths.js"; -import { isFileError, tryCatchIf, unwrapOr } from "../shared/result.js"; +import { asyncTryCatch, isFileError, tryCatch, tryCatchIf, unwrapOr } from "../shared/result.js"; import { getErrorMessage, isString } from "../shared/type-guards.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -32,14 +32,12 @@ export function handleCancel(): never { async function withSpinner(msg: string, fn: () => Promise, doneMsg?: string): Promise { const s = p.spinner(); s.start(msg); - try { - const result = await fn(); - s.stop(doneMsg ?? msg.replace(/\.{3}$/, "")); - return result; - } catch (err) { - s.stop(pc.red("Failed")); - throw err; + const r = await asyncTryCatch(fn); + s.stop(r.ok ? (doneMsg ?? msg.replace(/\.{3}$/, "")) : pc.red("Failed")); + if (!r.ok) { + throw r.error; } + return r.data; } export async function loadManifestWithSpinner(): Promise { @@ -321,10 +319,9 @@ export async function validateAndGetEntity( > { const def = ENTITY_DEFS[kind]; const capitalLabel = def.label.charAt(0).toUpperCase() + def.label.slice(1); - try { - validateIdentifier(value, `${capitalLabel} name`); - } catch (err) { - p.log.error(getErrorMessage(err)); + const r = tryCatch(() => validateIdentifier(value, `${capitalLabel} name`)); + if (!r.ok) { + p.log.error(getErrorMessage(r.error)); process.exit(1); } @@ -623,14 +620,15 @@ export function isInteractiveTTY(): boolean { /** Validate inputs for injection attacks (SECURITY) and check they're non-empty */ export function validateRunSecurity(agent: string, cloud: string, prompt?: string): void { - try { + const r = tryCatch(() => { validateIdentifier(agent, "Agent name"); validateIdentifier(cloud, "Cloud name"); if (prompt) { validatePrompt(prompt); } - } catch (err) { - p.log.error(getErrorMessage(err)); + }); + if (!r.ok) { + p.log.error(getErrorMessage(r.error)); process.exit(1); } diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 9be8482b..890afc53 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -265,7 +265,7 @@ export async function checkAccountStatus(): Promise { if (!_state.token) { return; } - try { + const r = await asyncTryCatch(async () => { const text = await doApi("GET", "/account", undefined, 1); const data = parseJsonObj(text); const rec = toRecord(data?.account); @@ -297,10 +297,11 @@ export async function checkAccountStatus(): Promise { if (emailVerified === false) { logWarn("Your DigitalOcean email is not verified. Verify it to avoid account restrictions."); } - } catch (err) { + }); + if (!r.ok) { // Only re-throw if it's our explicit lock error - if (err instanceof Error && err.message === "DigitalOcean account is locked") { - throw err; + if (r.error instanceof Error && r.error.message === "DigitalOcean account is locked") { + throw r.error; } // Otherwise non-fatal — let createServer be the final check } @@ -688,7 +689,7 @@ export async function ensureSshKey(): Promise { name: `spawn-${key.name}`, public_key: pubKey, }); - const regResult = await asyncTryCatch(async () => doApi("POST", "/account/keys", body)); + const regResult = await asyncTryCatch(() => doApi("POST", "/account/keys", body)); if (!regResult.ok) { const msg = getErrorMessage(regResult.error); // Key may already exist under a different name — non-fatal @@ -1089,13 +1090,12 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise killWithTimeout(proc), 330_000); - let exitCode: number; - try { - exitCode = await proc.exited; - } finally { - clearTimeout(streamTimer); + const exitResult = await asyncTryCatch(() => proc.exited); + clearTimeout(streamTimer); + if (!exitResult.ok) { + throw exitResult.error; } - return exitCode; + return exitResult.data; }); if (streamResult.ok) { if (streamResult.data === 0) { @@ -1131,21 +1131,22 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise killWithTimeout(proc), 30_000); // Drain both pipes before awaiting exit to prevent pipe buffer deadlock - let stdout: string; - let pollExitCode: number; - try { - [stdout] = await Promise.all([ + const pipeResult = await asyncTryCatch(async () => { + const [stdout] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); - pollExitCode = await proc.exited; - } finally { - clearTimeout(timer); + const pollExitCode = await proc.exited; + return { + stdout, + pollExitCode, + }; + }); + clearTimeout(timer); + if (!pipeResult.ok) { + throw pipeResult.error; } - return { - stdout, - pollExitCode, - }; + return pipeResult.data; }); if (pollResult.ok && pollResult.data.pollExitCode === 0 && pollResult.data.stdout.includes("done")) { logStepDone(); @@ -1183,13 +1184,13 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): const timeout = (timeoutSecs || 300) * 1000; const timer = setTimeout(() => killWithTimeout(proc), timeout); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); - } - } finally { - clearTimeout(timer); + const runResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!runResult.ok) { + throw runResult.error; + } + if (runResult.data !== 0) { + throw new Error(`run_server failed (exit ${runResult.data}): ${cmd}`); } } @@ -1222,13 +1223,13 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str }, ); const timer = setTimeout(() => killWithTimeout(proc), 120_000); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`upload_file failed for ${remotePath}`); - } - } finally { - clearTimeout(timer); + const uploadResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!uploadResult.ok) { + throw uploadResult.error; + } + if (uploadResult.data !== 0) { + throw new Error(`upload_file failed for ${remotePath}`); } } diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 0a2f34c4..644e5c3f 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -535,7 +535,7 @@ export async function checkBillingEnabled(): Promise { if (!_state.project) { return; } - try { + const billingResult = await asyncTryCatch(async () => { const result = gcloudSync([ "billing", "projects", @@ -562,10 +562,11 @@ export async function checkBillingEnabled(): Promise { logWarn("Billing is still not enabled. Continuing anyway — instance creation may fail."); } } - } catch (err) { + }); + if (!billingResult.ok) { // Re-throw our explicit billing error - if (err instanceof Error && err.message === "GCP billing not enabled") { - throw err; + if (billingResult.error instanceof Error && billingResult.error.message === "GCP billing not enabled") { + throw billingResult.error; } // Permission errors or missing billing API — non-fatal, continue } @@ -737,9 +738,9 @@ export async function createInstance( "--quiet", ]; - // Wrap all gcloud calls in try/finally so the temp file is cleaned up + // Wrap all gcloud calls so the temp file is cleaned up // even when billing retry re-uses it (the args array references tmpFile). - try { + const createResult = await asyncTryCatch(async () => { let result = await gcloud(args); // Auto-reauth on expired tokens @@ -795,15 +796,17 @@ export async function createInstance( throw new Error("Instance creation failed"); } } - } finally { - // Clean up temp file after all retry paths have completed - tryCatch(() => - Bun.spawnSync([ - "rm", - "-f", - tmpFile, - ]), - ); + }); + // Clean up temp file after all retry paths have completed + tryCatch(() => + Bun.spawnSync([ + "rm", + "-f", + tmpFile, + ]), + ); + if (!createResult.ok) { + throw createResult.error; } // Get external IP @@ -878,17 +881,18 @@ export async function waitForCloudInit(maxAttempts = 60): Promise { // can continue and the user isn't left with a hung CLI. const timer = setTimeout(() => killWithTimeout(proc), 30_000); // Drain both pipes before awaiting exit to prevent pipe buffer deadlock - let exitCode: number; - try { + const pipeResult = await asyncTryCatch(async () => { await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); - exitCode = await proc.exited; - } finally { - clearTimeout(timer); + return await proc.exited; + }); + clearTimeout(timer); + if (!pipeResult.ok) { + throw pipeResult.error; } - return exitCode; + return pipeResult.data; }); if (pollResult.ok && pollResult.data === 0) { logStepDone(); @@ -926,13 +930,13 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise killWithTimeout(proc), timeout); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); - } - } finally { - clearTimeout(timer); + const runResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!runResult.ok) { + throw runResult.error; + } + if (runResult.data !== 0) { + throw new Error(`run_server failed (exit ${runResult.data}): ${cmd}`); } } @@ -968,13 +972,13 @@ export async function uploadFile(localPath: string, remotePath: string): Promise }, ); const timer = setTimeout(() => killWithTimeout(proc), 120_000); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`upload_file failed for ${remotePath}`); - } - } finally { - clearTimeout(timer); + const uploadResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!uploadResult.ok) { + throw uploadResult.error; + } + if (uploadResult.data !== 0) { + throw new Error(`upload_file failed for ${remotePath}`); } } diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 9c43a353..d42f7f4c 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -543,21 +543,22 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise killWithTimeout(proc), 30_000); // Drain both pipes before awaiting exit to prevent pipe buffer deadlock - let stdout: string; - let exitCode: number; - try { - [stdout] = await Promise.all([ + const pipeResult = await asyncTryCatch(async () => { + const [stdout] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); - exitCode = await proc.exited; - } finally { - clearTimeout(timer); + const exitCode = await proc.exited; + return { + stdout, + exitCode, + }; + }); + clearTimeout(timer); + if (!pipeResult.ok) { + throw pipeResult.error; } - return { - stdout, - exitCode, - }; + return pipeResult.data; }); if (pollResult.ok && pollResult.data.exitCode === 0 && pollResult.data.stdout.includes("done")) { logStepDone(); @@ -598,13 +599,13 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): const timeout = (timeoutSecs || 300) * 1000; const timer = setTimeout(() => killWithTimeout(proc), timeout); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); - } - } finally { - clearTimeout(timer); + const runResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!runResult.ok) { + throw runResult.error; + } + if (runResult.data !== 0) { + throw new Error(`run_server failed (exit ${runResult.data}): ${cmd}`); } } @@ -638,13 +639,13 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str }, ); const timer = setTimeout(() => killWithTimeout(proc), 120_000); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`upload_file failed for ${remotePath}`); - } - } finally { - clearTimeout(timer); + const uploadResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!uploadResult.ok) { + throw uploadResult.error; + } + if (uploadResult.data !== 0) { + throw new Error(`upload_file failed for ${remotePath}`); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b455bcda..b2040773 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,7 +28,7 @@ import { } from "./commands/index.js"; import { expandEqualsFlags, findUnknownFlag } from "./flags.js"; import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js"; -import { asyncTryCatchIf, isFileError, isNetworkError, tryCatchIf } from "./shared/result.js"; +import { asyncTryCatch, asyncTryCatchIf, isFileError, isNetworkError, tryCatch, tryCatchIf } from "./shared/result.js"; import { getErrorMessage } from "./shared/type-guards.js"; import { checkForUpdates } from "./update-check.js"; @@ -328,17 +328,18 @@ async function readPromptFile(promptFile: string): Promise { const { validatePromptFilePath, validatePromptFileStats } = await import("./security.js"); const { readFileSync, statSync } = await import("node:fs"); - try { - validatePromptFilePath(promptFile); - } catch (err) { - console.error(pc.red(getErrorMessage(err))); + const validateResult = tryCatch(() => validatePromptFilePath(promptFile)); + if (!validateResult.ok) { + console.error(pc.red(getErrorMessage(validateResult.error))); process.exit(1); } - try { + const statsResult = tryCatch(() => { const stats = statSync(promptFile); validatePromptFileStats(promptFile, stats); - } catch (err) { + }); + if (!statsResult.ok) { + const err = statsResult.error; const code = err && typeof err === "object" && "code" in err ? err.code : ""; if (code === "ENOENT" || code === "EACCES" || code === "EISDIR") { handlePromptFileError(promptFile, err); @@ -729,10 +730,9 @@ async function main(): Promise { // Must be handled before expandEqualsFlags / resolvePrompt so that pick's // own --prompt flag is not mistakenly consumed by the top-level prompt logic. if (rawArgs[0] === "pick") { - try { - await cmdPick(expandEqualsFlags(rawArgs.slice(1))); - } catch (err) { - handleError(err); + const pickResult = await asyncTryCatch(() => cmdPick(expandEqualsFlags(rawArgs.slice(1)))); + if (!pickResult.ok) { + handleError(pickResult.error); } return; } @@ -908,7 +908,7 @@ async function main(): Promise { const cmd = filteredArgs[0]; - try { + const cmdResult = await asyncTryCatch(async () => { if (!cmd) { if (effectiveHeadless) { if (outputFormat === "json") { @@ -929,18 +929,19 @@ async function main(): Promise { } else { await dispatchCommand(cmd, filteredArgs, prompt, dryRun, debug, effectiveHeadless, outputFormat); } - } catch (err) { + }); + if (!cmdResult.ok) { if (effectiveHeadless && outputFormat === "json") { console.log( JSON.stringify({ status: "error", error_code: "UNEXPECTED_ERROR", - error_message: getErrorMessage(err), + error_message: getErrorMessage(cmdResult.error), }), ); process.exit(1); } - handleError(err); + handleError(cmdResult.error); } } diff --git a/packages/cli/src/picker.ts b/packages/cli/src/picker.ts index fd483c6f..4bc62442 100644 --- a/packages/cli/src/picker.ts +++ b/packages/cli/src/picker.ts @@ -225,7 +225,7 @@ function withTTYKeyLoop(callbacks: KeyLoopCallbacks): T { let finalResult: T | undefined; let cancelled = false; - try { + const loopResult = tryCatch(() => { while (true) { const readResult = tryCatch(() => fs.readSync(ttyFd, buf, 0, 8, null)); if (!readResult.ok) { @@ -250,8 +250,10 @@ function withTTYKeyLoop(callbacks: KeyLoopCallbacks): T { break; } } - } finally { - restore(); + }); + restore(); + if (!loopResult.ok) { + throw loopResult.error; } if (finalResult !== undefined) { diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index e7cc65b7..876d24cf 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -18,19 +18,18 @@ import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } f * - Everything else → throw (non-retryable: unknown failure) */ export async function wrapSshCall(op: Promise): Promise> { - try { - await op; + const r = await asyncTryCatch(() => op); + if (r.ok) { return Ok(undefined); - } catch (err) { - const msg = getErrorMessage(err); - // Timeouts are NOT retryable — the command may have completed on the - // remote but we lost the connection before seeing the exit code. - if (msg.includes("timed out") || msg.includes("timeout")) { - throw err; - } - // All other SSH errors (connection refused, reset, etc.) are retryable. - return Err(new Error(msg)); } + const msg = getErrorMessage(r.error); + // Timeouts are NOT retryable — the command may have completed on the + // remote but we lost the connection before seeing the exit code. + if (msg.includes("timed out") || msg.includes("timeout")) { + throw r.error; + } + // All other SSH errors (connection refused, reset, etc.) are retryable. + return Err(new Error(msg)); } // ─── CloudRunner interface ────────────────────────────────────────────────── @@ -68,8 +67,8 @@ async function uploadConfigFile(runner: CloudRunner, content: string, remotePath mode: 0o600, }); - try { - await withRetry( + const uploadResult = await asyncTryCatch(() => + withRetry( "config upload", () => wrapSshCall( @@ -83,13 +82,11 @@ async function uploadConfigFile(runner: CloudRunner, content: string, remotePath ), 2, 5, - ); - } finally { - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } + ), + ); + tryCatchIf(isOperationalError, () => unlinkSync(tmpFile)); + if (!uploadResult.ok) { + throw uploadResult.error; } } diff --git a/packages/cli/src/shared/ssh.ts b/packages/cli/src/shared/ssh.ts index 030bb49e..bf1538fa 100644 --- a/packages/cli/src/shared/ssh.ts +++ b/packages/cli/src/shared/ssh.ts @@ -324,7 +324,7 @@ export async function waitForSsh(opts: WaitForSshOpts): Promise { // during key exchange or auth, the process hangs indefinitely. Kill it // after 30s so the retry loop can continue. const timer = setTimeout(() => killWithTimeout(proc), 30_000); - try { + const inner = await asyncTryCatch(async () => { const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), @@ -347,9 +347,12 @@ export async function waitForSsh(opts: WaitForSshOpts): Promise { logStep(`SSH handshake failed (${i}/${handshakeAttempts})`); } return null; - } finally { - clearTimeout(timer); + }); + clearTimeout(timer); + if (!inner.ok) { + throw inner.error; } + return inner.data; }); if (r.ok && r.data !== null) { logInfo("SSH is ready"); diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index fe588c9d..9e0fa229 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import * as p from "@clack/prompts"; import { getSpawnCloudConfigPath } from "./paths"; -import { isFileError, tryCatch, tryCatchIf, unwrapOr } from "./result.js"; +import { asyncTryCatch, isFileError, tryCatch, tryCatchIf, unwrapOr } from "./result.js"; import { isString } from "./type-guards"; const RED = "\x1b[0;31m"; @@ -74,17 +74,19 @@ export async function prompt(question: string): Promise { }; }); - try { - const result = await Promise.race([ + const r = await asyncTryCatch(() => + Promise.race([ p.text({ message, }), stdinClosePromise, - ]); - return p.isCancel(result) ? "" : (result || "").trim(); - } finally { - cleanupStdinListener?.(); + ]), + ); + cleanupStdinListener?.(); + if (!r.ok) { + throw r.error; } + return p.isCancel(r.data) ? "" : (r.data || "").trim(); } /** diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 0cb9a6f8..86012558 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -445,13 +445,13 @@ export async function runSprite(cmd: string, timeoutSecs?: number): Promise killWithTimeout(proc), timeout); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`sprite exec failed (exit ${exitCode}): ${cmd.slice(0, 80)}`); - } - } finally { - clearTimeout(timer); + const execResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!execResult.ok) { + throw execResult.error; + } + if (execResult.data !== 0) { + throw new Error(`sprite exec failed (exit ${execResult.data}): ${cmd.slice(0, 80)}`); } }); } @@ -481,13 +481,13 @@ async function runSpriteSilent(cmd: string): Promise { ); // 60s timeout — silent commands should not hang indefinitely const timer = setTimeout(() => killWithTimeout(proc), 60_000); - try { - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`sprite exec (silent) failed (exit ${exitCode})`); - } - } finally { - clearTimeout(timer); + const silentResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!silentResult.ok) { + throw silentResult.error; + } + if (silentResult.data !== 0) { + throw new Error(`sprite exec (silent) failed (exit ${silentResult.data})`); } } @@ -556,15 +556,15 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P export async function installSpriteKeepAlive(): Promise { logStep("Installing Sprite keep-alive..."); const scriptUrl = "https://kurt-claw-f.sprites.app/sprite-keep-running.sh"; - const r = await asyncTryCatch(async () => { - await runSprite( + const keepAliveResult = await asyncTryCatch(() => + runSprite( "mkdir -p ~/.local/bin && " + `curl -fsSL '${scriptUrl}' -o ~/.local/bin/sprite-keep-running && ` + "chmod +x ~/.local/bin/sprite-keep-running", 60, - ); - }); - if (r.ok) { + ), + ); + if (keepAliveResult.ok) { logInfo("Sprite keep-alive installed"); } else { logWarn("Could not install Sprite keep-alive — sprite may shut down during inactivity"); @@ -671,12 +671,12 @@ export async function destroyServer(name?: string): Promise { const stderrText = new Response(proc.stderr).text(); // 60s timeout — sprite destroy should not hang indefinitely const timer = setTimeout(() => killWithTimeout(proc), 60_000); - let exitCode: number; - try { - exitCode = await proc.exited; - } finally { - clearTimeout(timer); + const destroyResult = await asyncTryCatch(() => proc.exited); + clearTimeout(timer); + if (!destroyResult.ok) { + throw destroyResult.error; } + const exitCode = destroyResult.data; if (exitCode !== 0) { logError(`Failed to destroy sprite '${target}'`); logError(`Delete it manually: sprite destroy ${target}`); diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 3dbc0a69..d9b2dbf2 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -180,17 +180,19 @@ function reExecWithArgs(): void { } console.error(); - try { + const r = tryCatch(() => executor.execFileSync(binPath, args, { stdio: "inherit", env: { ...process.env, SPAWN_NO_UPDATE_CHECK: "1", }, - }); + }), + ); + if (r.ok) { process.exit(0); - } catch (reexecErr) { - const code = hasStatus(reexecErr) ? reexecErr.status : 1; + } else { + const code = hasStatus(r.error) ? r.error.status : 1; process.exit(code); } }