refactor: add no-try-catch + no-try-finally grit rules, eliminate all violations (#2481)

Add two new GritQL biome plugins (matching ori repo patterns) that ban
all try/catch and try/finally in TypeScript code. Convert all remaining
blocks across production and test files to use tryCatch/asyncTryCatch
from @openrouter/spawn-shared.

no-try-catch.grit covers all 4 variants:
- try/catch with binding, try/catch without binding
- try/catch/finally with binding, try/catch/finally without binding

no-try-finally.grit covers bare try/finally.

Both exclude shared/result.ts and shared/parse.ts (the implementation layer).

Production files (18): aws, hetzner, digitalocean, gcp, sprite, index,
update-check, ui, ssh, agent-setup, picker, agent-tarball, shared,
run, connect, delete, list

Test files (12): cmdlast, cmd-interactive, cmdrun-happy-path,
commands-resolve-run, commands-swap-resolve, commands-error-paths,
download-and-failure, preload, ssh-keys, update-check, orchestrate,
fs-sandbox, prompt-file-security, security, script-failure-guidance

Bumps CLI version to 0.16.6

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-03-10 21:27:25 -07:00 committed by GitHub
parent 9a1dad7fcb
commit 46b1e9d42c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 505 additions and 635 deletions

23
lint/no-try-catch.grit Normal file
View file

@ -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"
)
}

21
lint/no-try-finally.grit Normal file
View file

@ -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"
)
}

View file

@ -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"
]
}

View file

@ -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);

View file

@ -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)

View file

@ -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 = [

View file

@ -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);
});

View file

@ -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

View file

@ -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(

View file

@ -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");

View file

@ -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", () => {

View file

@ -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<void> {
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 () => {

View file

@ -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();
});

View file

@ -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");
});
});

View file

@ -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;
}
});

View file

@ -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", () => {

View file

@ -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
}
}),
);
}
/**

View file

@ -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() {

View file

@ -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<void> {
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<void> {
// 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<void
);
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}`);
}
}
@ -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}`);
}
}

View file

@ -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<void> {
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<void> {
// 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<void> {
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<void> {
// 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'");

View file

@ -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<boolean> {
// 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<boolean> {
// 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);
});

View file

@ -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.`,
);

View file

@ -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)

View file

@ -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<T>(msg: string, fn: () => Promise<T>, doneMsg?: string): Promise<T> {
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<Manifest> {
@ -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);
}

View file

@ -265,7 +265,7 @@ export async function checkAccountStatus(): Promise<void> {
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<void> {
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<void> {
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<v
// network drops mid-stream `await proc.exited` blocks forever. Kill
// after 330s (5min + 30s grace) to match the remote timeout.
const streamTimer = setTimeout(() => 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<v
// 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 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}`);
}
}

View file

@ -535,7 +535,7 @@ export async function checkBillingEnabled(): Promise<void> {
if (!_state.project) {
return;
}
try {
const billingResult = await asyncTryCatch(async () => {
const result = gcloudSync([
"billing",
"projects",
@ -562,10 +562,11 @@ export async function checkBillingEnabled(): Promise<void> {
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<void> {
// 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<void
);
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}`);
}
}
@ -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}`);
}
}

View file

@ -543,21 +543,22 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise<v
// 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;
}
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}`);
}
}

View file

@ -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<string> {
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<void> {
// 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<void> {
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<void> {
} 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);
}
}

View file

@ -225,7 +225,7 @@ function withTTYKeyLoop<T>(callbacks: KeyLoopCallbacks<T>): 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<T>(callbacks: KeyLoopCallbacks<T>): T {
break;
}
}
} finally {
restore();
});
restore();
if (!loopResult.ok) {
throw loopResult.error;
}
if (finalResult !== undefined) {

View file

@ -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<void>): Promise<Result<void>> {
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;
}
}

View file

@ -324,7 +324,7 @@ export async function waitForSsh(opts: WaitForSshOpts): Promise<void> {
// 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<void> {
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");

View file

@ -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<string> {
};
});
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();
}
/**

View file

@ -445,13 +445,13 @@ export async function runSprite(cmd: string, timeoutSecs?: number): Promise<void
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => 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<void> {
);
// 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<void> {
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<void> {
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}`);

View file

@ -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);
}
}