refactor: resolve conflicts — merge packages/shared into packages/cli/src/shared (#2092)

Rebased fix/issue-2083 onto main after commands.ts split (PR #2095).
Key resolutions:
- commands.ts: kept HEAD shim (re-exports from ./commands/index.ts)
- package.json: kept PR version 0.12.0 without @openrouter/spawn-shared dep
- Fixed @openrouter/spawn-shared imports in commands/shared.ts, commands/update.ts,
  and __tests__/orchestrate.test.ts that were added after the PR branched

All 1390 tests pass, biome lint clean.

Agent: pr-maintainer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-01 22:05:41 -08:00 committed by GitHub
parent dc489fa652
commit 3911b5bc28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 148 additions and 29 deletions

View file

@ -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",
},

View file

@ -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"
},

View file

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

View file

@ -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).

View file

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

View file

@ -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).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
text: string,
schema: T,
): v.InferOutput<T> | 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<string, unknown> or null.
* Rejects non-object results (arrays, primitives).
* Use for API responses that are always a JSON object.
*/
export function parseJsonObj(text: string): Record<string, unknown> | null {
try {
const val = JSON.parse(text);
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
return val;
}
return null;
} catch {
return null;
}
}

View file

@ -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<T> =
| {
ok: true;
data: T;
}
| {
ok: false;
error: Error;
};
export const Ok = <T>(data: T): Result<T> => ({
ok: true,
data,
});
export const Err = <T>(error: Error): Result<T> => ({
ok: false,
error,
});

View file

@ -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<string, unknown> or return null.
*/
export function toRecord(val: unknown): Record<string, unknown> | null {
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
return val satisfies Record<string, unknown>;
}
return null;
}
/**
* Safely narrow an unknown value to an array of Record<string, unknown>.
* Filters out non-object items.
*/
export function toObjectArray(val: unknown): Record<string, unknown>[] {
if (!Array.isArray(val)) {
return [];
}
return val.filter(
(item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item),
);
}

View file

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

View file

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

View file

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