From 36582b3b954b2fe157b8a3a2e99725da35492e62 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 8 Mar 2026 07:45:11 -0700 Subject: [PATCH] refactor: deduplicate getErrorMessage into shared/type-guards.ts (#2343) Moves getErrorMessage to zero-dep shared module, eliminating 13 inline copies and 2 hasMessage variant sites across the codebase. Fixes #2341 Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/aws/main.ts | 4 ++-- packages/cli/src/commands/shared.ts | 7 ++----- packages/cli/src/digitalocean/main.ts | 4 ++-- packages/cli/src/gcp/main.ts | 4 ++-- packages/cli/src/hetzner/main.ts | 4 ++-- packages/cli/src/history.ts | 7 ++----- packages/cli/src/index.ts | 16 ++++++---------- packages/cli/src/local/main.ts | 4 ++-- packages/cli/src/manifest.ts | 5 ++--- packages/cli/src/shared/agent-setup.ts | 4 ++-- packages/cli/src/shared/type-guards.ts | 8 ++++++++ packages/cli/src/sprite/main.ts | 4 ++-- packages/cli/src/sprite/sprite.ts | 4 ++-- 14 files changed, 37 insertions(+), 40 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b5c80fd..f45b8520 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.18", + "version": "0.15.19", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/aws/main.ts b/packages/cli/src/aws/main.ts index 88258f6b..ed81a812 100644 --- a/packages/cli/src/aws/main.ts +++ b/packages/cli/src/aws/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { authenticate, @@ -68,7 +69,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 3308b40e..567235c2 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -8,7 +8,7 @@ import * as v from "valibot"; import pkg from "../../package.json" with { type: "json" }; import { agentKeys, cloudKeys, isStaleCache, loadManifest, matrixStatus } from "../manifest.js"; import { validateIdentifier, validatePrompt } from "../security.js"; -import { isString } from "../shared/type-guards.js"; +import { getErrorMessage, isString } from "../shared/type-guards.js"; import { getSpawnCloudConfigPath } from "../shared/ui.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -23,10 +23,7 @@ export const PkgVersionSchema = v.object({ // ── Helpers ────────────────────────────────────────────────────────────────── -export function getErrorMessage(err: unknown): string { - // Use duck typing instead of instanceof to avoid prototype chain issues - return err && typeof err === "object" && "message" in err ? String(err.message) : String(err); -} +export { getErrorMessage }; export function handleCancel(): never { p.outro(pc.dim("Cancelled.")); diff --git a/packages/cli/src/digitalocean/main.ts b/packages/cli/src/digitalocean/main.ts index 16b5dde4..c98ff520 100644 --- a/packages/cli/src/digitalocean/main.ts +++ b/packages/cli/src/digitalocean/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { checkAccountStatus, @@ -83,7 +84,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/gcp/main.ts b/packages/cli/src/gcp/main.ts index 7e75f86c..3a8f5028 100644 --- a/packages/cli/src/gcp/main.ts +++ b/packages/cli/src/gcp/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { authenticate, @@ -72,7 +73,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/hetzner/main.ts b/packages/cli/src/hetzner/main.ts index 1c224b87..35565bdc 100644 --- a/packages/cli/src/hetzner/main.ts +++ b/packages/cli/src/hetzner/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { createServer as createHetznerServer, @@ -66,7 +67,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/history.ts b/packages/cli/src/history.ts index fc2c09d0..4723e103 100644 --- a/packages/cli/src/history.ts +++ b/packages/cli/src/history.ts @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import * as v from "valibot"; import { validateConnectionIP, validateLaunchCmd, validateServerIdentifier, validateUsername } from "./security.js"; -import { isString } from "./shared/type-guards"; +import { getErrorMessage, isString } from "./shared/type-guards"; export interface VMConnection { ip: string; @@ -422,10 +422,7 @@ function mergeLastConnection(): void { } } catch (err) { // Log validation failure and skip merging - // Use duck typing instead of instanceof to avoid prototype chain issues - console.error( - `Warning: Invalid connection data from bash script, skipping merge: ${err && typeof err === "object" && "message" in err ? String(err.message) : String(err)}`, - ); + console.error(`Warning: Invalid connection data from bash script, skipping merge: ${getErrorMessage(err)}`); unlinkSync(connPath); return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2f9227bf..d3c26bac 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,13 +28,13 @@ import { } from "./commands/index.js"; import { expandEqualsFlags, findUnknownFlag } from "./flags.js"; import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js"; +import { getErrorMessage } from "./shared/type-guards.js"; import { checkForUpdates } from "./update-check.js"; const VERSION = pkg.version; function handleError(err: unknown): never { - // Use duck typing instead of instanceof to avoid prototype chain issues - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); + const msg = getErrorMessage(err); console.error(pc.red(`Error: ${msg}`)); console.error(`\nRun ${pc.cyan("spawn help")} for usage information.`); process.exit(1); @@ -302,8 +302,7 @@ function handlePromptFileError(promptFile: string, err: unknown): never { console.error(pc.red(`'${promptFile}' is a directory, not a file.`)); console.error("\nProvide a path to a text file containing your prompt."); } else { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - console.error(pc.red(`Error reading prompt file '${promptFile}': ${msg}`)); + console.error(pc.red(`Error reading prompt file '${promptFile}': ${getErrorMessage(err)}`)); } process.exit(1); } @@ -316,8 +315,7 @@ async function readPromptFile(promptFile: string): Promise { try { validatePromptFilePath(promptFile); } catch (err) { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - console.error(pc.red(msg)); + console.error(pc.red(getErrorMessage(err))); process.exit(1); } @@ -329,8 +327,7 @@ async function readPromptFile(promptFile: string): Promise { if (code === "ENOENT" || code === "EACCES" || code === "EISDIR") { handlePromptFileError(promptFile, err); } - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - console.error(pc.red(msg)); + console.error(pc.red(getErrorMessage(err))); process.exit(1); } @@ -898,12 +895,11 @@ async function main(): Promise { } } catch (err) { if (effectiveHeadless && outputFormat === "json") { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); console.log( JSON.stringify({ status: "error", error_code: "UNEXPECTED_ERROR", - error_message: msg, + error_message: getErrorMessage(err), }), ); process.exit(1); diff --git a/packages/cli/src/local/main.ts b/packages/cli/src/local/main.ts index 0e44c65c..101c7ec6 100644 --- a/packages/cli/src/local/main.ts +++ b/packages/cli/src/local/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { interactiveSession, runLocal, saveLocalConnection, uploadFile } from "./local"; @@ -57,7 +58,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index 32c821ed..164917e7 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 { homedir } from "node:os"; import { join } from "node:path"; +import { getErrorMessage } from "./shared/type-guards.js"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -93,9 +94,7 @@ function cacheAge(): number { } function logError(message: string, err?: unknown): void { - // Use duck typing instead of instanceof to avoid prototype chain issues - const errMsg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - console.error(err ? `${message}: ${errMsg}` : message); + console.error(err ? `${message}: ${getErrorMessage(err)}` : message); } function readCache(): Manifest | null { diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index c0172e72..83ade5ba 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -7,7 +7,7 @@ import type { Result } from "./ui"; import { unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { hasMessage } from "./type-guards"; +import { getErrorMessage } from "./type-guards"; import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui"; /** @@ -21,7 +21,7 @@ export async function wrapSshCall(op: Promise): Promise> { await op; return Ok(undefined); } catch (err) { - const msg = hasMessage(err) ? err.message : String(err); + const msg = getErrorMessage(err); // Timeouts are NOT retryable — the command may have completed on the // remote but we lost the connection before seeing the exit code. if (msg.includes("timed out") || msg.includes("timeout")) { diff --git a/packages/cli/src/shared/type-guards.ts b/packages/cli/src/shared/type-guards.ts index 3b90f6a3..dc97ffb9 100644 --- a/packages/cli/src/shared/type-guards.ts +++ b/packages/cli/src/shared/type-guards.ts @@ -21,6 +21,14 @@ export function hasMessage(err: unknown): err is { return err !== null && typeof err === "object" && "message" in err && typeof err.message === "string"; } +/** + * Extract a human-readable error message from an unknown caught value. + * Uses duck-typing instead of instanceof to avoid prototype chain issues. + */ +export function getErrorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String(err.message) : String(err); +} + /** * Safely narrow an unknown value to a Record or return null. */ diff --git a/packages/cli/src/sprite/main.ts b/packages/cli/src/sprite/main.ts index 77f2dcc2..124ba20d 100644 --- a/packages/cli/src/sprite/main.ts +++ b/packages/cli/src/sprite/main.ts @@ -6,6 +6,7 @@ import type { CloudOrchestrator } from "../shared/orchestrate"; import { saveLaunchCmd } from "../history.js"; import { runOrchestration } from "../shared/orchestrate"; +import { getErrorMessage } from "../shared/type-guards.js"; import { agents, resolveAgent } from "./agents"; import { createSprite, @@ -61,7 +62,6 @@ async function main() { } main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); + process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`); process.exit(1); }); diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index ab93ed77..230c02db 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { saveVmConnection as saveVmConnectionToHistory } from "../history.js"; import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh"; -import { hasMessage } from "../shared/type-guards"; +import { getErrorMessage } from "../shared/type-guards"; import { defaultSpawnName, logError, @@ -72,7 +72,7 @@ async function spriteRetry(desc: string, fn: () => Promise): Promise { return await fn(); } catch (err) { lastError = err; - const msg = hasMessage(err) ? err.message : String(err); + const msg = getErrorMessage(err); if (attempt >= maxRetries) { break;