diff --git a/packages/cli/package.json b/packages/cli/package.json index da3dfa5f..8052e8d1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.1", + "version": "0.16.2", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/result-helpers.test.ts b/packages/cli/src/__tests__/result-helpers.test.ts new file mode 100644 index 00000000..adb49e89 --- /dev/null +++ b/packages/cli/src/__tests__/result-helpers.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "bun:test"; +import { + asyncTryCatch, + asyncTryCatchIf, + Err, + isFileError, + isNetworkError, + isOperationalError, + mapResult, + Ok, + tryCatch, + tryCatchIf, + unwrapOr, +} from "../shared/result"; + +// ── Helper: create an Error with a `code` property (like Node.js errno errors) ── + +function errnoError(message: string, code: string): Error { + const err = new Error(message); + Object.defineProperty(err, "code", { + value: code, + }); + return err; +} + +// ── tryCatch ──────────────────────────────────────────────────────────────────── + +describe("tryCatch", () => { + it("returns Ok on success", () => { + const result = tryCatch(() => 42); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe(42); + } + }); + + it("returns Err on thrown Error", () => { + const result = tryCatch(() => { + throw new Error("boom"); + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("boom"); + } + }); + + it("wraps non-Error throws in Error", () => { + const result = tryCatch(() => { + throw "string error"; + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("string error"); + } + }); +}); + +// ── asyncTryCatch ─────────────────────────────────────────────────────────────── + +describe("asyncTryCatch", () => { + it("returns Ok on resolved promise", async () => { + const result = await asyncTryCatch(async () => "hello"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe("hello"); + } + }); + + it("returns Err on rejected promise", async () => { + const result = await asyncTryCatch(async () => { + throw new Error("async boom"); + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("async boom"); + } + }); + + it("returns Err on sync throw inside async fn", async () => { + const result = await asyncTryCatch(() => Promise.reject(new Error("rejected"))); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("rejected"); + } + }); +}); + +// ── tryCatchIf ────────────────────────────────────────────────────────────────── + +describe("tryCatchIf", () => { + it("returns Ok on success", () => { + const result = tryCatchIf(isFileError, () => 42); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe(42); + } + }); + + it("returns Err when guard matches", () => { + const result = tryCatchIf(isFileError, () => { + throw errnoError("file not found", "ENOENT"); + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("file not found"); + } + }); + + it("re-throws when guard does NOT match", () => { + expect(() => { + tryCatchIf(isFileError, () => { + throw new TypeError("cannot read property of null"); + }); + }).toThrow(TypeError); + }); + + it("re-throws RangeError (programming bug)", () => { + expect(() => { + tryCatchIf(isFileError, () => { + throw new RangeError("index out of range"); + }); + }).toThrow(RangeError); + }); +}); + +// ── asyncTryCatchIf ───────────────────────────────────────────────────────────── + +describe("asyncTryCatchIf", () => { + it("returns Ok on success", async () => { + const result = await asyncTryCatchIf(isNetworkError, async () => "ok"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe("ok"); + } + }); + + it("returns Err when guard matches", async () => { + const result = await asyncTryCatchIf(isNetworkError, async () => { + throw errnoError("connection refused", "ECONNREFUSED"); + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("connection refused"); + } + }); + + it("re-throws when guard does NOT match", async () => { + await expect( + asyncTryCatchIf(isNetworkError, async () => { + throw new TypeError("null dereference"); + }), + ).rejects.toThrow(TypeError); + }); +}); + +// ── unwrapOr ──────────────────────────────────────────────────────────────────── + +describe("unwrapOr", () => { + it("returns data on Ok", () => { + expect(unwrapOr(Ok(42), 0)).toBe(42); + }); + + it("returns fallback on Err", () => { + expect(unwrapOr(Err(new Error("fail")), 0)).toBe(0); + }); + + it("returns null fallback on Err", () => { + const result: string | null = unwrapOr(Err(new Error("fail")), null); + expect(result).toBeNull(); + }); +}); + +// ── mapResult ─────────────────────────────────────────────────────────────────── + +describe("mapResult", () => { + it("transforms Ok value", () => { + const result = mapResult(Ok(5), (n) => n * 2); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe(10); + } + }); + + it("passes Err through unchanged", () => { + const err = new Error("fail"); + const result = mapResult(Err(err), (n) => n * 2); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(err); + } + }); +}); + +// ── isFileError ───────────────────────────────────────────────────────────────── + +describe("isFileError", () => { + it("returns true for ENOENT", () => { + expect(isFileError(errnoError("no such file", "ENOENT"))).toBe(true); + }); + + it("returns true for EACCES", () => { + expect(isFileError(errnoError("permission denied", "EACCES"))).toBe(true); + }); + + it("returns true for EISDIR", () => { + expect(isFileError(errnoError("is a directory", "EISDIR"))).toBe(true); + }); + + it("returns true for ENOSPC", () => { + expect(isFileError(errnoError("no space left", "ENOSPC"))).toBe(true); + }); + + it("returns false for TypeError", () => { + expect(isFileError(new TypeError("cannot read"))).toBe(false); + }); + + it("returns false for generic Error", () => { + expect(isFileError(new Error("something"))).toBe(false); + }); + + it("returns false for network error code", () => { + expect(isFileError(errnoError("conn refused", "ECONNREFUSED"))).toBe(false); + }); +}); + +// ── isNetworkError ────────────────────────────────────────────────────────────── + +describe("isNetworkError", () => { + it("returns true for ECONNREFUSED", () => { + expect(isNetworkError(errnoError("conn refused", "ECONNREFUSED"))).toBe(true); + }); + + it("returns true for ECONNRESET", () => { + expect(isNetworkError(errnoError("conn reset", "ECONNRESET"))).toBe(true); + }); + + it("returns true for ETIMEDOUT", () => { + expect(isNetworkError(errnoError("timed out", "ETIMEDOUT"))).toBe(true); + }); + + it("returns true for AbortError", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(isNetworkError(err)).toBe(true); + }); + + it("returns true for TimeoutError", () => { + const err = new Error("timed out"); + err.name = "TimeoutError"; + expect(isNetworkError(err)).toBe(true); + }); + + it('returns true for "fetch failed" message', () => { + expect(isNetworkError(new Error("fetch failed"))).toBe(true); + }); + + it('returns true for "network error" message', () => { + expect(isNetworkError(new Error("network error"))).toBe(true); + }); + + it("returns true for TypeError with fetch message", () => { + expect(isNetworkError(new TypeError("fetch failed: connection refused"))).toBe(true); + }); + + it("returns false for TypeError without network message", () => { + expect(isNetworkError(new TypeError("cannot read property of null"))).toBe(false); + }); + + it("returns false for generic Error", () => { + expect(isNetworkError(new Error("something else"))).toBe(false); + }); + + it("returns false for file error code", () => { + expect(isNetworkError(errnoError("no such file", "ENOENT"))).toBe(false); + }); +}); + +// ── isOperationalError ────────────────────────────────────────────────────────── + +describe("isOperationalError", () => { + it("returns true for file errors", () => { + expect(isOperationalError(errnoError("no such file", "ENOENT"))).toBe(true); + }); + + it("returns true for network errors", () => { + expect(isOperationalError(errnoError("conn refused", "ECONNREFUSED"))).toBe(true); + }); + + it("returns false for TypeError", () => { + expect(isOperationalError(new TypeError("bug"))).toBe(false); + }); + + it("returns false for RangeError", () => { + expect(isOperationalError(new RangeError("out of range"))).toBe(false); + }); +}); + +// ── Bug propagation integration test ──────────────────────────────────────────── + +describe("bug propagation", () => { + it("TypeError from null dereference is NOT caught by tryCatchIf(isFileError)", () => { + expect(() => { + tryCatchIf(isFileError, () => { + // Simulate a programming bug — accessing a property on null throws TypeError + const obj: Record | null = null; + return obj!.foo; + }); + }).toThrow(TypeError); + }); + + it("SyntaxError is NOT caught by tryCatchIf(isFileError)", () => { + expect(() => { + tryCatchIf(isFileError, () => { + JSON.parse("not valid json {{{"); + }); + }).toThrow(SyntaxError); + }); + + it("RangeError is NOT caught by tryCatchIf(isNetworkError)", () => { + expect(() => { + tryCatchIf(isNetworkError, () => { + throw new RangeError("maximum call stack size exceeded"); + }); + }).toThrow(RangeError); + }); +}); diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index abe24749..215e59ea 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -10,6 +10,7 @@ import { handleBillingError, isBillingError, showNonBillingError } from "../shar import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { parseJsonWith } from "../shared/parse"; import { getSpawnCloudConfigPath } from "../shared/paths"; +import { isFileError, tryCatchIf, unwrapOr } from "../shared/result.js"; import { killWithTimeout, SSH_BASE_OPTS, @@ -68,26 +69,27 @@ export function loadCredsFromConfig(): { secretAccessKey: string; region: string; } | null { - try { - const raw = readFileSync(getAwsConfigPath(), "utf-8"); - const data = parseJsonWith(raw, AwsCredsSchema); - if (!data?.accessKeyId || !data?.secretAccessKey) { - return null; - } - if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { - return null; - } - if (data.secretAccessKey.length < 16) { - return null; - } - return { - accessKeyId: data.accessKeyId, - secretAccessKey: data.secretAccessKey, - region: data.region || "us-east-1", - }; - } catch { - return null; - } + return unwrapOr( + tryCatchIf(isFileError, () => { + const raw = readFileSync(getAwsConfigPath(), "utf-8"); + const data = parseJsonWith(raw, AwsCredsSchema); + if (!data?.accessKeyId || !data?.secretAccessKey) { + return null; + } + if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { + return null; + } + if (data.secretAccessKey.length < 16) { + return null; + } + return { + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + region: data.region || "us-east-1", + }; + }), + null, + ); } // ─── Lightsail Bundles ──────────────────────────────────────────────────────── diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 2d5f975c..9cdf524b 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -9,6 +9,7 @@ import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../sh import { OAUTH_CSS } from "../shared/oauth"; import { parseJsonObj } from "../shared/parse"; import { getSpawnCloudConfigPath } from "../shared/paths"; +import { asyncTryCatchIf, isFileError, isNetworkError, tryCatchIf, unwrapOr } from "../shared/result.js"; import { killWithTimeout, SSH_BASE_OPTS, @@ -152,7 +153,8 @@ async function doApi(method: string, endpoint: string, body?: string, maxRetries } return text; } catch (err) { - if (attempt >= maxRetries) { + const e = err instanceof Error ? err : new Error(String(err)); + if (!isNetworkError(e) || attempt >= maxRetries) { throw err; } logWarn(`API request failed (attempt ${attempt}/${maxRetries}), retrying...`); @@ -166,11 +168,10 @@ async function doApi(method: string, endpoint: string, body?: string, maxRetries // ─── Token Persistence ─────────────────────────────────────────────────────── function loadConfig(): Record | null { - try { - return parseJsonObj(readFileSync(getSpawnCloudConfigPath("digitalocean"), "utf-8")); - } catch { - return null; - } + return unwrapOr( + tryCatchIf(isFileError, () => parseJsonObj(readFileSync(getSpawnCloudConfigPath("digitalocean"), "utf-8"))), + null, + ); } async function saveConfig(values: Record): Promise { @@ -234,12 +235,13 @@ async function testDoToken(): Promise { if (!_state.token) { return false; } - try { - const text = await doApi("GET", "/account", undefined, 1); - return text.includes('"uuid"'); - } catch { - return false; - } + return unwrapOr( + await asyncTryCatchIf(isNetworkError, async () => { + const text = await doApi("GET", "/account", undefined, 1); + return text.includes('"uuid"'); + }), + false, + ); } /** diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index c9eaf1a8..e586a1d9 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -8,6 +8,7 @@ import { handleBillingError, isBillingError, showNonBillingError } from "../shar import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; import { parseJsonObj } from "../shared/parse"; import { getSpawnCloudConfigPath } from "../shared/paths"; +import { asyncTryCatchIf, isNetworkError, unwrapOr } from "../shared/result.js"; import { killWithTimeout, SSH_BASE_OPTS, @@ -99,7 +100,8 @@ async function hetznerApi(method: string, endpoint: string, body?: string, maxRe } return text; } catch (err) { - if (attempt >= maxRetries) { + const e = err instanceof Error ? err : new Error(String(err)); + if (!isNetworkError(e) || attempt >= maxRetries) { throw err; } logWarn(`API request failed (attempt ${attempt}/${maxRetries}), retrying...`); @@ -131,19 +133,20 @@ async function testHcloudToken(): Promise { if (!_state.hcloudToken) { return false; } - try { - const resp = await hetznerApi("GET", "/servers?per_page=1", undefined, 1); - const data = parseJsonObj(resp); - // Hetzner returns { "error": { ... } } on auth failure. - // Success responses may contain "error": null inside action objects, - // so check for a real error object with a message. - if (toRecord(data?.error)?.message) { - return false; - } - return true; - } catch { - return false; - } + return unwrapOr( + await asyncTryCatchIf(isNetworkError, async () => { + const resp = await hetznerApi("GET", "/servers?per_page=1", undefined, 1); + const data = parseJsonObj(resp); + // Hetzner returns { "error": { ... } } on auth failure. + // Success responses may contain "error": null inside action objects, + // so check for a real error object with a message. + if (toRecord(data?.error)?.message) { + return false; + } + return true; + }), + false, + ); } // ─── Authentication ────────────────────────────────────────────────────────── diff --git a/packages/cli/src/history.ts b/packages/cli/src/history.ts index 03e96884..4d3b167f 100644 --- a/packages/cli/src/history.ts +++ b/packages/cli/src/history.ts @@ -12,7 +12,7 @@ import { import { join } from "node:path"; import * as v from "valibot"; import { getHistoryPath, getSpawnDir } from "./shared/paths.js"; -import { tryCatch } from "./shared/result.js"; +import { isFileError, tryCatch, tryCatchIf } from "./shared/result.js"; import { getErrorMessage } from "./shared/type-guards.js"; import { logDebug, logWarn } from "./shared/ui.js"; @@ -101,7 +101,7 @@ function writeHistory(records: SpawnRecord[]): void { /** Save launch command to a history record's connection. * Matches by spawnId when provided; falls back to most recent record with a connection. */ export function saveLaunchCmd(launchCmd: string, spawnId?: string): void { - const result = tryCatch(() => { + const result = tryCatchIf(isFileError, () => { const history = loadHistory(); let found = false; @@ -135,7 +135,7 @@ export function saveLaunchCmd(launchCmd: string, spawnId?: string): void { /** Back up a corrupted file before discarding it. Non-fatal (best-effort). */ function backupCorruptedFile(filePath: string): void { - const result = tryCatch(() => { + const result = tryCatchIf(isFileError, () => { copyFileSync(filePath, `${filePath}.corrupt.${Date.now()}`); console.error(`Warning: ${filePath} was corrupted. A backup has been saved with .corrupt suffix.`); }); @@ -144,7 +144,8 @@ function backupCorruptedFile(filePath: string): void { } } -/** Try to parse valid records from a single archive file. */ +/** Try to parse valid records from a single archive file. + * Uses tryCatch (catch-all) because corrupted JSON is expected — SyntaxError is not a file error. */ function parseArchiveFile(dir: string, file: string): SpawnRecord[] | null { const result = tryCatch(() => { const text = readFileSync(join(dir, file), "utf-8"); @@ -160,7 +161,8 @@ function parseArchiveFile(dir: string, file: string): SpawnRecord[] | null { return result.data.length > 0 ? result.data : null; } -/** Attempt to recover records from archive files (history-*.json). */ +/** Attempt to recover records from archive files (history-*.json). + * Uses tryCatch (catch-all) because archive recovery is best-effort — any failure returns []. */ function recoverFromArchives(): SpawnRecord[] { const result = tryCatch(() => { const dir = getSpawnDir(); @@ -214,7 +216,7 @@ export function loadHistory(): SpawnRecord[] { if (!existsSync(path)) { return []; } - const readResult = tryCatch(() => readFileSync(path, "utf-8")); + const readResult = tryCatchIf(isFileError, () => readFileSync(path, "utf-8")); if (!readResult.ok) { logWarn("Could not read spawn history"); logDebug(getErrorMessage(readResult.error)); @@ -263,7 +265,7 @@ function archiveRecords(records: SpawnRecord[]): void { return; } // Non-fatal — archive failure should not block saving - tryCatch(() => { + tryCatchIf(isFileError, () => { const dir = getSpawnDir(); const date = new Date().toISOString().slice(0, 10); const archivePath = join(dir, `history-${date}.json`); diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index d48fdf36..ed09267b 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getCacheDir, getCacheFile } from "./shared/paths.js"; +import { asyncTryCatch, isFileError, tryCatchIf, unwrapOr } from "./shared/result.js"; import { getErrorMessage } from "./shared/type-guards.js"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -78,13 +79,13 @@ const FETCH_TIMEOUT = 10_000; // 10 seconds // ── Cache helpers ────────────────────────────────────────────────────────────── function cacheAge(): number { - try { - const st: ReturnType = statSync(getCacheFile()); - return (Date.now() - st.mtimeMs) / 1000; - } catch (_err) { - // Cache file doesn't exist or is inaccessible - treat as infinitely old - return Number.POSITIVE_INFINITY; - } + return unwrapOr( + tryCatchIf(isFileError, () => { + const st: ReturnType = statSync(getCacheFile()); + return (Date.now() - st.mtimeMs) / 1000; + }), + Number.POSITIVE_INFINITY, + ); } function logError(message: string, err?: unknown): void { @@ -92,18 +93,19 @@ function logError(message: string, err?: unknown): void { } function readCache(): Manifest | null { - try { + const result = tryCatchIf(isFileError, () => { const raw = JSON.parse(readFileSync(getCacheFile(), "utf-8")); const cleaned = stripDangerousKeys(raw); if (isValidManifest(cleaned)) { return cleaned; } return null; - } catch (err) { - // Cache file missing, corrupted, or unreadable - logError(`Failed to read cache from ${getCacheFile()}`, err); + }); + if (!result.ok) { + logError(`Failed to read cache from ${getCacheFile()}`, result.error); return null; } + return result.data; } function isTestEnv(): boolean { @@ -159,7 +161,9 @@ function isValidManifest(data: unknown): data is Manifest { } async function fetchManifestFromGitHub(): Promise { - try { + // Uses asyncTryCatch (catch-all) because fetch + JSON parse + validation is a single + // remote operation — any failure (network, JSON parse, TypeError) means "fetch failed". + const result = await asyncTryCatch(async () => { const res = await fetch(`${RAW_BASE}/manifest.json`, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); @@ -174,10 +178,12 @@ async function fetchManifestFromGitHub(): Promise { return null; } return data; - } catch (err) { - logError("Network error fetching manifest", err); + }); + if (!result.ok) { + logError("Network error fetching manifest", result.error); return null; } + return result.data; } // ── Public API ───────────────────────────────────────────────────────────────── @@ -205,8 +211,7 @@ function tryLoadLocalManifest(): Manifest | null { return null; } - try { - // Try loading manifest.json from current directory (development mode) + const result = tryCatchIf(isFileError, () => { const localPath = join(process.cwd(), "manifest.json"); if (existsSync(localPath)) { const raw = JSON.parse(readFileSync(localPath, "utf-8")); @@ -215,10 +220,9 @@ function tryLoadLocalManifest(): Manifest | null { return data; } } - } catch (_err) { - // Local manifest not found or invalid - not an error, just continue - } - return null; + return null; + }); + return result.ok ? result.data : null; } export async function loadManifest(forceRefresh = false): Promise { diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 477324b1..238ddefa 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -7,6 +7,7 @@ import type { Result } from "./ui"; import { unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getTmpDir } from "./paths"; +import { asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; import { getErrorMessage } from "./type-guards"; import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui"; @@ -170,8 +171,8 @@ let hostGitEmail = ""; /** Read a git config value from the host machine, returning "" on failure. */ function readHostGitConfig(key: string): string { - try { - const result = Bun.spawnSync( + const result = tryCatchIf(isOperationalError, () => { + const r = Bun.spawnSync( [ "git", "config", @@ -186,21 +187,20 @@ function readHostGitConfig(key: string): string { ], }, ); - if (result.exitCode === 0) { - return new TextDecoder().decode(result.stdout).trim(); + if (r.exitCode === 0) { + return new TextDecoder().decode(r.stdout).trim(); } - } catch { - /* ignore — git may not be installed on host */ - } - return ""; + return ""; + }); + return result.ok ? result.data : ""; } async function detectGithubAuth(): Promise { if (process.env.GITHUB_TOKEN) { githubToken = process.env.GITHUB_TOKEN; } else { - try { - const result = Bun.spawnSync( + const ghResult = tryCatchIf(isOperationalError, () => { + const r = Bun.spawnSync( [ "gh", "auth", @@ -214,11 +214,13 @@ async function detectGithubAuth(): Promise { ], }, ); - if (result.exitCode === 0) { - githubToken = new TextDecoder().decode(result.stdout).trim(); + if (r.exitCode === 0) { + return new TextDecoder().decode(r.stdout).trim(); } - } catch { - /* ignore */ + return ""; + }); + if (ghResult.ok && ghResult.data) { + githubToken = ghResult.data; } } @@ -246,9 +248,8 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { } logStep("Installing and authenticating GitHub CLI on the remote server..."); - try { - await runner.runServer(ghCmd); - } catch { + const ghSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(ghCmd)); + if (!ghSetup.ok) { logWarn("GitHub CLI setup failed (non-fatal, continuing)"); } @@ -264,10 +265,10 @@ export async function offerGithubAuth(runner: CloudRunner): Promise { const escaped = hostGitEmail.replace(/'/g, "'\\''"); cmds.push(`git config --global user.email '${escaped}'`); } - try { - await runner.runServer(cmds.join(" && ")); + const gitSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(cmds.join(" && "))); + if (gitSetup.ok) { logInfo("Git identity configured on remote server"); - } catch { + } else { logWarn("Git identity setup failed (non-fatal, continuing)"); } } @@ -296,16 +297,18 @@ async function installChromeBrowser(runner: CloudRunner): Promise { // Snap Chromium on Ubuntu 24.04 fails — AppArmor confinement blocks CDP control. // Google Chrome .deb bypasses snap entirely and lands at /usr/bin/google-chrome. logStep("Installing Google Chrome for browser tool..."); - try { - await runner.runServer( + const result = await asyncTryCatchIf(isOperationalError, () => + runner.runServer( "{ command -v google-chrome-stable >/dev/null 2>&1 || command -v google-chrome >/dev/null 2>&1; } && { echo 'Chrome already installed'; exit 0; }; " + "curl --proto '=https' -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/google-chrome.deb && " + "sudo dpkg -i /tmp/google-chrome.deb; sudo apt-get install -f -y -qq; " + "rm -f /tmp/google-chrome.deb", 120, - ); + ), + ); + if (result.ok) { logInfo("Google Chrome installed"); - } catch { + } else { logWarn("Google Chrome install failed (browser tool will be unavailable)"); } } @@ -354,15 +357,16 @@ async function setupOpenclawConfig( // Configure browser via CLI (openclaw config set) — the supported way to set // browser options. Writing JSON directly may not be picked up by all versions. - try { - await runner.runServer( + const browserResult = await asyncTryCatchIf(isOperationalError, () => + runner.runServer( "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + "openclaw config set browser.executablePath /usr/bin/google-chrome-stable; " + "openclaw config set browser.noSandbox true; " + "openclaw config set browser.headless true; " + "openclaw config set browser.defaultProfile openclaw", - ); - } catch { + ), + ); + if (!browserResult.ok) { logWarn("Browser config setup failed (non-fatal)"); } @@ -527,10 +531,10 @@ async function ensureSwapSpace(runner: CloudRunner, sizeMb = 1024): Promise Swap enabled'", "fi", ].join("\n"); - try { - await runner.runServer(script); + const result = await asyncTryCatchIf(isOperationalError, () => runner.runServer(script)); + if (result.ok) { logInfo("Swap space ready"); - } catch { + } else { logWarn("Swap setup failed (non-fatal) — build may still succeed on larger instances"); } } diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index e4e56e4e..9608d81d 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -6,6 +6,7 @@ import * as v from "valibot"; import { OAUTH_CODE_REGEX } from "./oauth-constants"; import { parseJsonWith } from "./parse"; import { getSpawnCloudConfigPath } from "./paths"; +import { asyncTryCatchIf, isFileError, isNetworkError, tryCatchIf } from "./result.js"; import { getErrorMessage, isString } from "./type-guards"; import { logDebug, logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui"; @@ -25,7 +26,7 @@ async function verifyOpenrouterKey(apiKey: string): Promise { return true; } - try { + const result = await asyncTryCatchIf(isNetworkError, async () => { const resp = await fetch("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${apiKey}`, @@ -41,9 +42,8 @@ async function verifyOpenrouterKey(apiKey: string): Promise { return false; } return true; // unknown status = don't block - } catch { - return true; // network error = skip validation - } + }); + return result.ok ? result.data : true; // network error = skip validation } // ─── OAuth Flow via Bun.serve ──────────────────────────────────────────────── @@ -67,12 +67,14 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: logStep("Attempting OAuth authentication..."); // Check network connectivity - try { + const reachable = await asyncTryCatchIf(isNetworkError, async () => { await fetch("https://openrouter.ai", { method: "HEAD", signal: AbortSignal.timeout(5_000), }); - } catch { + return true; + }); + if (!reachable.ok) { logWarn("Cannot reach openrouter.ai — network may be unavailable"); return null; } @@ -191,7 +193,7 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: // Exchange code for API key logStep("Exchanging OAuth code for API key..."); - try { + const exchangeResult = await asyncTryCatchIf(isNetworkError, async () => { const resp = await fetch("https://openrouter.ai/api/v1/auth/keys", { method: "POST", headers: { @@ -209,17 +211,19 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?: } logError("Failed to exchange OAuth code for API key"); return null; - } catch (_err) { + }); + if (!exchangeResult.ok) { logError("Failed to contact OpenRouter API"); return null; } + return exchangeResult.data; } // ─── API Key Persistence ───────────────────────────────────────────────────── /** Save OpenRouter API key to ~/.config/spawn/openrouter.json so it persists across runs. */ async function saveOpenRouterKey(key: string): Promise { - try { + const result = await asyncTryCatchIf(isFileError, async () => { const configPath = getSpawnCloudConfigPath("openrouter"); mkdirSync(dirname(configPath), { recursive: true, @@ -238,15 +242,16 @@ async function saveOpenRouterKey(key: string): Promise { mode: 0o600, }, ); - } catch (err) { + }); + if (!result.ok) { logWarn("Could not save API key — you may need to re-authenticate next run"); - logDebug(getErrorMessage(err)); + logDebug(getErrorMessage(result.error)); } } /** Load a previously saved OpenRouter API key from ~/.config/spawn/openrouter.json. */ function loadSavedOpenRouterKey(): string | null { - try { + const result = tryCatchIf(isFileError, () => { const configPath = getSpawnCloudConfigPath("openrouter"); const data = JSON.parse(readFileSync(configPath, "utf-8")); const key = isString(data.api_key) ? data.api_key : ""; @@ -254,9 +259,8 @@ function loadSavedOpenRouterKey(): string | null { return key; } return null; - } catch { - return null; - } + }); + return result.ok ? result.data : null; } // ─── Main API Key Acquisition ──────────────────────────────────────────────── diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 0f47c6d3..c2340e02 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -11,6 +11,7 @@ import { offerGithubAuth, wrapSshCall } from "./agent-setup"; import { tryTarballInstall } from "./agent-tarball"; import { generateEnvConfig } from "./agents"; import { getOrPromptApiKey } from "./oauth"; +import { asyncTryCatchIf, isOperationalError } from "./result.js"; import { startSshTunnel } from "./ssh"; import { ensureSshKeys, getSshKeyOpts } from "./ssh-keys"; import { getErrorMessage } from "./type-guards"; @@ -89,6 +90,7 @@ export async function runOrchestration( await cloud.authenticate(); // 1b. Pre-flight account readiness check (billing, email verification, etc.) + // Uses try/catch (not guarded) because hooks can throw ANY provider-specific error. if (cloud.checkAccountReady) { try { await cloud.checkAccountReady(); @@ -103,6 +105,7 @@ export async function runOrchestration( const apiKey = await getOrPromptApiKey(agentName, cloud.cloudName); // 3. Pre-provision hooks (e.g., GitHub auth prompt — non-fatal) + // Uses try/catch (not guarded) because hooks can throw ANY provider-specific error. if (agent.preProvision) { try { await agent.preProvision(); @@ -214,7 +217,7 @@ export async function runOrchestration( if (agent.tunnel) { if (cloud.getConnectionInfo) { // SSH-based cloud: tunnel the remote port to localhost - try { + const tunnelResult = await asyncTryCatchIf(isOperationalError, async () => { const conn = cloud.getConnectionInfo(); const keys = await ensureSshKeys(); tunnelHandle = await startSshTunnel({ @@ -229,7 +232,8 @@ export async function runOrchestration( openBrowser(url); } } - } catch { + }); + if (!tunnelResult.ok) { logWarn("Web dashboard tunnel failed — use the TUI instead"); } } else if (cloud.cloudName === "local") { diff --git a/packages/cli/src/shared/result.ts b/packages/cli/src/shared/result.ts index b384c139..f8547bdc 100644 --- a/packages/cli/src/shared/result.ts +++ b/packages/cli/src/shared/result.ts @@ -1 +1,14 @@ -export { Err, Ok, type Result, tryCatch } from "@openrouter/spawn-shared"; +export { + asyncTryCatch, + asyncTryCatchIf, + Err, + isFileError, + isNetworkError, + isOperationalError, + mapResult, + Ok, + type Result, + tryCatch, + tryCatchIf, + unwrapOr, +} from "@openrouter/spawn-shared"; diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index 1591d579..1fff0afc 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import * as p from "@clack/prompts"; import { getSpawnCloudConfigPath } from "./paths"; +import { isFileError, tryCatchIf, unwrapOr } from "./result.js"; import { isString } from "./type-guards"; const RED = "\x1b[0;31m"; @@ -232,19 +233,20 @@ export async function withRetry( * Returns null if the file is missing, unreadable, or the token is invalid. */ export function loadApiToken(cloud: string): string | null { - try { - const data = JSON.parse(readFileSync(getSpawnCloudConfigPath(cloud), "utf-8")); - const token = (isString(data.api_key) ? data.api_key : "") || (isString(data.token) ? data.token : ""); - if (!token) { - return null; - } - if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(token)) { - return null; - } - return token; - } catch { - return null; - } + return unwrapOr( + tryCatchIf(isFileError, () => { + const data = JSON.parse(readFileSync(getSpawnCloudConfigPath(cloud), "utf-8")); + const token = (isString(data.api_key) ? data.api_key : "") || (isString(data.token) ? data.token : ""); + if (!token) { + return null; + } + if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(token)) { + return null; + } + return token; + }), + null, + ); } /** JSON-escape a string (returns the quoted JSON string). */ diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index 0002c6a8..aa77e067 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -9,6 +9,7 @@ import pkg from "../package.json" with { type: "json" }; import { RAW_BASE, SPAWN_CDN, VERSION_URL } from "./manifest.js"; import { PkgVersionSchema, parseJsonWith } from "./shared/parse"; import { getUpdateFailedPath } from "./shared/paths"; +import { asyncTryCatchIf, isFileError, isNetworkError, tryCatchIf, unwrapOr } from "./shared/result"; import { getErrorMessage, hasStatus } from "./shared/type-guards"; import { logDebug, logWarn } from "./shared/ui"; @@ -33,7 +34,7 @@ const CROSS_MARK = isAscii ? "x" : "\u2717"; async function fetchLatestVersion(): Promise { // Primary: plain-text version file from GitHub release artifact (static URL) - try { + const primary = await asyncTryCatchIf(isNetworkError, async () => { const res = await fetch(VERSION_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); @@ -43,12 +44,14 @@ async function fetchLatestVersion(): Promise { return text; } } - } catch { - // Fall through to GitHub raw fallback + return null; + }); + if (primary.ok && primary.data) { + return primary.data; } // Fallback: package.json from GitHub raw - try { + const fallback = await asyncTryCatchIf(isNetworkError, async () => { const res = await fetch(`${RAW_BASE}/packages/cli/package.json`, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); @@ -57,9 +60,8 @@ async function fetchLatestVersion(): Promise { } const data = parseJsonWith(await res.text(), PkgVersionSchema); return data?.version ?? null; - } catch { - return null; - } + }); + return fallback.ok ? fallback.data : null; } function compareVersions(current: string, latest: string): boolean { @@ -84,37 +86,34 @@ function compareVersions(current: string, latest: string): boolean { // ── Failure Backoff ────────────────────────────────────────────────────────── function isUpdateBackedOff(): boolean { - try { - const failedPath = getUpdateFailedPath(); - const content = fs.readFileSync(failedPath, "utf8").trim(); - const failedAt = Number.parseInt(content, 10); - if (Number.isNaN(failedAt)) { - return false; - } - return Date.now() - failedAt < UPDATE_BACKOFF_MS; - } catch { - return false; - } + return unwrapOr( + tryCatchIf(isFileError, () => { + const failedPath = getUpdateFailedPath(); + const content = fs.readFileSync(failedPath, "utf8").trim(); + const failedAt = Number.parseInt(content, 10); + if (Number.isNaN(failedAt)) { + return false; + } + return Date.now() - failedAt < UPDATE_BACKOFF_MS; + }), + false, + ); } function markUpdateFailed(): void { - try { + tryCatchIf(isFileError, () => { const failedPath = getUpdateFailedPath(); fs.mkdirSync(path.dirname(failedPath), { recursive: true, }); fs.writeFileSync(failedPath, String(Date.now())); - } catch { - // Best-effort — don't break the CLI if we can't write the file - } + }); } function clearUpdateFailed(): void { - try { + tryCatchIf(isFileError, () => { fs.unlinkSync(getUpdateFailedPath()); - } catch { - // File may not exist — that's fine - } + }); } /** Print boxed update banner to stderr */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0390ebd3..6455c968 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,16 @@ export { parseJsonObj, parseJsonWith } from "./parse"; -export { Err, Ok, type Result, tryCatch } from "./result"; +export { + asyncTryCatch, + asyncTryCatchIf, + Err, + isFileError, + isNetworkError, + isOperationalError, + mapResult, + Ok, + type Result, + tryCatch, + tryCatchIf, + unwrapOr, +} from "./result"; export { getErrorMessage, hasStatus, isNumber, isString, toObjectArray, toRecord } from "./type-guards"; diff --git a/packages/shared/src/result.ts b/packages/shared/src/result.ts index 2ed9fbd8..d3c83186 100644 --- a/packages/shared/src/result.ts +++ b/packages/shared/src/result.ts @@ -31,3 +31,107 @@ export function tryCatch(fn: () => T): Result { return Err(e instanceof Error ? e : new Error(String(e))); } } + +/** Wrap an async function call into a Result — no try/catch at the call site. */ +export async function asyncTryCatch(fn: () => Promise): Promise> { + try { + return Ok(await fn()); + } catch (e) { + return Err(e instanceof Error ? e : new Error(String(e))); + } +} + +/** + * Guarded sync try/catch — catches ONLY errors where `guard` returns true. + * Non-matching errors (programming bugs like TypeError) are re-thrown immediately. + */ +export function tryCatchIf(guard: (err: Error) => boolean, fn: () => T): Result { + try { + return Ok(fn()); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (guard(err)) { + return Err(err); + } + throw e; + } +} + +/** + * Guarded async try/catch — catches ONLY errors where `guard` returns true. + * Non-matching errors (programming bugs like TypeError) are re-thrown immediately. + */ +export async function asyncTryCatchIf(guard: (err: Error) => boolean, fn: () => Promise): Promise> { + try { + return Ok(await fn()); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (guard(err)) { + return Err(err); + } + throw e; + } +} + +/** Extract the value from a Result, returning `fallback` on Err. */ +export function unwrapOr(result: Result, fallback: T): T { + return result.ok ? result.data : fallback; +} + +/** Transform the Ok value of a Result, passing Err through unchanged. */ +export function mapResult(result: Result, fn: (data: T) => U): Result { + return result.ok ? Ok(fn(result.data)) : result; +} + +// ── Error predicates ────────────────────────────────────────────────────────── + +const FILE_ERROR_CODES = new Set([ + "ENOENT", + "EACCES", + "EISDIR", + "ENOSPC", + "EPERM", + "ENOTDIR", +]); + +/** Returns true for filesystem I/O errors (ENOENT, EACCES, EISDIR, ENOSPC, EPERM, ENOTDIR). */ +export function isFileError(err: Error): boolean { + const code = (err as NodeJS.ErrnoException).code; + return typeof code === "string" && FILE_ERROR_CODES.has(code); +} + +const NETWORK_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENOTFOUND", + "EPIPE", + "EAI_AGAIN", +]); + +/** Returns true for network/fetch errors (connection refused, reset, timeout, DNS, AbortError, "fetch failed"). */ +export function isNetworkError(err: Error): boolean { + const code = (err as NodeJS.ErrnoException).code; + if (typeof code === "string" && NETWORK_ERROR_CODES.has(code)) { + return true; + } + if (err.name === "AbortError" || err.name === "TimeoutError") { + return true; + } + // Bun throws TypeError on fetch failures; also match common error message patterns + if (err.name === "TypeError" && /fetch|network|socket/i.test(err.message)) { + return true; + } + const msg = err.message.toLowerCase(); + return ( + msg.includes("fetch failed") || + msg.includes("network error") || + msg.includes("econnrefused") || + msg.includes("econnreset") + ); +} + +/** Returns true for operational errors (file I/O + network) — safe broad default for non-fatal catches. */ +export function isOperationalError(err: Error): boolean { + return isFileError(err) || isNetworkError(err); +}