diff --git a/cli/package.json b/cli/package.json index eacd2965..9aea4c96 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/update-check.test.ts b/cli/src/__tests__/update-check.test.ts index 6c18e701..fe425d6b 100644 --- a/cli/src/__tests__/update-check.test.ts +++ b/cli/src/__tests__/update-check.test.ts @@ -48,18 +48,22 @@ function readUpdateCheckCache(): any { describe("update-check", () => { let originalEnv: NodeJS.ProcessEnv; let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; 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(); }); }); }); diff --git a/cli/src/index.ts b/cli/src/index.ts index 15208e14..b67bc7af 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -61,8 +61,8 @@ async function handleDefaultCommand(agent: string, cloud: string | undefined, pr async function main(): Promise { 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( diff --git a/cli/src/update-check.ts b/cli/src/update-check.ts index ad9d307c..1d13326b 100644 --- a/cli/src/update-check.ts +++ b/cli/src/update-check.ts @@ -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 { // 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 + } } diff --git a/cli/src/version.ts b/cli/src/version.ts index 52c905c1..edbab618 100644 --- a/cli/src/version.ts +++ b/cli/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0";