feat: migrate to openrouter.ai/labs/spawn CDN + release artifact version checks (#2178)

* feat: migrate shell script URLs to openrouter.ai/labs/spawn CDN

Users on older CLI versions can't auto-update because the repo was restructured
(cli/ → packages/cli/), so old version-check URLs 404. This decouples the CLI
from the repo's internal directory structure:

- Shell script URLs (install, agent scripts, github-auth) now use
  openrouter.ai/labs/spawn/* as primary with GitHub raw as fallback
- Version checks now use GitHub release artifact (cli-latest/version)
  as primary — a static URL that never changes regardless of repo layout
- CI workflow updated to publish a `version` file alongside cli.js
- Remove GITHUB_RAW_URL_PATTERN validation (no longer needed since
  install URL is now a hardcoded CDN string, not interpolated)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix biome formatting in update-check test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: CLAUDE.md says biome lint but should say biome check

biome lint only checks lint rules, not formatting. biome check does both.
The hooks and CI already run biome check — the docs were out of sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(hooks): PostToolUse hook wasn't running biome on CLI source files

Two bugs in validate-file.ts:

1. Config search only checked 1-2 levels up from the edited file, but
   biome.json is at packages/cli/ — 3 levels above src/__tests__/*.ts.
   Fix: walk up directories until biome.json is found (or hit root).

2. Ran `biome format` (prints formatted output, always exits 0) instead
   of `biome format --check` (exits non-zero if file needs formatting).
   Fix: use `biome check` which does lint + format check in one pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L 2026-03-04 02:34:58 -05:00 committed by GitHub
parent 67443aa4b3
commit 61bcedc0eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 95 additions and 149 deletions

View file

@ -18,7 +18,9 @@ macOS ships bash 3.2. All scripts MUST work on it:
## Conventions
- `#!/bin/bash` + `set -eo pipefail` (no `u` flag)
- Use `${VAR:-}` for all optional env var checks (`OPENROUTER_API_KEY`, cloud tokens, etc.)
- Remote fallback URL: `https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/{path}` (shell scripts are under `sh/`, e.g., `sh/{cloud}/{agent}.sh`)
- Primary script URL: `https://openrouter.ai/labs/spawn/{path}` (CDN proxy, maps to repo `sh/`, e.g., `{cloud}/{agent}.sh`)
- Fallback script URL: `https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/{path}`
- Version check URL: `https://github.com/OpenRouterTeam/spawn/releases/download/cli-latest/version` (GitHub release artifact)
- All env vars documented in the cloud's `sh/{cloud}/README.md`
## Use Bun + TypeScript for Inline Scripting — NEVER python/python3

View file

@ -89,24 +89,26 @@ if (file.endsWith(".ts")) {
}
}
// Find biome config
const dir = dirname(file);
// Find biome config by walking up from the file's directory to the repo root
let biomeDir: string | null = null;
if (existsSync(resolve(dir, "biome.json")) || existsSync(resolve(dir, "biome.jsonc"))) {
biomeDir = dir;
} else if (existsSync(resolve(dir, "..", "biome.json")) || existsSync(resolve(dir, "..", "biome.jsonc"))) {
biomeDir = resolve(dir, "..");
let searchDir = dirname(file);
const root = resolve("/");
while (searchDir !== root) {
if (existsSync(resolve(searchDir, "biome.json")) || existsSync(resolve(searchDir, "biome.jsonc"))) {
biomeDir = searchDir;
break;
}
searchDir = resolve(searchDir, "..");
}
if (biomeDir) {
// Run biome lint
// Run biome check (lint + format) in a single pass
try {
run(
"bunx",
[
"@biomejs/biome",
"lint",
"check",
file,
],
{
@ -115,25 +117,7 @@ if (file.endsWith(".ts")) {
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
fail(`BIOME LINT FAILED for ${file}\n${msg}`);
}
// Run biome format
try {
run(
"bunx",
[
"@biomejs/biome",
"format",
file,
],
{
cwd: biomeDir,
},
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
fail(`BIOME FORMAT FAILED for ${file}\n${msg}`);
fail(`BIOME CHECK FAILED for ${file}\n${msg}`);
}
}
}

View file

@ -41,6 +41,10 @@ jobs:
working-directory: packages/cli
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- name: Create version file
working-directory: packages/cli
run: jq -r .version package.json > version
- name: Update cli-latest release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -50,17 +54,19 @@ jobs:
git tag -d cli-latest 2>/dev/null || true
git push origin :refs/tags/cli-latest 2>/dev/null || true
# Create new release with built cli.js
# Create new release with built cli.js and version file
gh release create cli-latest \
--title "CLI v${{ steps.version.outputs.version }}" \
--notes "Pre-built CLI binary (auto-updated on every push to main).
This release is used as a fallback by \`install.sh\` when the local build fails (e.g. Termux proot).
The \`version\` file is used by the CLI's auto-update check.
**Version:** ${{ steps.version.outputs.version }}
**Built:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--prerelease \
packages/cli/cli.js
packages/cli/cli.js \
packages/cli/version
- name: Upload cloud bundles
env:

View file

@ -67,7 +67,7 @@ All cloud provisioning and agent setup logic lives in TypeScript under `packages
## After Each Change
1. `bash -n {file}` syntax check on all modified scripts
2. `cd packages/cli && bunx @biomejs/biome lint src/` — **must pass with zero errors** on all modified TypeScript
2. `cd packages/cli && bunx @biomejs/biome check src/` — **must pass with zero errors** (lint + format) on all modified TypeScript
3. Update `manifest.json` matrix status to `"implemented"`
4. Update the cloud's `sh/{cloud}/README.md` with usage instructions
5. Commit with a descriptive message

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.12.14",
"version": "0.12.15",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -78,12 +78,8 @@ describe("cmdUpdate", () => {
it("should report up-to-date when remote version matches current", async () => {
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("package.json")) {
return new Response(
JSON.stringify({
version: VERSION,
}),
);
if (isString(url) && url.includes("/version")) {
return new Response(`${VERSION}\n`);
}
return new Response("Not Found", {
status: 404,
@ -101,12 +97,8 @@ describe("cmdUpdate", () => {
it("should report available update when remote version differs", async () => {
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("package.json")) {
return new Response(
JSON.stringify({
version: "99.99.99",
}),
);
if (isString(url) && url.includes("/version")) {
return new Response("99.99.99\n");
}
return new Response("Not Found", {
status: 404,
@ -154,12 +146,8 @@ describe("cmdUpdate", () => {
it("should handle update failure gracefully", async () => {
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("package.json")) {
return new Response(
JSON.stringify({
version: "99.99.99",
}),
);
if (isString(url) && url.includes("/version")) {
return new Response("99.99.99\n");
}
return new Response("Not Found", {
status: 404,
@ -193,12 +181,8 @@ describe("cmdUpdate", () => {
it("should show version in spinner stop during update", async () => {
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("package.json")) {
return new Response(
JSON.stringify({
version: "2.0.0",
}),
);
if (isString(url) && url.includes("/version")) {
return new Response("2.0.0\n");
}
return new Response("Error", {
status: 500,

View file

@ -77,15 +77,7 @@ describe("update-check", () => {
});
it("should check for updates on every run", async () => {
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to prevent actual update + re-exec
@ -101,15 +93,7 @@ describe("update-check", () => {
});
it("should auto-update when newer version is available", async () => {
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to prevent actual update + re-exec
@ -137,15 +121,7 @@ describe("update-check", () => {
});
it("should not update when up to date", async () => {
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "0.2.3",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("0.2.3\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock executor to prevent actual commands
@ -177,15 +153,7 @@ describe("update-check", () => {
});
it("should handle update failures gracefully", async () => {
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execFileSync to throw an error (curl fetch fails)
@ -237,15 +205,7 @@ describe("update-check", () => {
"sprite",
];
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -308,15 +268,7 @@ describe("update-check", () => {
"sprite",
];
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");
@ -351,15 +303,7 @@ describe("update-check", () => {
"/usr/local/bin/spawn",
];
const mockFetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
version: "99.0.0",
}),
),
),
);
const mockFetch = mock(() => Promise.resolve(new Response("99.0.0\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { executor } = await import("../update-check.js");

View file

@ -1,5 +1,5 @@
import pc from "picocolors";
import { RAW_BASE, REPO } from "../manifest.js";
import { SPAWN_CDN, REPO } from "../manifest.js";
function getHelpUsageSection(): string {
return `${pc.bold("USAGE")}
@ -69,7 +69,7 @@ function getHelpAuthSection(): string {
function getHelpInstallSection(): string {
return `${pc.bold("INSTALL")}
curl -fsSL ${RAW_BASE}/sh/cli/install.sh | bash`;
curl -fsSL ${SPAWN_CDN}/cli/install.sh | bash`;
}
function getHelpTroubleshootingSection(): string {

View file

@ -4,7 +4,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import type { Manifest } from "../manifest.js";
import { loadManifest, RAW_BASE, REPO } from "../manifest.js";
import { loadManifest, SPAWN_CDN, RAW_BASE, REPO } from "../manifest.js";
import {
validateIdentifier,
validateScriptContent,
@ -176,7 +176,7 @@ function showDryRunPreview(manifest: Manifest, agent: string, cloud: string, pro
printDryRunSection("Agent", buildAgentLines(manifest.agents[agent]));
printDryRunSection("Cloud", buildCloudLines(manifest.clouds[cloud]));
printDryRunSection("Script", [
` URL: ${RAW_BASE}/sh/${cloud}/${agent}.sh`,
` URL: ${SPAWN_CDN}/${cloud}/${agent}.sh`,
]);
const envLines = buildEnvironmentLines(manifest, agent);

View file

@ -1,12 +1,28 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { parseJsonWith } from "../shared/parse.js";
import { RAW_BASE } from "../manifest.js";
import { SPAWN_CDN, VERSION_URL, RAW_BASE } from "../manifest.js";
import { VERSION, PkgVersionSchema, getErrorMessage } from "./shared.js";
const INSTALL_CMD = `curl -fsSL ${RAW_BASE}/sh/cli/install.sh | bash`;
const INSTALL_CMD = `curl -fsSL ${SPAWN_CDN}/cli/install.sh | bash`;
async function fetchRemoteVersion(): Promise<string> {
// Primary: plain-text version file from GitHub release artifact (static URL)
try {
const res = await fetch(VERSION_URL, {
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
const text = (await res.text()).trim();
if (text && /^\d+\.\d+\.\d+/.test(text)) {
return text;
}
}
} catch {
// Fall through to GitHub raw fallback
}
// Fallback: package.json from GitHub raw
const res = await fetch(`${RAW_BASE}/packages/cli/package.json`, {
signal: AbortSignal.timeout(10_000),
});

View file

@ -66,6 +66,10 @@ export interface Manifest {
const REPO = "OpenRouterTeam/spawn";
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main` as const;
/** Primary CDN for shell scripts — maps openrouter.ai/labs/spawn/* → repo sh/* */
const SPAWN_CDN = "https://openrouter.ai/labs/spawn" as const;
/** Static URL for version checks — GitHub release artifact, never changes with repo structure */
const VERSION_URL = `https://github.com/${REPO}/releases/download/cli-latest/version` as const;
// Dynamic getters so tests can override XDG_CACHE_HOME at runtime
function getCacheDir(): string {
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "spawn");
@ -311,4 +315,4 @@ export function _resetCacheForTesting(): void {
_staleCache = false;
}
export { RAW_BASE, REPO, stripDangerousKeys };
export { RAW_BASE, REPO, SPAWN_CDN, VERSION_URL, stripDangerousKeys };

View file

@ -208,7 +208,7 @@ export async function offerGithubAuth(runner: CloudRunner): Promise<void> {
return;
}
let ghCmd = "curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/shared/github-auth.sh | bash";
let ghCmd = "curl -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash";
let localTmpFile = "";
if (githubToken) {
const escaped = githubToken.replace(/'/g, "'\\''");

View file

@ -9,7 +9,7 @@ import * as v from "valibot";
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";
import { SPAWN_CDN, VERSION_URL, RAW_BASE } from "./manifest.js";
const VERSION = pkg.version;
@ -29,13 +29,6 @@ const PkgVersionSchema = v.object({
version: v.string(),
});
// Validate RAW_BASE matches expected GitHub raw content URL pattern (defense-in-depth, CWE-78)
const GITHUB_RAW_URL_PATTERN =
/^https:\/\/raw\.githubusercontent\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
if (!GITHUB_RAW_URL_PATTERN.test(RAW_BASE)) {
throw new Error(`RAW_BASE URL does not match expected GitHub raw URL pattern: ${RAW_BASE}`);
}
// Use ASCII-safe symbols when unicode is disabled (SSH, dumb terminals)
const isAscii = process.env.TERM === "linux";
const CHECK_MARK = isAscii ? "*" : "\u2713";
@ -44,6 +37,22 @@ const CROSS_MARK = isAscii ? "x" : "\u2717";
// ── Helpers ────────────────────────────────────────────────────────────────────
async function fetchLatestVersion(): Promise<string | null> {
// Primary: plain-text version file from GitHub release artifact (static URL)
try {
const res = await fetch(VERSION_URL, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
if (res.ok) {
const text = (await res.text()).trim();
if (text && /^\d+\.\d+\.\d+/.test(text)) {
return text;
}
}
} catch {
// Fall through to GitHub raw fallback
}
// Fallback: package.json from GitHub raw
try {
const res = await fetch(`${RAW_BASE}/packages/cli/package.json`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
@ -51,7 +60,6 @@ async function fetchLatestVersion(): Promise<string | null> {
if (!res.ok) {
return null;
}
const data = parseJsonWith(await res.text(), PkgVersionSchema);
return data?.version ?? null;
} catch {
@ -202,19 +210,16 @@ function reExecWithArgs(): void {
function performAutoUpdate(latestVersion: string): void {
printUpdateBanner(latestVersion);
// Validate RAW_BASE immediately before use to prevent command injection (CWE-78, #1819)
if (!GITHUB_RAW_URL_PATTERN.test(RAW_BASE)) {
throw new Error(`Security: RAW_BASE failed pre-execution validation: ${RAW_BASE}`);
}
// Hardcoded CDN URL — no variable interpolation, eliminates CWE-78 concern entirely
const installUrl = `${SPAWN_CDN}/cli/install.sh`;
try {
// Two-step approach: fetch script bytes with curl, then execute via bash -c
// This eliminates shell interpolation of RAW_BASE entirely (CWE-78, #2161)
const scriptBytes = executor.execFileSync(
"curl",
[
"-fsSL",
`${RAW_BASE}/sh/cli/install.sh`,
installUrl,
],
{
encoding: "utf8",
@ -247,7 +252,7 @@ function performAutoUpdate(latestVersion: string): void {
console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));
console.error(pc.dim(" Please update manually:"));
console.error();
console.error(pc.cyan(` curl -fsSL ${RAW_BASE}/sh/cli/install.sh | bash`));
console.error(pc.cyan(` curl -fsSL ${installUrl} | bash`));
console.error();
// Continue with original command despite update failure
}

View file

@ -1,10 +1,10 @@
# Spawn CLI installer for Windows PowerShell
#
# Usage (PowerShell):
# irm https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/cli/install.ps1 | iex
# irm https://openrouter.ai/labs/spawn/cli/install.ps1 | iex
#
# Or download and run:
# Invoke-WebRequest -Uri https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/cli/install.ps1 -OutFile install.ps1
# Invoke-WebRequest -Uri https://openrouter.ai/labs/spawn/cli/install.ps1 -OutFile install.ps1
# .\install.ps1
#
# Override install directory:

View file

@ -2,7 +2,7 @@
# Installer for the spawn CLI
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/cli/install.sh | bash
# curl -fsSL https://openrouter.ai/labs/spawn/cli/install.sh | bash
#
# This installs spawn via bun. If bun is not available, it auto-installs it first.
#
@ -12,6 +12,7 @@
set -eo pipefail
SPAWN_REPO="OpenRouterTeam/spawn"
SPAWN_CDN="https://openrouter.ai/labs/spawn"
SPAWN_RAW_BASE="https://raw.githubusercontent.com/${SPAWN_REPO}/main"
MIN_BUN_VERSION="1.2.0"
@ -62,7 +63,7 @@ ensure_min_bun_version() {
echo " bun upgrade"
echo ""
echo "Then re-run:"
echo " curl -fsSL ${SPAWN_RAW_BASE}/sh/cli/install.sh | bash"
echo " curl -fsSL ${SPAWN_CDN}/cli/install.sh | bash"
exit 1
fi
log_info "bun upgraded to ${current}"
@ -238,7 +239,7 @@ if ! command -v bun &>/dev/null; then
echo " curl -fsSL https://bun.sh/install | bash"
echo ""
echo "Then reopen your terminal and re-run:"
echo " curl -fsSL ${SPAWN_RAW_BASE}/sh/cli/install.sh | bash"
echo " curl -fsSL ${SPAWN_CDN}/cli/install.sh | bash"
exit 1
fi