feat: Bump version to 0.2.0 and implement auto-update on run

Changes:
- Bumped version from 0.1.0 to 0.2.0
- Changed update-check mechanism to auto-install updates instead of just notifying
- checkForUpdates() now blocks and runs install.sh automatically when update is available
- Added executor wrapper for testability of execSync calls
- Updated all tests to mock executor.execSync instead of child_process.execSync
- Auto-update runs on every spawn invocation (24-hour cache prevents excessive checks)
- On update failure, shows error message but continues with original command

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Sprite 2026-02-10 06:48:19 +00:00
parent d223038a5e
commit c4d99daaab
5 changed files with 170 additions and 120 deletions

View file

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

View file

@ -48,18 +48,22 @@ function readUpdateCheckCache(): any {
describe("update-check", () => {
let originalEnv: NodeJS.ProcessEnv;
let consoleErrorSpy: ReturnType<typeof spyOn>;
let processExitSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
cleanupTestCache(); // Clean first to ensure fresh state
originalEnv = mockEnv();
setupTestCache();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
// Mock process.exit to prevent tests from exiting
processExitSpy = spyOn(process, "exit").mockImplementation((() => {}) as any);
});
afterEach(() => {
restoreEnv(originalEnv);
cleanupTestCache();
consoleErrorSpy.mockRestore();
processExitSpy.mockRestore();
});
describe("checkForUpdates", () => {
@ -92,19 +96,21 @@ describe("update-check", () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.2.0" }),
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
expect(fetchSpy).toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should not check again within 24 hours", async () => {
@ -112,17 +118,22 @@ describe("update-check", () => {
const now = Math.floor(Date.now() / 1000);
writeUpdateCheckCache({
lastCheck: now - 3600, // 1 hour ago
latestVersion: "0.2.0",
latestVersion: "0.3.0",
});
const fetchSpy = spyOn(global, "fetch");
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should use cache, not fetch
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should check again after 24 hours", async () => {
@ -136,22 +147,57 @@ describe("update-check", () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.2.0" }),
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
expect(fetchSpy).toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should show notification for newer version", async () => {
it("should auto-update when newer version is available", async () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should have printed update message to stderr
expect(consoleErrorSpy).toHaveBeenCalled();
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("0.3.0");
expect(output).toContain("Updating automatically");
// Should have run the install script
expect(execSyncSpy).toHaveBeenCalled();
// Should have exited
expect(processExitSpy).toHaveBeenCalledWith(0);
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should not update when up to date", async () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
@ -160,40 +206,19 @@ describe("update-check", () => {
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have printed notification to stderr
expect(consoleErrorSpy).toHaveBeenCalled();
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("0.2.0");
fetchSpy.mockRestore();
});
it("should not show notification when up to date", async () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.1.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Should not print notification
expect(consoleErrorSpy).not.toHaveBeenCalled();
// Should not auto-update
expect(execSyncSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should handle network errors gracefully", async () => {
@ -203,16 +228,13 @@ describe("update-check", () => {
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to reject
await new Promise((resolve) => setTimeout(resolve, 100));
// Should not crash or show notification
expect(consoleErrorSpy).not.toHaveBeenCalled();
// Should not crash or try to update
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("should show cached notification when skipping check", async () => {
it("should auto-update using cached version when skipping check", async () => {
// Clear any previous state
cleanupTestCache();
setupTestCache();
@ -226,37 +248,55 @@ describe("update-check", () => {
const fetchSpy = spyOn(global, "fetch");
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should not fetch, but should show cached notification
// Should not fetch, but should auto-update from cache
expect(fetchSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalled();
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Update available");
expect(output).toContain("0.3.0");
// Should have run the install script
expect(execSyncSpy).toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe("version comparison", () => {
it("should detect newer major version", () => {
// This is tested indirectly through checkForUpdates
// We'll create a more direct test by mocking different versions
expect(true).toBe(true); // Placeholder - actual logic tested above
execSyncSpy.mockRestore();
});
it("should detect newer minor version", () => {
expect(true).toBe(true); // Placeholder - actual logic tested above
});
it("should handle update failures gracefully", async () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
it("should detect newer patch version", () => {
expect(true).toBe(true); // Placeholder - actual logic tested above
});
// Mock execSync to throw an error
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {
throw new Error("Update failed");
});
it("should handle equal versions", () => {
expect(true).toBe(true); // Placeholder - actual logic tested above
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should have printed error message
expect(consoleErrorSpy).toHaveBeenCalled();
const output = consoleErrorSpy.mock.calls.map((call) => call[0]).join("\n");
expect(output).toContain("Auto-update failed");
// Should NOT have exited (continue with original command)
expect(processExitSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
});
@ -267,21 +307,23 @@ describe("update-check", () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.2.0" }),
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Cache file should exist now (if CACHE_DIR is writable)
// This is non-critical, so we don't assert
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
it("should handle corrupted cache gracefully", async () => {
@ -292,43 +334,22 @@ describe("update-check", () => {
const mockFetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ version: "0.2.0" }),
json: () => Promise.resolve({ version: "0.3.0" }),
} as Response)
);
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
// Mock execSync to prevent actual update
const { executor } = await import("../update-check.js");
const execSyncSpy = spyOn(executor, "execSync").mockImplementation(() => {});
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Should treat corrupted cache as missing and check for updates
// Give the promise time to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
expect(fetchSpy).toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe("timeout handling", () => {
it("should timeout slow requests", async () => {
const mockFetch = mock(() => {
return new Promise((_, reject) => {
// Simulate a timeout error
setTimeout(() => reject(new Error("Timeout")), 100);
});
});
const fetchSpy = spyOn(global, "fetch").mockImplementation(mockFetch);
const { checkForUpdates } = await import("../update-check.js");
await checkForUpdates();
// Give it time to timeout
await new Promise((resolve) => setTimeout(resolve, 200));
// Should not crash or show notification
expect(consoleErrorSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
execSyncSpy.mockRestore();
});
});
});

View file

@ -61,8 +61,8 @@ async function handleDefaultCommand(agent: string, cloud: string | undefined, pr
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Check for updates in the background (non-blocking)
checkForUpdates();
// Check for updates and auto-update if needed (blocking)
await checkForUpdates();
// Extract --prompt or -p flag
let [prompt, filteredArgs] = extractFlagValue(

View file

@ -1,9 +1,15 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
import { join } from "path";
import { execSync as nodeExecSync } from "child_process";
import pc from "picocolors";
import { VERSION } from "./version.js";
import { RAW_BASE, CACHE_DIR } from "./manifest.js";
// Internal executor for testability - can be replaced in tests
export const executor = {
execSync: (cmd: string, options?: any) => nodeExecSync(cmd, options),
};
// ── Constants ──────────────────────────────────────────────────────────────────
const CHECK_INTERVAL = 86400; // 24 hours in seconds
@ -86,7 +92,7 @@ function compareVersions(current: string, latest: string): boolean {
return false; // Versions are equal
}
function printUpdateNotification(latestVersion: string): void {
function performAutoUpdate(latestVersion: string): void {
console.error(); // Use stderr so it doesn't interfere with parseable output
console.error(pc.yellow("┌────────────────────────────────────────────────────────────┐"));
console.error(
@ -96,12 +102,36 @@ function printUpdateNotification(latestVersion: string): void {
pc.yellow(" │")
);
console.error(
pc.yellow("│ Run: ") +
pc.cyan(pc.bold("spawn update")) +
pc.yellow(" to see how to upgrade │")
pc.yellow("│ ") +
pc.bold("Updating automatically...") +
pc.yellow(" │")
);
console.error(pc.yellow("└────────────────────────────────────────────────────────────┘"));
console.error();
try {
// Run the install script to update
executor.execSync(`curl -fsSL ${RAW_BASE}/cli/install.sh | bash`, {
stdio: "inherit",
shell: "/bin/bash",
});
console.error();
console.error(pc.green(pc.bold("✓ Updated successfully!")));
console.error(pc.dim(" Restart your command to use the new version."));
console.error();
// Exit cleanly after update
process.exit(0);
} catch (err) {
console.error();
console.error(pc.red(pc.bold("✗ 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 ─────────────────────────────────────────────────────────────────
@ -124,33 +154,32 @@ export async function checkForUpdates(): Promise<void> {
// Skip if we checked recently
if (!shouldCheckForUpdate()) {
// Show cached notification if available
// Auto-update if cached version is newer
const cache = readUpdateCache();
if (cache?.latestVersion && compareVersions(VERSION, cache.latestVersion)) {
printUpdateNotification(cache.latestVersion);
performAutoUpdate(cache.latestVersion);
}
return;
}
// Fetch latest version (non-blocking, don't await)
fetchLatestVersion()
.then((latestVersion) => {
if (!latestVersion) return;
// Fetch latest version (blocking for auto-update)
try {
const latestVersion = await fetchLatestVersion();
if (!latestVersion) return;
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
// Update cache with latest check time
writeUpdateCache({
lastCheck: now,
latestVersion,
});
// Show notification if newer version is available
if (compareVersions(VERSION, latestVersion)) {
printUpdateNotification(latestVersion);
}
})
.catch(() => {
// Silently fail - update check is non-critical
// Update cache with latest check time
writeUpdateCache({
lastCheck: now,
latestVersion,
});
// Auto-update if newer version is available
if (compareVersions(VERSION, latestVersion)) {
performAutoUpdate(latestVersion);
}
} catch {
// Silently fail - update check is non-critical
}
}

View file

@ -1 +1 @@
export const VERSION = "0.1.0";
export const VERSION = "0.2.0";