diff --git a/README.md b/README.md index a7c7b1c..9051798 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,20 @@ git push origin main git push --tags ``` +## Auto-update + +Prebuilt binaries check for updates automatically on startup and download new versions in the background. The update is applied on the next startup. + +```bash +# manually check for updates +loop update + +# same thing (alias) +loop upgrade +``` + +When running from source (`bun src/loop.ts`), auto-update is disabled — use `git pull` instead. + ## Options - `-a, --agent `: agent to run (default: `codex`) diff --git a/src/loop.ts b/src/loop.ts index 60ab3d9..bea4f7b 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -1,9 +1,16 @@ #!/usr/bin/env bun import { cliDeps } from "./loop/deps"; +import { updateDeps } from "./loop/update-deps"; const TMUX_DETACH_HINT = "[loop] detach with Ctrl-b d"; export const runCli = async (argv: string[]): Promise => { + await updateDeps.applyStagedUpdateOnStartup(); + if (await updateDeps.handleManualUpdateCommand(argv)) { + return; + } + updateDeps.startAutoUpdateCheck(); + if (process.env.TMUX) { console.log(TMUX_DETACH_HINT); } diff --git a/src/loop/args.ts b/src/loop/args.ts index 8b71a36..88ca332 100644 --- a/src/loop/args.ts +++ b/src/loop/args.ts @@ -3,6 +3,7 @@ import { DEFAULT_CODEX_MODEL, DEFAULT_DONE_SIGNAL, HELP, + LOOP_VERSION, VALUE_FLAGS, } from "./constants"; import type { Agent, Format, Options, ReviewMode, ValueFlag } from "./types"; @@ -100,6 +101,11 @@ const consumeArg = ( ): { nextIndex: number; stop: boolean } => { const arg = argv[index]; + if (arg === "-v" || arg === "--version") { + console.log(`loop v${LOOP_VERSION}`); + process.exit(0); + } + if (arg === "-h" || arg === "--help") { console.log(HELP); process.exit(0); diff --git a/src/loop/constants.ts b/src/loop/constants.ts index d7f8a84..f64206e 100644 --- a/src/loop/constants.ts +++ b/src/loop/constants.ts @@ -1,15 +1,19 @@ +import pkg from "../../package.json"; import type { ValueFlag } from "./types"; export const DEFAULT_DONE_SIGNAL = "DONE"; export const DEFAULT_CODEX_MODEL = "gpt-5.3-codex"; export const DEFAULT_CLAUDE_MODEL = "opus"; +export const LOOP_VERSION = pkg.version; export const HELP = ` -loop - meta agent loop runner +loop - v${LOOP_VERSION} - meta agent loop runner Usage: loop Open live panel for running claude/codex instances loop [options] [prompt] + loop update Check for updates and stage if available + loop upgrade Alias for update Options: -a, --agent Agent CLI to run (default: codex) @@ -21,7 +25,12 @@ Options: --review [claude|codex|claudex] Review on done (default: claudex) --tmux Run in a detached tmux session (name: repo-loop-X) --worktree Create and run in a fresh git worktree (name: repo-loop-X) + -v, --version Show loop version -h, --help Show this help + +Auto-update: + Updates are checked automatically on startup and applied on the next run. + Use "loop update" to manually check and stage an update. `.trim(); export const REVIEW_PASS = "PASS"; diff --git a/src/loop/update-deps.ts b/src/loop/update-deps.ts new file mode 100644 index 0000000..bbaa41c --- /dev/null +++ b/src/loop/update-deps.ts @@ -0,0 +1,11 @@ +import { + applyStagedUpdateOnStartup, + handleManualUpdateCommand, + startAutoUpdateCheck, +} from "./update"; + +export const updateDeps = { + applyStagedUpdateOnStartup, + handleManualUpdateCommand, + startAutoUpdateCheck, +}; diff --git a/src/loop/update.ts b/src/loop/update.ts new file mode 100644 index 0000000..cca9937 --- /dev/null +++ b/src/loop/update.ts @@ -0,0 +1,301 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import pkg from "../../package.json"; + +const GITHUB_REPO = "axeldelafosse/loop"; +const API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; +const CACHE_DIR = join(homedir(), ".cache", "loop", "update"); +const STAGED_BINARY = join(CACHE_DIR, "loop-staged"); +const METADATA_FILE = join(CACHE_DIR, "metadata.json"); +const CHECK_FILE = join(CACHE_DIR, "last-check.json"); +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const VERSION_PREFIX_RE = /^v/; +const WHITESPACE_RE = /\s+/; + +interface UpdateMetadata { + downloadedAt: string; + sourceUrl: string; + targetVersion: string; +} + +interface ReleaseAsset { + browser_download_url: string; + name: string; +} + +interface ReleaseResponse { + assets: ReleaseAsset[]; + tag_name: string; +} + +export const getCurrentVersion = (): string => pkg.version; + +export const isNewerVersion = (remote: string, current: string): boolean => { + const r = remote.replace(VERSION_PREFIX_RE, "").split(".").map(Number); + const c = current.replace(VERSION_PREFIX_RE, "").split(".").map(Number); + for (let i = 0; i < Math.max(r.length, c.length); i++) { + if ((r[i] ?? 0) > (c[i] ?? 0)) { + return true; + } + if ((r[i] ?? 0) < (c[i] ?? 0)) { + return false; + } + } + return false; +}; + +const OS_MAP: Record = { darwin: "macos", linux: "linux" }; +const ARCH_MAP: Record = { arm64: "arm64", x64: "x64" }; + +export const getAssetName = (): string => { + const os = OS_MAP[process.platform]; + if (!os) { + throw new Error(`Unsupported OS: ${process.platform}`); + } + const arch = ARCH_MAP[process.arch]; + if (!arch) { + throw new Error(`Unsupported architecture: ${process.arch}`); + } + return `loop-${os}-${arch}`; +}; + +export const isDevMode = (): boolean => { + const name = basename(process.execPath); + return name === "bun" || name === "node"; +}; + +const ensureCacheDir = (): void => { + if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); + } +}; + +const shouldThrottle = (): boolean => { + if (!existsSync(CHECK_FILE)) { + return false; + } + try { + const data = JSON.parse(readFileSync(CHECK_FILE, "utf-8")); + return Date.now() - new Date(data.lastCheck).getTime() < CHECK_INTERVAL_MS; + } catch { + return false; + } +}; + +const saveCheckTime = (): void => { + ensureCacheDir(); + writeFileSync( + CHECK_FILE, + JSON.stringify({ lastCheck: new Date().toISOString() }) + ); +}; + +export const parseReleaseResponse = (data: unknown): ReleaseResponse => { + if (typeof data !== "object" || data === null) { + throw new Error("Invalid release response: expected an object"); + } + const obj = data as Record; + if (typeof obj.tag_name !== "string") { + throw new Error("Invalid release response: missing or invalid tag_name"); + } + if (!Array.isArray(obj.assets)) { + throw new Error("Invalid release response: missing or invalid assets"); + } + for (const asset of obj.assets) { + if (typeof asset !== "object" || asset === null) { + throw new Error("Invalid release response: asset is not an object"); + } + const a = asset as Record; + if (typeof a.name !== "string") { + throw new Error("Invalid release response: asset missing name"); + } + if (typeof a.browser_download_url !== "string") { + throw new Error( + "Invalid release response: asset missing browser_download_url" + ); + } + } + return data as ReleaseResponse; +}; + +const fetchLatestRelease = async (): Promise => { + const res = await fetch(API_URL, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!res.ok) { + throw new Error(`GitHub API returned ${res.status}`); + } + return parseReleaseResponse(await res.json()); +}; + +const verifyChecksum = async ( + data: Buffer, + checksumUrl: string +): Promise => { + const res = await fetch(checksumUrl); + if (!res.ok) { + throw new Error(`Checksum download failed: HTTP ${res.status}`); + } + const expected = (await res.text()).trim().split(WHITESPACE_RE)[0]; + const actual = createHash("sha256").update(data).digest("hex"); + if (actual !== expected) { + throw new Error(`Checksum mismatch: expected ${expected}, got ${actual}`); + } +}; + +const downloadAndStage = async ( + url: string, + version: string, + checksumUrl?: string +): Promise => { + ensureCacheDir(); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed: HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.byteLength === 0) { + throw new Error("Downloaded file is empty"); + } + + if (checksumUrl) { + await verifyChecksum(buf, checksumUrl); + } else { + console.error( + "[loop] warning: no .sha256 checksum available, skipping verification" + ); + } + + writeFileSync(STAGED_BINARY, buf); + chmodSync(STAGED_BINARY, 0o755); + + const metadata: UpdateMetadata = { + downloadedAt: new Date().toISOString(), + sourceUrl: url, + targetVersion: version, + }; + writeFileSync(METADATA_FILE, JSON.stringify(metadata, null, 2)); +}; + +export const applyStagedUpdateOnStartup = (): Promise => { + if (isDevMode()) { + return Promise.resolve(); + } + if (!(existsSync(STAGED_BINARY) && existsSync(METADATA_FILE))) { + return Promise.resolve(); + } + + try { + const metadata: UpdateMetadata = JSON.parse( + readFileSync(METADATA_FILE, "utf-8") + ); + const execPath = process.execPath; + const tmpPath = `${execPath}.tmp-${Date.now()}`; + + writeFileSync(tmpPath, readFileSync(STAGED_BINARY)); + chmodSync(tmpPath, 0o755); + renameSync(tmpPath, execPath); + + unlinkSync(STAGED_BINARY); + unlinkSync(METADATA_FILE); + + console.log(`[loop] updated to v${metadata.targetVersion}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[loop] failed to apply staged update: ${msg}`); + } + return Promise.resolve(); +}; + +const checkAndStage = async ( + assetName: string, + silent: boolean +): Promise => { + const currentVersion = getCurrentVersion(); + const release = await fetchLatestRelease(); + const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); + + if (!isNewerVersion(version, currentVersion)) { + if (!silent) { + console.log(`[loop] already up to date (v${currentVersion})`); + } + return; + } + + const asset = release.assets.find((a) => a.name === assetName); + if (!asset) { + throw new Error(`No release asset for ${assetName}`); + } + + if (!silent) { + console.log(`[loop] downloading v${version}...`); + } + + const checksumAsset = release.assets.find( + (a) => a.name === `${assetName}.sha256` + ); + await downloadAndStage( + asset.browser_download_url, + version, + checksumAsset?.browser_download_url + ); + + if (!silent) { + console.log(`[loop] v${version} staged — will apply on next startup`); + } +}; + +export const handleManualUpdateCommand = async ( + argv: string[] +): Promise => { + const cmd = argv[0]?.toLowerCase(); + if (cmd !== "update" && cmd !== "upgrade") { + return false; + } + + if (isDevMode()) { + console.log("[loop] running from source — use git pull to update"); + return true; + } + + try { + await checkAndStage(getAssetName(), false); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[loop] update failed: ${msg}`); + } + return true; +}; + +export const startAutoUpdateCheck = (): void => { + if (isDevMode()) { + return; + } + if (shouldThrottle()) { + return; + } + + let assetName: string; + try { + assetName = getAssetName(); + saveCheckTime(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[loop] auto-update skipped: ${msg}`); + return; + } + + checkAndStage(assetName, true).catch(() => { + // Network/download failures are best-effort in auto mode + }); +}; diff --git a/tests/loop.test.ts b/tests/loop.test.ts index 4efdff3..33f3339 100644 --- a/tests/loop.test.ts +++ b/tests/loop.test.ts @@ -26,7 +26,16 @@ interface CliModuleDeps { runPanel?: () => Promise; } -const loadRunCli = async (deps: CliModuleDeps = {}) => { +interface UpdateModuleDeps { + applyStagedUpdateOnStartup?: () => Promise; + handleManualUpdateCommand?: (argv: string[]) => Promise; + startAutoUpdateCheck?: () => void; +} + +const loadRunCli = async ( + deps: CliModuleDeps = {}, + updateOverrides: UpdateModuleDeps = {} +) => { const maybeEnterWorktreeMock = mock( deps.maybeEnterWorktree ?? (() => undefined) ); @@ -36,6 +45,16 @@ const loadRunCli = async (deps: CliModuleDeps = {}) => { const runLoopMock = mock(deps.runLoop ?? (async () => undefined)); const runPanelMock = mock(deps.runPanel ?? (async () => undefined)); + const applyStagedMock = mock( + updateOverrides.applyStagedUpdateOnStartup ?? (async () => undefined) + ); + const handleManualMock = mock( + updateOverrides.handleManualUpdateCommand ?? (async () => false) + ); + const startAutoCheckMock = mock( + updateOverrides.startAutoUpdateCheck ?? (() => undefined) + ); + mock.module("../src/loop/deps", () => ({ cliDeps: { maybeEnterWorktree: maybeEnterWorktreeMock, @@ -47,8 +66,18 @@ const loadRunCli = async (deps: CliModuleDeps = {}) => { }, })); + mock.module("../src/loop/update-deps", () => ({ + updateDeps: { + applyStagedUpdateOnStartup: applyStagedMock, + handleManualUpdateCommand: handleManualMock, + startAutoUpdateCheck: startAutoCheckMock, + }, + })); + const { runCli } = await import(`../src/loop?test=${Date.now()}`); return { + applyStagedMock, + handleManualMock, maybeEnterWorktreeMock, parseArgsMock, resolveTaskMock, @@ -56,6 +85,7 @@ const loadRunCli = async (deps: CliModuleDeps = {}) => { runInTmuxMock, runLoopMock, runPanelMock, + startAutoCheckMock, }; }; @@ -228,3 +258,57 @@ test("runCli creates worktree before resolving task when --worktree is set", asy expect(calls).toEqual(["worktree", "resolve"]); expect(runLoopMock).toHaveBeenCalledWith("ship feature", opts); }); + +test("runCli calls update hooks in correct order before task flow", async () => { + const calls: string[] = []; + const opts = makeOptions(); + const { runCli, applyStagedMock, handleManualMock, startAutoCheckMock } = + await loadRunCli( + { + parseArgs: () => opts, + resolveTask: () => { + calls.push("resolveTask"); + return Promise.resolve("ship feature"); + }, + }, + { + applyStagedUpdateOnStartup: () => { + calls.push("applyStaged"); + return Promise.resolve(); + }, + handleManualUpdateCommand: () => { + calls.push("handleManual"); + return Promise.resolve(false); + }, + startAutoUpdateCheck: () => { + calls.push("autoCheck"); + }, + } + ); + + await runCli(["--proof", "verify with tests"]); + + expect(applyStagedMock).toHaveBeenCalledTimes(1); + expect(handleManualMock).toHaveBeenCalledTimes(1); + expect(startAutoCheckMock).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + "applyStaged", + "handleManual", + "autoCheck", + "resolveTask", + ]); +}); + +test("runCli returns early when handleManualUpdateCommand returns true", async () => { + const { runCli, runLoopMock, runPanelMock } = await loadRunCli( + {}, + { + handleManualUpdateCommand: () => Promise.resolve(true), + } + ); + + await runCli(["update"]); + + expect(runLoopMock).not.toHaveBeenCalled(); + expect(runPanelMock).not.toHaveBeenCalled(); +}); diff --git a/tests/loop/args.test.ts b/tests/loop/args.test.ts index 2ffbf7d..93feefa 100644 --- a/tests/loop/args.test.ts +++ b/tests/loop/args.test.ts @@ -3,9 +3,13 @@ import { parseArgs } from "../../src/loop/args"; import { DEFAULT_CODEX_MODEL, DEFAULT_DONE_SIGNAL, + LOOP_VERSION, } from "../../src/loop/constants"; const ORIGINAL_LOOP_CODEX_MODEL = process.env.LOOP_CODEX_MODEL; +const originalExit = process.exit; +const originalLog = console.log; + const clearModelEnv = (): void => { Reflect.deleteProperty(process.env, "LOOP_CODEX_MODEL"); }; @@ -20,6 +24,42 @@ const restoreModelEnv = (): void => { afterEach(() => { restoreModelEnv(); + process.exit = originalExit; + console.log = originalLog; +}); + +test("parseArgs prints version and exits when --version is passed", () => { + let code: number | undefined; + console.log = ((_: string) => { + expect(_).toBe(`loop v${LOOP_VERSION}`); + }) as typeof console.log; + process.exit = ((exitCode?: number): never => { + code = exitCode; + throw new Error("exit"); + }) as typeof process.exit; + + expect(() => { + parseArgs(["--version"]); + }).toThrow("exit"); + + expect(code).toBe(0); +}); + +test("parseArgs prints version and exits when -v is passed", () => { + let code: number | undefined; + console.log = ((_: string) => { + expect(_).toBe(`loop v${LOOP_VERSION}`); + }) as typeof console.log; + process.exit = ((exitCode?: number): never => { + code = exitCode; + throw new Error("exit"); + }) as typeof process.exit; + + expect(() => { + parseArgs(["-v"]); + }).toThrow("exit"); + + expect(code).toBe(0); }); test("parseArgs throws when required proof is missing", () => { diff --git a/tests/loop/update.test.ts b/tests/loop/update.test.ts new file mode 100644 index 0000000..22297c1 --- /dev/null +++ b/tests/loop/update.test.ts @@ -0,0 +1,674 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +const CACHE_DIR = join(homedir(), ".cache", "loop", "update"); +const STAGED_BINARY = join(CACHE_DIR, "loop-staged"); +const METADATA_FILE = join(CACHE_DIR, "metadata.json"); +const CHECK_FILE = join(CACHE_DIR, "last-check.json"); + +const ASSET_NAME_RE = /^loop-(macos|linux)-(x64|arm64)$/; +const SEMVER_RE = /^\d+\.\d+\.\d+/; + +afterEach(() => { + mock.restore(); +}); + +// --- isNewerVersion --- + +test("isNewerVersion returns true when remote is newer (patch)", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("1.0.3", "1.0.2")).toBe(true); +}); + +test("isNewerVersion returns true when remote is newer (minor)", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("1.1.0", "1.0.9")).toBe(true); +}); + +test("isNewerVersion returns true when remote is newer (major)", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("2.0.0", "1.9.9")).toBe(true); +}); + +test("isNewerVersion returns false when versions are equal", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("1.0.2", "1.0.2")).toBe(false); +}); + +test("isNewerVersion returns false when remote is older", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("1.0.1", "1.0.2")).toBe(false); +}); + +test("isNewerVersion strips v prefix", async () => { + const { isNewerVersion } = await import("../../src/loop/update"); + expect(isNewerVersion("v1.0.3", "v1.0.2")).toBe(true); + expect(isNewerVersion("v1.0.2", "1.0.2")).toBe(false); +}); + +// --- getAssetName --- + +test("getAssetName returns platform-specific name", async () => { + const { getAssetName } = await import("../../src/loop/update"); + const name = getAssetName(); + expect(name).toMatch(ASSET_NAME_RE); +}); + +// --- isDevMode --- + +test("isDevMode returns true when execPath ends with bun", async () => { + const original = process.execPath; + try { + Object.defineProperty(process, "execPath", { value: "/usr/bin/bun" }); + const { isDevMode } = await import( + `../../src/loop/update?dev=${Date.now()}` + ); + expect(isDevMode()).toBe(true); + } finally { + Object.defineProperty(process, "execPath", { value: original }); + } +}); + +test("isDevMode returns false when execPath is loop binary", async () => { + const original = process.execPath; + try { + Object.defineProperty(process, "execPath", { + value: "/home/user/.local/bin/loop", + }); + const { isDevMode } = await import( + `../../src/loop/update?bin=${Date.now()}` + ); + expect(isDevMode()).toBe(false); + } finally { + Object.defineProperty(process, "execPath", { value: original }); + } +}); + +// --- handleManualUpdateCommand --- + +test("handleManualUpdateCommand returns true for update", async () => { + const { handleManualUpdateCommand } = await import("../../src/loop/update"); + const result = await handleManualUpdateCommand(["update"]); + expect(result).toBe(true); +}); + +test("handleManualUpdateCommand returns true for upgrade", async () => { + const { handleManualUpdateCommand } = await import("../../src/loop/update"); + const result = await handleManualUpdateCommand(["upgrade"]); + expect(result).toBe(true); +}); + +test("handleManualUpdateCommand returns false for other commands", async () => { + const { handleManualUpdateCommand } = await import("../../src/loop/update"); + expect(await handleManualUpdateCommand(["--help"])).toBe(false); + expect(await handleManualUpdateCommand(["some-task"])).toBe(false); + expect(await handleManualUpdateCommand([])).toBe(false); +}); + +// --- applyStagedUpdateOnStartup --- + +test("applyStagedUpdateOnStartup skips when no staged binary", async () => { + const logMock = mock(() => undefined); + const originalLog = console.log; + console.log = logMock; + + try { + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + + const { applyStagedUpdateOnStartup } = await import( + `../../src/loop/update?noop=${Date.now()}` + ); + await applyStagedUpdateOnStartup(); + + expect(logMock).not.toHaveBeenCalled(); + } finally { + console.log = originalLog; + } +}); + +// --- startAutoUpdateCheck --- + +test("startAutoUpdateCheck does not block", async () => { + const { startAutoUpdateCheck } = await import("../../src/loop/update"); + const start = Date.now(); + startAutoUpdateCheck(); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(50); +}); + +// --- throttle behavior --- + +test("auto-check respects throttle interval", async () => { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync( + CHECK_FILE, + JSON.stringify({ lastCheck: new Date().toISOString() }) + ); + + const fetchMock = mock(() => + Promise.resolve(Response.json({ tag_name: "v99.0.0", assets: [] })) + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as typeof fetch; + + try { + const { startAutoUpdateCheck } = await import( + `../../src/loop/update?throttle=${Date.now()}` + ); + startAutoUpdateCheck(); + + await new Promise((r) => setTimeout(r, 100)); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + if (existsSync(CHECK_FILE)) { + unlinkSync(CHECK_FILE); + } + } +}); + +// --- getCurrentVersion --- + +test("getCurrentVersion returns a valid semver string", async () => { + const { getCurrentVersion } = await import("../../src/loop/update"); + const version = getCurrentVersion(); + expect(version).toMatch(SEMVER_RE); +}); + +// --- getAssetName unsupported platform/arch --- + +test("getAssetName throws for unsupported OS", async () => { + const original = process.platform; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + try { + const { getAssetName } = await import( + `../../src/loop/update?platwin=${Date.now()}` + ); + expect(() => getAssetName()).toThrow("Unsupported OS: win32"); + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }); + } +}); + +test("getAssetName throws for unsupported architecture", async () => { + const origArch = process.arch; + Object.defineProperty(process, "arch", { + value: "mips", + configurable: true, + }); + try { + const { getAssetName } = await import( + `../../src/loop/update?archmips=${Date.now()}` + ); + expect(() => getAssetName()).toThrow("Unsupported architecture: mips"); + } finally { + Object.defineProperty(process, "arch", { + value: origArch, + configurable: true, + }); + } +}); + +// --- applyStagedUpdateOnStartup apply success --- + +test("applyStagedUpdateOnStartup applies staged binary and cleans up", async () => { + const testDir = join(tmpdir(), `loop-apply-test-${Date.now()}`); + const fakeExec = join(testDir, "loop"); + mkdirSync(testDir, { recursive: true }); + writeFileSync(fakeExec, "old-binary"); + + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(STAGED_BINARY, "new-binary"); + writeFileSync( + METADATA_FILE, + JSON.stringify({ + targetVersion: "2.0.0", + downloadedAt: new Date().toISOString(), + sourceUrl: "https://example.com/loop", + }) + ); + + const originalExecPath = process.execPath; + const logMock = mock(() => undefined); + const originalLog = console.log; + + Object.defineProperty(process, "execPath", { + value: fakeExec, + configurable: true, + }); + console.log = logMock; + + try { + const { applyStagedUpdateOnStartup } = await import( + `../../src/loop/update?apply=${Date.now()}` + ); + await applyStagedUpdateOnStartup(); + + expect(readFileSync(fakeExec, "utf-8")).toBe("new-binary"); + expect(existsSync(STAGED_BINARY)).toBe(false); + expect(existsSync(METADATA_FILE)).toBe(false); + expect(logMock).toHaveBeenCalledWith("[loop] updated to v2.0.0"); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + console.log = originalLog; + rmSync(testDir, { recursive: true, force: true }); + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); + +// --- applyStagedUpdateOnStartup permission failure --- + +test("applyStagedUpdateOnStartup logs error on write failure", async () => { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(STAGED_BINARY, "new-binary"); + writeFileSync( + METADATA_FILE, + JSON.stringify({ + targetVersion: "3.0.0", + downloadedAt: new Date().toISOString(), + sourceUrl: "https://example.com/loop", + }) + ); + + const originalExecPath = process.execPath; + const errorMock = mock(() => undefined); + const originalError = console.error; + + // Point execPath to an unwritable system directory + Object.defineProperty(process, "execPath", { + value: "/usr/bin/loop-update-test-fake", + configurable: true, + }); + console.error = errorMock; + + try { + const { applyStagedUpdateOnStartup } = await import( + `../../src/loop/update?permfail=${Date.now()}` + ); + await applyStagedUpdateOnStartup(); + + expect(errorMock).toHaveBeenCalledTimes(1); + const msg = errorMock.mock.calls[0][0] as string; + expect(msg).toContain("[loop] failed to apply staged update:"); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + console.error = originalError; + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); + +// --- handleManualUpdateCommand staging --- + +test("handleManualUpdateCommand stages update with correct metadata", async () => { + const osName = process.platform === "darwin" ? "macos" : "linux"; + const assetName = `loop-${osName}-${process.arch}`; + + const originalExecPath = process.execPath; + const originalFetch = globalThis.fetch; + const originalLog = console.log; + + Object.defineProperty(process, "execPath", { + value: "/tmp/loop-update-test-binary", + configurable: true, + }); + + const fetchMock = mock((...args: unknown[]) => { + const url = String(args[0]); + if (url.includes("api.github.com")) { + return Promise.resolve( + Response.json({ + tag_name: "v99.0.0", + assets: [ + { + name: assetName, + browser_download_url: "https://example.com/loop-binary", + }, + ], + }) + ); + } + return Promise.resolve(new Response("fake-binary-data")); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const logMock = mock(() => undefined); + console.log = logMock; + + try { + const { handleManualUpdateCommand } = await import( + `../../src/loop/update?staging=${Date.now()}` + ); + await handleManualUpdateCommand(["update"]); + + expect(existsSync(STAGED_BINARY)).toBe(true); + expect(existsSync(METADATA_FILE)).toBe(true); + + const metadata = JSON.parse(readFileSync(METADATA_FILE, "utf-8")); + expect(metadata.targetVersion).toBe("99.0.0"); + expect(metadata.sourceUrl).toBe("https://example.com/loop-binary"); + expect(metadata.downloadedAt).toBeTruthy(); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + globalThis.fetch = originalFetch; + console.log = originalLog; + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); + +// --- parseReleaseResponse --- + +test("parseReleaseResponse accepts valid input", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + const result = parseReleaseResponse({ + tag_name: "v1.0.0", + assets: [ + { + name: "loop-macos-arm64", + browser_download_url: "https://example.com/binary", + }, + ], + }); + expect(result.tag_name).toBe("v1.0.0"); + expect(result.assets).toHaveLength(1); +}); + +test("parseReleaseResponse throws for non-object", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + expect(() => parseReleaseResponse("not an object")).toThrow( + "expected an object" + ); + expect(() => parseReleaseResponse(null)).toThrow("expected an object"); +}); + +test("parseReleaseResponse throws for missing tag_name", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + expect(() => parseReleaseResponse({ assets: [] })).toThrow( + "missing or invalid tag_name" + ); +}); + +test("parseReleaseResponse throws for missing assets", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + expect(() => parseReleaseResponse({ tag_name: "v1.0.0" })).toThrow( + "missing or invalid assets" + ); +}); + +test("parseReleaseResponse throws for asset missing name", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + expect(() => + parseReleaseResponse({ + tag_name: "v1.0.0", + assets: [{ browser_download_url: "https://example.com" }], + }) + ).toThrow("asset missing name"); +}); + +test("parseReleaseResponse throws for asset missing browser_download_url", async () => { + const { parseReleaseResponse } = await import("../../src/loop/update"); + expect(() => + parseReleaseResponse({ + tag_name: "v1.0.0", + assets: [{ name: "loop-macos-arm64" }], + }) + ).toThrow("asset missing browser_download_url"); +}); + +// --- SHA-256 checksum verification --- + +test("update verifies matching checksum", async () => { + const binaryData = "fake-binary-data"; + const expectedHash = createHash("sha256").update(binaryData).digest("hex"); + + const osName = process.platform === "darwin" ? "macos" : "linux"; + const assetName = `loop-${osName}-${process.arch}`; + + const originalExecPath = process.execPath; + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + + Object.defineProperty(process, "execPath", { + value: "/tmp/loop-checksum-test-binary", + configurable: true, + }); + + const fetchMock = mock((...args: unknown[]) => { + const url = String(args[0]); + if (url.includes("api.github.com")) { + return Promise.resolve( + Response.json({ + tag_name: "v99.0.0", + assets: [ + { + name: assetName, + browser_download_url: "https://example.com/loop-binary", + }, + { + name: `${assetName}.sha256`, + browser_download_url: "https://example.com/loop-binary.sha256", + }, + ], + }) + ); + } + if (url.includes(".sha256")) { + return Promise.resolve(new Response(`${expectedHash} ${assetName}\n`)); + } + return Promise.resolve(new Response(binaryData)); + }); + globalThis.fetch = fetchMock as typeof fetch; + + console.log = mock(() => undefined); + console.error = mock(() => undefined); + + try { + const { handleManualUpdateCommand } = await import( + `../../src/loop/update?checksum=${Date.now()}` + ); + await handleManualUpdateCommand(["update"]); + + expect(existsSync(STAGED_BINARY)).toBe(true); + expect(existsSync(METADATA_FILE)).toBe(true); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + globalThis.fetch = originalFetch; + console.log = originalLog; + console.error = originalError; + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); + +test("update rejects mismatched checksum", async () => { + const osName = process.platform === "darwin" ? "macos" : "linux"; + const assetName = `loop-${osName}-${process.arch}`; + + const originalExecPath = process.execPath; + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + + Object.defineProperty(process, "execPath", { + value: "/tmp/loop-checksum-test-binary", + configurable: true, + }); + + const fetchMock = mock((...args: unknown[]) => { + const url = String(args[0]); + if (url.includes("api.github.com")) { + return Promise.resolve( + Response.json({ + tag_name: "v99.0.0", + assets: [ + { + name: assetName, + browser_download_url: "https://example.com/loop-binary", + }, + { + name: `${assetName}.sha256`, + browser_download_url: "https://example.com/loop-binary.sha256", + }, + ], + }) + ); + } + if (url.includes(".sha256")) { + return Promise.resolve( + new Response( + "0000000000000000000000000000000000000000000000000000000000000000 loop\n" + ) + ); + } + return Promise.resolve(new Response("fake-binary-data")); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const errorMock = mock(() => undefined); + console.log = mock(() => undefined); + console.error = errorMock; + + try { + const { handleManualUpdateCommand } = await import( + `../../src/loop/update?badchecksum=${Date.now()}` + ); + await handleManualUpdateCommand(["update"]); + + const errorCalls = errorMock.mock.calls.map((c) => String(c[0])); + expect(errorCalls.some((msg) => msg.includes("Checksum mismatch"))).toBe( + true + ); + expect(existsSync(STAGED_BINARY)).toBe(false); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + globalThis.fetch = originalFetch; + console.log = originalLog; + console.error = originalError; + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); + +test("update warns when no checksum available", async () => { + const osName = process.platform === "darwin" ? "macos" : "linux"; + const assetName = `loop-${osName}-${process.arch}`; + + const originalExecPath = process.execPath; + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const originalError = console.error; + + Object.defineProperty(process, "execPath", { + value: "/tmp/loop-checksum-test-binary", + configurable: true, + }); + + const fetchMock = mock((...args: unknown[]) => { + const url = String(args[0]); + if (url.includes("api.github.com")) { + return Promise.resolve( + Response.json({ + tag_name: "v99.0.0", + assets: [ + { + name: assetName, + browser_download_url: "https://example.com/loop-binary", + }, + ], + }) + ); + } + return Promise.resolve(new Response("fake-binary-data")); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const errorMock = mock(() => undefined); + console.log = mock(() => undefined); + console.error = errorMock; + + try { + const { handleManualUpdateCommand } = await import( + `../../src/loop/update?nochecksum=${Date.now()}` + ); + await handleManualUpdateCommand(["update"]); + + const errorCalls = errorMock.mock.calls.map((c) => String(c[0])); + expect( + errorCalls.some((msg) => msg.includes("no .sha256 checksum available")) + ).toBe(true); + expect(existsSync(STAGED_BINARY)).toBe(true); + } finally { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + globalThis.fetch = originalFetch; + console.log = originalLog; + console.error = originalError; + if (existsSync(STAGED_BINARY)) { + unlinkSync(STAGED_BINARY); + } + if (existsSync(METADATA_FILE)) { + unlinkSync(METADATA_FILE); + } + } +}); diff --git a/tsconfig.json b/tsconfig.json index 23c59da..eb6a6f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "types": ["bun-types", "node"], + "resolveJsonModule": true, "strict": true, "noEmit": true, "skipLibCheck": true