diff --git a/src/loop/constants.ts b/src/loop/constants.ts index 6544df0..ce28f19 100644 --- a/src/loop/constants.ts +++ b/src/loop/constants.ts @@ -12,7 +12,7 @@ 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 update Check for updates and apply if available loop upgrade Alias for update Options: @@ -31,7 +31,7 @@ Options: Auto-update: Updates are checked automatically on startup and applied on the next run. - Use "loop update" to manually check and stage an update. + Use "loop update" to manually check and apply an update. `.trim(); export const REVIEW_PASS = "PASS"; diff --git a/src/loop/update.ts b/src/loop/update.ts index d95ce58..eb4248b 100644 --- a/src/loop/update.ts +++ b/src/loop/update.ts @@ -153,12 +153,18 @@ const verifyChecksum = async ( } }; -const downloadAndStage = async ( +const applyBinary = (binary: Buffer): void => { + const execPath = process.execPath; + const tmpPath = `${execPath}.tmp-${Date.now()}`; + writeFileSync(tmpPath, binary); + chmodSync(tmpPath, 0o755); + renameSync(tmpPath, execPath); +}; + +const downloadBinary = async ( url: string, - version: string, checksumUrl?: string -): Promise => { - ensureCacheDir(); +): Promise => { const res = await fetch(url); if (!res.ok) { throw new Error(`Download failed: HTTP ${res.status}`); @@ -167,7 +173,6 @@ const downloadAndStage = async ( if (buf.byteLength === 0) { throw new Error("Downloaded file is empty"); } - if (checksumUrl) { await verifyChecksum(buf, checksumUrl); } else { @@ -175,6 +180,16 @@ const downloadAndStage = async ( "No .sha256 checksum available — refusing to install unverified binary" ); } + return buf; +}; + +const downloadAndStage = async ( + url: string, + version: string, + checksumUrl?: string +): Promise => { + ensureCacheDir(); + const buf = await downloadBinary(url, checksumUrl); writeFileSync(STAGED_BINARY, buf); chmodSync(STAGED_BINARY, 0o755); @@ -199,12 +214,7 @@ export const applyStagedUpdateOnStartup = (): Promise => { 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); + applyBinary(readFileSync(STAGED_BINARY)); unlinkSync(STAGED_BINARY); unlinkSync(METADATA_FILE); @@ -255,6 +265,44 @@ const checkAndStage = async ( } }; +const checkAndApply = 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` + ); + const binary = await downloadBinary( + asset.browser_download_url, + checksumAsset?.browser_download_url + ); + applyBinary(binary); + + if (!silent) { + console.log(`[loop] v${version} applied`); + } +}; + export const handleManualUpdateCommand = async ( argv: string[] ): Promise => { @@ -269,7 +317,7 @@ export const handleManualUpdateCommand = async ( } try { - await checkAndStage(getAssetName(), false); + await checkAndApply(getAssetName(), false); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[loop] update failed: ${msg}`); diff --git a/tests/loop/update.test.ts b/tests/loop/update.test.ts index 13bca8a..9e78772 100644 --- a/tests/loop/update.test.ts +++ b/tests/loop/update.test.ts @@ -334,18 +334,19 @@ test("applyStagedUpdateOnStartup logs error on write failure", async () => { } }); -// --- handleManualUpdateCommand staging --- +// --- handleManualUpdateCommand apply --- -test("handleManualUpdateCommand stages update with correct metadata", async () => { +test("handleManualUpdateCommand applies update to executable", async () => { const osName = process.platform === "darwin" ? "macos" : "linux"; const assetName = `loop-${osName}-${process.arch}`; const originalExecPath = process.execPath; + const testExecPath = "/tmp/loop-update-test-binary"; const originalFetch = globalThis.fetch; const originalLog = console.log; Object.defineProperty(process, "execPath", { - value: "/tmp/loop-update-test-binary", + value: testExecPath, configurable: true, }); @@ -387,13 +388,9 @@ test("handleManualUpdateCommand stages update with correct metadata", async () = ); 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(); + expect(existsSync(STAGED_BINARY)).toBe(false); + expect(existsSync(METADATA_FILE)).toBe(false); + expect(readFileSync(testExecPath, "utf-8")).toBe(binaryData); } finally { Object.defineProperty(process, "execPath", { value: originalExecPath, @@ -407,6 +404,9 @@ test("handleManualUpdateCommand stages update with correct metadata", async () = if (existsSync(METADATA_FILE)) { unlinkSync(METADATA_FILE); } + if (existsSync(testExecPath)) { + unlinkSync(testExecPath); + } } }); @@ -482,9 +482,10 @@ test("update verifies matching checksum", async () => { const originalFetch = globalThis.fetch; const originalLog = console.log; const originalError = console.error; + const testExecPath = "/tmp/loop-checksum-test-binary"; Object.defineProperty(process, "execPath", { - value: "/tmp/loop-checksum-test-binary", + value: testExecPath, configurable: true, }); @@ -525,29 +526,19 @@ test("update verifies matching checksum", async () => { ); expect(await handleManualUpdateCommand(["update"])).toBe(true); - expect(existsSync(STAGED_BINARY)).toBe(true); - expect(existsSync(METADATA_FILE)).toBe(true); - expect(readFileSync(STAGED_BINARY, "utf-8")).toBe(binaryData); - 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(); + expect(existsSync(STAGED_BINARY)).toBe(false); + expect(existsSync(METADATA_FILE)).toBe(false); + expect(readFileSync(testExecPath, "utf-8")).toBe(binaryData); expect(errorMock).not.toHaveBeenCalled(); const logMessages = logMock.mock.calls.map((call) => String(call[0])); expect( logMessages.some((msg) => msg.includes("[loop] downloading v99.0.0...")) ).toBe(true); expect( - logMessages.some((msg) => - msg.includes("[loop] v99.0.0 staged — will apply on next startup") - ) + logMessages.some((msg) => msg.includes("[loop] v99.0.0 applied")) ).toBe(true); expect(fetchMock.mock.calls).toHaveLength(3); } finally { - Object.defineProperty(process, "execPath", { - value: originalExecPath, - configurable: true, - }); globalThis.fetch = originalFetch; console.log = originalLog; console.error = originalError; @@ -557,6 +548,13 @@ test("update verifies matching checksum", async () => { if (existsSync(METADATA_FILE)) { unlinkSync(METADATA_FILE); } + if (existsSync(testExecPath)) { + unlinkSync(testExecPath); + } + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); } });