diff --git a/bun.lock b/bun.lock index 34e0da88..ce26ad22 100644 --- a/bun.lock +++ b/bun.lock @@ -14,13 +14,12 @@ }, "packages/cli": { "name": "@openrouter/spawn", - "version": "0.10.3", + "version": "0.11.24", "bin": { "spawn": "cli.js", }, "dependencies": { "@clack/prompts": "1.0.0", - "@openrouter/spawn-shared": "workspace:*", "picocolors": "1.1.1", "valibot": "1.2.0", }, @@ -31,7 +30,7 @@ }, "packages/shared": { "name": "@openrouter/spawn-shared", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "valibot": "1.2.0", }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c510b07..c86892a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.11.26", + "version": "0.12.0", "type": "module", "bin": { "spawn": "cli.js" @@ -15,7 +15,6 @@ }, "dependencies": { "@clack/prompts": "1.0.0", - "@openrouter/spawn-shared": "workspace:*", "picocolors": "1.1.1", "valibot": "1.2.0" }, diff --git a/packages/cli/src/__tests__/cmd-interactive.test.ts b/packages/cli/src/__tests__/cmd-interactive.test.ts index a1247d26..01897ae8 100644 --- a/packages/cli/src/__tests__/cmd-interactive.test.ts +++ b/packages/cli/src/__tests__/cmd-interactive.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for cmdInteractive() in commands.ts. diff --git a/packages/cli/src/__tests__/cmdrun-duplicate-detection.test.ts b/packages/cli/src/__tests__/cmdrun-duplicate-detection.test.ts index d2147d0e..70341318 100644 --- a/packages/cli/src/__tests__/cmdrun-duplicate-detection.test.ts +++ b/packages/cli/src/__tests__/cmdrun-duplicate-detection.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for the --name duplicate detection feature (issue #1864). diff --git a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts index eb3c7da8..bfc6e420 100644 --- a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts +++ b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for the cmdRun happy-path pipeline: successful download, history diff --git a/packages/cli/src/__tests__/commands-error-paths.test.ts b/packages/cli/src/__tests__/commands-error-paths.test.ts index 567bd4f1..a2f25729 100644 --- a/packages/cli/src/__tests__/commands-error-paths.test.ts +++ b/packages/cli/src/__tests__/commands-error-paths.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for commands.ts error/validation paths that call process.exit(1). diff --git a/packages/cli/src/__tests__/commands-resolve-run.test.ts b/packages/cli/src/__tests__/commands-resolve-run.test.ts index a04c2433..8fc7aa0f 100644 --- a/packages/cli/src/__tests__/commands-resolve-run.test.ts +++ b/packages/cli/src/__tests__/commands-resolve-run.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for cmdRun display-name resolution and validateImplementation diff --git a/packages/cli/src/__tests__/commands-swap-resolve.test.ts b/packages/cli/src/__tests__/commands-swap-resolve.test.ts index b80d081d..335c2f93 100644 --- a/packages/cli/src/__tests__/commands-swap-resolve.test.ts +++ b/packages/cli/src/__tests__/commands-swap-resolve.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for detectAndFixSwappedArgs and resolveAndLog logic in commands.ts. diff --git a/packages/cli/src/__tests__/commands-update-download.test.ts b/packages/cli/src/__tests__/commands-update-download.test.ts index e8851fa8..eb2d5910 100644 --- a/packages/cli/src/__tests__/commands-update-download.test.ts +++ b/packages/cli/src/__tests__/commands-update-download.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; import pkg from "../../package.json" with { type: "json" }; const VERSION = pkg.version; diff --git a/packages/cli/src/__tests__/download-and-failure.test.ts b/packages/cli/src/__tests__/download-and-failure.test.ts index cd2d6957..11d562fe 100644 --- a/packages/cli/src/__tests__/download-and-failure.test.ts +++ b/packages/cli/src/__tests__/download-and-failure.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { createMockManifest, createConsoleMocks, restoreMocks, mockClackPrompts } from "./test-helpers"; import { loadManifest } from "../manifest"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards"; /** * Tests for the download fallback pipeline and script failure reporting diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 5d2dedce..ac346926 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"; -import { isNumber } from "@openrouter/spawn-shared"; +import { isNumber } from "../shared/type-guards.js"; // ── Mock only oauth (needed to avoid interactive prompts) ───────────── diff --git a/packages/cli/src/__tests__/parse.test.ts b/packages/cli/src/__tests__/parse.test.ts index b39092d1..d1ba0cfd 100644 --- a/packages/cli/src/__tests__/parse.test.ts +++ b/packages/cli/src/__tests__/parse.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import * as v from "valibot"; -import { parseJsonWith, parseJsonRaw } from "@openrouter/spawn-shared"; +import { parseJsonWith, parseJsonRaw } from "../shared/parse"; describe("parseJsonWith", () => { const NumberSchema = v.object({ diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 27fa3956..40544413 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -30,7 +30,7 @@ import { } from "../shared/ssh"; import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys"; import * as v from "valibot"; -import { parseJsonWith } from "@openrouter/spawn-shared"; +import { parseJsonWith } from "../shared/parse"; import { saveVmConnection } from "../history.js"; const DASHBOARD_URL = "https://lightsail.aws.amazon.com/"; diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 9d9356a9..619e2a3f 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -2,7 +2,7 @@ import "../unicode-detect.js"; // Must be first: configures TERM before clack re import * as p from "@clack/prompts"; import pc from "picocolors"; import * as v from "valibot"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "../shared/type-guards.js"; import * as fs from "node:fs"; import type { Manifest } from "../manifest.js"; import { loadManifest, agentKeys, cloudKeys, matrixStatus, isStaleCache } from "../manifest.js"; diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index b5b75642..f14108df 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,6 +1,6 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; -import { parseJsonWith } from "@openrouter/spawn-shared"; +import { parseJsonWith } from "../shared/parse.js"; import { RAW_BASE } from "../manifest.js"; import { VERSION, PkgVersionSchema, getErrorMessage } from "./shared.js"; diff --git a/packages/cli/src/daytona/daytona.ts b/packages/cli/src/daytona/daytona.ts index 97b8c8ac..bbd5dbb2 100644 --- a/packages/cli/src/daytona/daytona.ts +++ b/packages/cli/src/daytona/daytona.ts @@ -18,7 +18,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; -import { parseJsonObj, isString } from "@openrouter/spawn-shared"; +import { parseJsonObj } from "../shared/parse"; +import { isString } from "../shared/type-guards"; import { saveVmConnection } from "../history.js"; import { sleep, spawnInteractive, killWithTimeout } from "../shared/ssh"; diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index b1752d61..0590baa9 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -19,7 +19,8 @@ import { } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; -import { parseJsonObj, isString, isNumber, toObjectArray } from "@openrouter/spawn-shared"; +import { parseJsonObj } from "../shared/parse"; +import { isString, isNumber, toObjectArray } from "../shared/type-guards"; import { SSH_BASE_OPTS, SSH_INTERACTIVE_OPTS, diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 5d4c337b..be672d5c 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -28,7 +28,8 @@ import { spawnInteractive, } from "../shared/ssh"; import { ensureSshKeys, getSshFingerprint, getSshKeyOpts } from "../shared/ssh-keys"; -import { parseJsonObj, isString, isNumber, toObjectArray, toRecord } from "@openrouter/spawn-shared"; +import { parseJsonObj } from "../shared/parse"; +import { isString, isNumber, toObjectArray, toRecord } from "../shared/type-guards"; import { saveVmConnection } from "../history.js"; const HETZNER_API_BASE = "https://api.hetzner.cloud/v1"; diff --git a/packages/cli/src/history.ts b/packages/cli/src/history.ts index f071c031..325dc768 100644 --- a/packages/cli/src/history.ts +++ b/packages/cli/src/history.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from " import { join, resolve, isAbsolute } from "node:path"; import { homedir } from "node:os"; import { validateConnectionIP, validateUsername, validateServerIdentifier, validateLaunchCmd } from "./security.js"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "./shared/type-guards"; export interface VMConnection { ip: string; diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index efff90f0..3595fd22 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Result } from "./ui"; import { logInfo, logWarn, logError, logStep, prompt, jsonEscape, withRetry, Ok, Err } from "./ui"; -import { hasMessage } from "@openrouter/spawn-shared"; +import { hasMessage } from "./type-guards"; import type { AgentConfig } from "./agents"; /** diff --git a/packages/cli/src/shared/oauth.ts b/packages/cli/src/shared/oauth.ts index 410b975e..a3a82489 100644 --- a/packages/cli/src/shared/oauth.ts +++ b/packages/cli/src/shared/oauth.ts @@ -1,7 +1,7 @@ // shared/oauth.ts — OpenRouter OAuth flow + API key management import * as v from "valibot"; -import { parseJsonWith } from "@openrouter/spawn-shared"; +import { parseJsonWith } from "./parse"; import { logInfo, logWarn, logError, logStep, prompt, openBrowser, validateModelId } from "./ui"; // ─── Schemas ───────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/shared/parse.ts b/packages/cli/src/shared/parse.ts new file mode 100644 index 00000000..06273eb2 --- /dev/null +++ b/packages/cli/src/shared/parse.ts @@ -0,0 +1,48 @@ +// shared/parse.ts — Schema-validated JSON parsing (replaces unsafe `as` casts) + +import * as v from "valibot"; + +/** + * Parse a JSON string and validate it against a valibot schema. + * Returns the validated value, or null if parsing/validation fails. + */ +export function parseJsonWith>>( + text: string, + schema: T, +): v.InferOutput | null { + try { + return v.parse(schema, JSON.parse(text)); + } catch { + return null; + } +} + +/** + * Escape hatch: parse JSON to `unknown` without schema validation. + * Use for dynamic response formats where a fixed schema isn't practical + * (e.g., cloud APIs with 5+ response shapes). + */ +export function parseJsonRaw(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Parse a JSON string and return it as a Record or null. + * Rejects non-object results (arrays, primitives). + * Use for API responses that are always a JSON object. + */ +export function parseJsonObj(text: string): Record | null { + try { + const val = JSON.parse(text); + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + return val; + } + return null; + } catch { + return null; + } +} diff --git a/packages/cli/src/shared/result.ts b/packages/cli/src/shared/result.ts new file mode 100644 index 00000000..0d954c08 --- /dev/null +++ b/packages/cli/src/shared/result.ts @@ -0,0 +1,24 @@ +// shared/result.ts — Lightweight Result monad for retry-aware error handling. +// +// Returning Err() signals a retryable failure; throwing signals a non-retryable one. +// Used with withRetry() so callers decide at the point of failure whether an error +// is retryable (return Err) or fatal (throw), instead of relying on brittle +// error-message pattern matching after the fact. + +export type Result = + | { + ok: true; + data: T; + } + | { + ok: false; + error: Error; + }; +export const Ok = (data: T): Result => ({ + ok: true, + data, +}); +export const Err = (error: Error): Result => ({ + ok: false, + error, +}); diff --git a/packages/cli/src/shared/type-guards.ts b/packages/cli/src/shared/type-guards.ts new file mode 100644 index 00000000..3b90f6a3 --- /dev/null +++ b/packages/cli/src/shared/type-guards.ts @@ -0,0 +1,45 @@ +// shared/type-guards.ts — Runtime type guards (replaces unsafe `as` casts on non-API values) +// biome-ignore-all lint/plugin: type-guard implementations must use raw typeof + +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +export function hasStatus(err: unknown): err is { + status: number; +} { + return err !== null && typeof err === "object" && "status" in err && typeof err.status === "number"; +} + +export function hasMessage(err: unknown): err is { + message: string; +} { + return err !== null && typeof err === "object" && "message" in err && typeof err.message === "string"; +} + +/** + * Safely narrow an unknown value to a Record or return null. + */ +export function toRecord(val: unknown): Record | null { + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + return val satisfies Record; + } + return null; +} + +/** + * Safely narrow an unknown value to an array of Record. + * Filters out non-object items. + */ +export function toObjectArray(val: unknown): Record[] { + if (!Array.isArray(val)) { + return []; + } + return val.filter( + (item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item), + ); +} diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index c969b856..a79e151c 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -4,7 +4,7 @@ import * as p from "@clack/prompts"; import { homedir } from "node:os"; import { join } from "node:path"; -import { isString } from "@openrouter/spawn-shared"; +import { isString } from "./type-guards"; const RED = "\x1b[0;31m"; const GREEN = "\x1b[0;32m"; @@ -173,8 +173,8 @@ export function openBrowser(url: string): void { // ─── Result-based retry ──────────────────────────────────────────────── -import type { Result } from "@openrouter/spawn-shared"; -export { type Result, Ok, Err } from "@openrouter/spawn-shared"; +import type { Result } from "./result"; +export { type Result, Ok, Err } from "./result"; /** * Phase-aware retry helper using the Result monad. diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 19678e17..64702583 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -15,7 +15,7 @@ import { defaultSpawnName, } from "../shared/ui"; import { sleep, spawnInteractive, killWithTimeout } from "../shared/ssh"; -import { hasMessage } from "@openrouter/spawn-shared"; +import { hasMessage } from "../shared/type-guards"; import { getSpawnDir } from "../history.js"; // ─── Configurable Constants ────────────────────────────────────────────────── diff --git a/packages/cli/src/update-check.ts b/packages/cli/src/update-check.ts index bf6cf001..0554fbe3 100644 --- a/packages/cli/src/update-check.ts +++ b/packages/cli/src/update-check.ts @@ -6,7 +6,8 @@ import { homedir } from "node:os"; import path from "node:path"; import pc from "picocolors"; import * as v from "valibot"; -import { parseJsonWith, hasStatus } from "@openrouter/spawn-shared"; +import { parseJsonWith } from "./shared/parse"; +import { hasStatus } from "./shared/type-guards"; import pkg from "../package.json" with { type: "json" }; import { RAW_BASE } from "./manifest.js";