From c889289bb2bf1edf0cb18feb377f68c13027eede Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:54:42 -0800 Subject: [PATCH 1/3] Add self-update support with background check and manual commands Prebuilt binaries now check for updates automatically on startup, download new versions in the background, and apply them on the next run. Adds `loop update` and `loop upgrade` commands for manual updates. --- README.md | 14 ++ src/loop.ts | 7 + src/loop/constants.ts | 6 + src/loop/update-deps.ts | 11 ++ src/loop/update.ts | 249 ++++++++++++++++++++++++ tests/loop.test.ts | 86 +++++++- tests/loop/update.test.ts | 400 ++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 8 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 src/loop/update-deps.ts create mode 100644 src/loop/update.ts create mode 100644 tests/loop/update.test.ts 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/constants.ts b/src/loop/constants.ts index d7f8a84..f446d72 100644 --- a/src/loop/constants.ts +++ b/src/loop/constants.ts @@ -10,6 +10,8 @@ loop - 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) @@ -22,6 +24,10 @@ Options: --tmux Run in a detached tmux session (name: repo-loop-X) --worktree Create and run in a fresh git worktree (name: repo-loop-X) -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..b0bac79 --- /dev/null +++ b/src/loop/update.ts @@ -0,0 +1,249 @@ +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/; + +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() }) + ); +}; + +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 (await res.json()) as ReleaseResponse; +}; + +const downloadAndStage = async ( + url: string, + version: string +): Promise => { + ensureCacheDir(); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed: HTTP ${res.status}`); + } + const buffer = await res.arrayBuffer(); + if (buffer.byteLength === 0) { + throw new Error("Downloaded file is empty"); + } + + writeFileSync(STAGED_BINARY, Buffer.from(buffer)); + 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 runUpdateFlow = async (): Promise => { + const currentVersion = getCurrentVersion(); + const assetName = getAssetName(); + const release = await fetchLatestRelease(); + const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); + + if (!isNewerVersion(version, currentVersion)) { + 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}`); + } + + console.log(`[loop] downloading v${version}...`); + await downloadAndStage(asset.browser_download_url, version); + 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 runUpdateFlow(); + } 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; + } + + // Validate platform and persist check time synchronously. + // These are local/actionable errors — report them to the user. + 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; + } + + (async () => { + try { + const currentVersion = getCurrentVersion(); + const release = await fetchLatestRelease(); + const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); + + if (!isNewerVersion(version, currentVersion)) { + return; + } + + const asset = release.assets.find((a) => a.name === assetName); + if (!asset) { + return; + } + + await downloadAndStage(asset.browser_download_url, version); + } catch { + // Network and 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/update.test.ts b/tests/loop/update.test.ts new file mode 100644 index 0000000..918f514 --- /dev/null +++ b/tests/loop/update.test.ts @@ -0,0 +1,400 @@ +import { afterEach, expect, mock, test } from "bun:test"; +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); + } + } +}); 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 From ee11f146466b2635558c0447fcf7de6184b7bc43 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:54:42 -0800 Subject: [PATCH 2/3] feat: show version in help and add --version flag --- src/loop/args.ts | 6 ++++++ src/loop/constants.ts | 5 ++++- tests/loop/args.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) 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 f446d72..f64206e 100644 --- a/src/loop/constants.ts +++ b/src/loop/constants.ts @@ -1,11 +1,13 @@ +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 @@ -23,6 +25,7 @@ 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: 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", () => { From 56243a1a9105124d289610ee3add5e3350f9c79d Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:54:42 -0800 Subject: [PATCH 3/3] Push unstaged changes --- src/loop/update.ts | 120 ++++++++++++----- tests/loop/update.test.ts | 274 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 34 deletions(-) diff --git a/src/loop/update.ts b/src/loop/update.ts index b0bac79..cca9937 100644 --- a/src/loop/update.ts +++ b/src/loop/update.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -19,6 +20,7 @@ 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; @@ -98,6 +100,34 @@ const saveCheckTime = (): void => { ); }; +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" }, @@ -105,24 +135,48 @@ const fetchLatestRelease = async (): Promise => { if (!res.ok) { throw new Error(`GitHub API returned ${res.status}`); } - return (await res.json()) as ReleaseResponse; + 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 + version: string, + checksumUrl?: string ): Promise => { ensureCacheDir(); const res = await fetch(url); if (!res.ok) { throw new Error(`Download failed: HTTP ${res.status}`); } - const buffer = await res.arrayBuffer(); - if (buffer.byteLength === 0) { + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.byteLength === 0) { throw new Error("Downloaded file is empty"); } - writeFileSync(STAGED_BINARY, Buffer.from(buffer)); + 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 = { @@ -163,14 +217,18 @@ export const applyStagedUpdateOnStartup = (): Promise => { return Promise.resolve(); }; -const runUpdateFlow = async (): Promise => { +const checkAndStage = async ( + assetName: string, + silent: boolean +): Promise => { const currentVersion = getCurrentVersion(); - const assetName = getAssetName(); const release = await fetchLatestRelease(); const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); if (!isNewerVersion(version, currentVersion)) { - console.log(`[loop] already up to date (v${currentVersion})`); + if (!silent) { + console.log(`[loop] already up to date (v${currentVersion})`); + } return; } @@ -179,9 +237,22 @@ const runUpdateFlow = async (): Promise => { throw new Error(`No release asset for ${assetName}`); } - console.log(`[loop] downloading v${version}...`); - await downloadAndStage(asset.browser_download_url, version); - console.log(`[loop] v${version} staged — will apply on next startup`); + 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 ( @@ -198,7 +269,7 @@ export const handleManualUpdateCommand = async ( } try { - await runUpdateFlow(); + await checkAndStage(getAssetName(), false); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[loop] update failed: ${msg}`); @@ -214,8 +285,6 @@ export const startAutoUpdateCheck = (): void => { return; } - // Validate platform and persist check time synchronously. - // These are local/actionable errors — report them to the user. let assetName: string; try { assetName = getAssetName(); @@ -226,24 +295,7 @@ export const startAutoUpdateCheck = (): void => { return; } - (async () => { - try { - const currentVersion = getCurrentVersion(); - const release = await fetchLatestRelease(); - const version = release.tag_name.replace(VERSION_PREFIX_RE, ""); - - if (!isNewerVersion(version, currentVersion)) { - return; - } - - const asset = release.assets.find((a) => a.name === assetName); - if (!asset) { - return; - } - - await downloadAndStage(asset.browser_download_url, version); - } catch { - // Network and download failures are best-effort in auto mode - } - })(); + checkAndStage(assetName, true).catch(() => { + // Network/download failures are best-effort in auto mode + }); }; diff --git a/tests/loop/update.test.ts b/tests/loop/update.test.ts index 918f514..22297c1 100644 --- a/tests/loop/update.test.ts +++ b/tests/loop/update.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, mock, test } from "bun:test"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, @@ -398,3 +399,276 @@ test("handleManualUpdateCommand stages update with correct metadata", async () = } } }); + +// --- 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); + } + } +});