Skip to content
Closed
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
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,11 @@ source ~/.zshrc
Example release:

```bash
# bump version
npm version patch
# bump patch version and push commit + tag
bun run release:patch

# push commit + tag created by npm version
git push origin main
git push --tags
# equivalent to:
# npm version patch && git push --follow-tags
```

## Auto-update
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"build": "bun build --compile --outfile loop src/loop.ts",
"install:global": "bun run build && bun run src/install.ts",
"release:patch": "npm version patch && git push --follow-tags",
"check": "ultracite check",
"fix": "ultracite fix"
},
Expand Down
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`);
}
};
Comment on lines +268 to +304
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The checkAndApply function duplicates a significant amount of logic already present in checkAndStage. To reduce redundancy and improve maintainability, consider extracting the common logic (fetching release, version comparison, asset finding, and initial logging) into a shared helper function. This suggestion refactors checkAndApply to use such a helper, which is included in the suggestion.

const getUpdateInfo = async (assetName: string, silent: boolean) => {
  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 null;
  }

  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`
  );

  return { version, asset, checksumAsset };
};

const checkAndApply = async (
  assetName: string,
  silent: boolean
): Promise<void> => {
  const updateInfo = await getUpdateInfo(assetName, silent);
  if (!updateInfo) {
    return;
  }
  const { version, asset, checksumAsset } = updateInfo;

  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