mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
9a1dad7fcb
commit
46b1e9d42c
34 changed files with 505 additions and 635 deletions
23
lint/no-try-catch.grit
Normal file
23
lint/no-try-catch.grit
Normal 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
21
lint/no-try-finally.grit
Normal 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"
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue