mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
d223038a5e
commit
c4d99daaab
5 changed files with 170 additions and 120 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const VERSION = "0.1.0";
|
||||
export const VERSION = "0.2.0";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue