mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
dc489fa652
commit
3911b5bc28
27 changed files with 148 additions and 29 deletions
5
bun.lock
5
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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ─────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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/";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
48
packages/cli/src/shared/parse.ts
Normal file
48
packages/cli/src/shared/parse.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
packages/cli/src/shared/result.ts
Normal file
24
packages/cli/src/shared/result.ts
Normal 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,
|
||||
});
|
||||
45
packages/cli/src/shared/type-guards.ts
Normal file
45
packages/cli/src/shared/type-guards.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue