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,
+ });
}
});