refactor: replace indiscriminate try/catch with guarded Result helpers (#2477)

Add tryCatchIf/asyncTryCatchIf with error predicates (isFileError,
isNetworkError, isOperationalError) so operational errors are handled
explicitly while programming bugs (TypeError, ReferenceError) propagate
and crash visibly instead of being silently swallowed.

Transforms ~40 try/catch blocks across 14 files:
- File I/O (manifest cache, config loading, history) → tryCatchIf(isFileError)
- Network/fetch (API calls, version checks, OAuth) → asyncTryCatchIf(isNetworkError)
- SSH/subprocess (agent setup, tunnel) → asyncTryCatchIf(isOperationalError)
- API retry loops (DO, Hetzner) → guard retries with isNetworkError

Intentionally keeps ~85 try/catch blocks as-is (cleanup/finally, retry
loops, user-facing error handlers, catch-classify-rethrow patterns).

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-03-10 18:55:07 -07:00 committed by GitHub
parent 7444c3bbc6
commit 3fd17e3d1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 645 additions and 163 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.16.1",
"version": "0.16.2",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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<number>(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<string, unknown> | 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);
});
});

View file

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

View file

@ -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<string, unknown> | 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<string, unknown>): Promise<void> {
@ -234,12 +235,13 @@ async function testDoToken(): Promise<boolean> {
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,
);
}
/**

View file

@ -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<boolean> {
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 ──────────────────────────────────────────────────────────

View file

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

View file

@ -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<typeof statSync> = 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<typeof statSync> = 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<Manifest | null> {
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<Manifest | null> {
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<Manifest> {

View file

@ -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<void> {
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<void> {
],
},
);
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<void> {
}
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<void> {
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<void> {
// 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<void
" echo '==> 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");
}
}

View file

@ -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<boolean> {
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<boolean> {
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<void> {
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<void> {
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 ────────────────────────────────────────────────

View file

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

View file

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

View file

@ -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<T>(
* 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). */

View file

@ -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<string | null> {
// 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<string | null> {
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<string | null> {
}
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 */

View file

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

View file

@ -31,3 +31,107 @@ export function tryCatch<T>(fn: () => T): Result<T> {
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<T>(fn: () => Promise<T>): Promise<Result<T>> {
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<T>(guard: (err: Error) => boolean, fn: () => T): Result<T> {
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<T>(guard: (err: Error) => boolean, fn: () => Promise<T>): Promise<Result<T>> {
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<T>(result: Result<T>, fallback: T): T {
return result.ok ? result.data : fallback;
}
/** Transform the Ok value of a Result, passing Err through unchanged. */
export function mapResult<T, U>(result: Result<T>, fn: (data: T) => U): Result<U> {
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);
}