mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-11 05:00:07 +00:00
Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
import "./unicode-detect.js"; // Ensure TERM is set before using symbols
|
|
import { execSync as nodeExecSync, execFileSync as nodeExecFileSync, type ExecSyncOptions, type ExecFileSyncOptions } from "child_process";
|
|
import pc from "picocolors";
|
|
import pkg from "../package.json" with { type: "json" };
|
|
import { RAW_BASE } from "./manifest.js";
|
|
|
|
const VERSION = pkg.version;
|
|
|
|
// Internal executor for testability - can be replaced in tests
|
|
export const executor = {
|
|
execSync: (cmd: string, options?: ExecSyncOptions) => nodeExecSync(cmd, options),
|
|
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptions) => nodeExecFileSync(file, args, options),
|
|
};
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
|
|
const FETCH_TIMEOUT = 5000; // 5 seconds
|
|
|
|
// 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";
|
|
const CROSS_MARK = isAscii ? "x" : "\u2717";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
async function fetchLatestVersion(): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(`${RAW_BASE}/cli/package.json`, {
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
});
|
|
if (!res.ok) return null;
|
|
|
|
const pkg = (await res.json()) as { version: string };
|
|
return pkg.version;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareVersions(current: string, latest: string): boolean {
|
|
// Simple semantic version comparison (assumes format: major.minor.patch)
|
|
const parseSemver = (v: string): number[] =>
|
|
v.split(".").map((n) => parseInt(n, 10) || 0);
|
|
|
|
const currentParts = parseSemver(current);
|
|
const latestParts = parseSemver(latest);
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((latestParts[i] || 0) > (currentParts[i] || 0)) return true;
|
|
if ((latestParts[i] || 0) < (currentParts[i] || 0)) return false;
|
|
}
|
|
|
|
return false; // Versions are equal
|
|
}
|
|
|
|
/** Print boxed update banner to stderr */
|
|
function printUpdateBanner(latestVersion: string): void {
|
|
const line1 = `Update available: v${VERSION} -> v${latestVersion}`;
|
|
const line2 = "Updating automatically...";
|
|
const width = Math.max(line1.length, line2.length) + 4;
|
|
const border = "+" + "-".repeat(width) + "+";
|
|
|
|
console.error(); // Use stderr so it doesn't interfere with parseable output
|
|
console.error(pc.yellow(border));
|
|
console.error(
|
|
pc.yellow("| ") +
|
|
pc.bold(`Update available: v${VERSION} -> `) +
|
|
pc.green(pc.bold(`v${latestVersion}`)) +
|
|
" ".repeat(width - 2 - line1.length) +
|
|
pc.yellow(" |")
|
|
);
|
|
console.error(
|
|
pc.yellow("| ") +
|
|
pc.bold(line2) +
|
|
" ".repeat(width - 2 - line2.length) +
|
|
pc.yellow(" |")
|
|
);
|
|
console.error(pc.yellow(border));
|
|
console.error();
|
|
}
|
|
|
|
/**
|
|
* Find the spawn binary to re-exec after an update.
|
|
*
|
|
* Prefers `which spawn` (PATH resolution) over process.argv[1] because the
|
|
* installer may place the new binary in a different directory than where the
|
|
* currently running binary lives, causing re-exec to run the stale old binary.
|
|
*/
|
|
function findUpdatedBinary(): string {
|
|
try {
|
|
const result = executor.execSync("which spawn 2>/dev/null", {
|
|
encoding: "utf8",
|
|
shell: "/bin/bash",
|
|
});
|
|
const found = result ? result.toString().trim() : "";
|
|
if (found) return found;
|
|
} catch {
|
|
// fall through to argv fallback
|
|
}
|
|
return process.argv[1] || "spawn";
|
|
}
|
|
|
|
/** Re-exec the updated binary with the original CLI arguments, forwarding the exit code */
|
|
function reExecWithArgs(): void {
|
|
const args = process.argv.slice(2);
|
|
const binPath = findUpdatedBinary();
|
|
|
|
if (args.length === 0) {
|
|
console.error(pc.dim(" Restarting spawn with updated version..."));
|
|
} else {
|
|
console.error(pc.dim(` Rerunning: spawn ${args.join(" ")}`));
|
|
}
|
|
console.error();
|
|
|
|
try {
|
|
executor.execFileSync(binPath, args, {
|
|
stdio: "inherit",
|
|
env: { ...process.env, SPAWN_NO_UPDATE_CHECK: "1" },
|
|
});
|
|
process.exit(0);
|
|
} catch (reexecErr) {
|
|
const code = reexecErr && typeof reexecErr === "object" && "status" in reexecErr
|
|
? (reexecErr as { status: number }).status
|
|
: 1;
|
|
process.exit(code);
|
|
}
|
|
}
|
|
|
|
function performAutoUpdate(latestVersion: string): void {
|
|
printUpdateBanner(latestVersion);
|
|
|
|
try {
|
|
executor.execSync(`curl -fsSL ${RAW_BASE}/cli/install.sh | bash`, {
|
|
stdio: "inherit",
|
|
shell: "/bin/bash",
|
|
});
|
|
|
|
console.error();
|
|
console.error(pc.green(pc.bold(`${CHECK_MARK} Updated successfully!`)));
|
|
reExecWithArgs();
|
|
} catch {
|
|
console.error();
|
|
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}/cli/install.sh | bash`));
|
|
console.error();
|
|
// Continue with original command despite update failure
|
|
}
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check for updates on every run and auto-update if available.
|
|
* Uses a 5-second timeout to avoid blocking for too long.
|
|
*/
|
|
export async function checkForUpdates(): Promise<void> {
|
|
// Skip in test environment
|
|
if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
|
|
return;
|
|
}
|
|
|
|
// Skip if SPAWN_NO_UPDATE_CHECK is set
|
|
if (process.env.SPAWN_NO_UPDATE_CHECK === "1") {
|
|
return;
|
|
}
|
|
|
|
// Always fetch the latest version on every run
|
|
try {
|
|
const latestVersion = await fetchLatestVersion();
|
|
if (!latestVersion) return;
|
|
|
|
// Auto-update if newer version is available
|
|
if (compareVersions(VERSION, latestVersion)) {
|
|
performAutoUpdate(latestVersion);
|
|
}
|
|
} catch {
|
|
// Silently fail - update check is non-critical
|
|
}
|
|
}
|