mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
7444c3bbc6
commit
3fd17e3d1d
15 changed files with 645 additions and 163 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.16.1",
|
||||
"version": "0.16.2",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
326
packages/cli/src/__tests__/result-helpers.test.ts
Normal file
326
packages/cli/src/__tests__/result-helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue