Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/loop/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 = "<review>PASS</review>";
Expand Down
72 changes: 60 additions & 12 deletions src/loop/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
ensureCacheDir();
): Promise<Buffer> => {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Download failed: HTTP ${res.status}`);
Expand All @@ -167,14 +173,23 @@ const downloadAndStage = async (
if (buf.byteLength === 0) {
throw new Error("Downloaded file is empty");
}

if (checksumUrl) {
await verifyChecksum(buf, checksumUrl);
} else {
throw new Error(
"No .sha256 checksum available — refusing to install unverified binary"
);
}
return buf;
};

const downloadAndStage = async (
url: string,
version: string,
checksumUrl?: string
): Promise<void> => {
ensureCacheDir();
const buf = await downloadBinary(url, checksumUrl);

writeFileSync(STAGED_BINARY, buf);
chmodSync(STAGED_BINARY, 0o755);
Expand All @@ -199,12 +214,7 @@ export const applyStagedUpdateOnStartup = (): Promise<void> => {
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);
Expand Down Expand Up @@ -255,6 +265,44 @@ const checkAndStage = async (
}
};

const checkAndApply = async (
assetName: string,
silent: boolean
): Promise<void> => {
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<boolean> => {
Expand All @@ -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}`);
Expand Down
48 changes: 23 additions & 25 deletions tests/loop/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
Expand All @@ -407,6 +404,9 @@ test("handleManualUpdateCommand stages update with correct metadata", async () =
if (existsSync(METADATA_FILE)) {
unlinkSync(METADATA_FILE);
}
if (existsSync(testExecPath)) {
unlinkSync(testExecPath);
}
}
});

Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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;
Expand All @@ -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,
});
}
});

Expand Down