Skip to content
Open
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: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ alab pages setup
alab pages put ./site
alab review --help
alab review start ./site
alab upgrade --check
alab upgrade
```

`alab pages setup` guides Cloudflare API token validation, account selection, and project creation or reuse. Pages maintains authoritative state on one machine. A repeated `put` of the same directory retains its ID and URL. Cloudflare's default URL is used unless configuration explicitly supplies another base URL.

`alab review start` serves a local folder on loopback with a click-to-comment overlay, prints the project id and URL, and blocks until interrupted. Comments persist to `<dir>/.alab/review.json` for the agent to read. Review is local only and never deploys.

`alab upgrade` installs the latest stable GitHub release for the current operating system and CPU. It verifies the archive against that release's `SHA256SUMS`, checks the new executable before replacing the current one, and prints both complete version reports. Use `--version v1.0.0` to select a tag or `--check` to report the available release without changing the install. A non-native executable or unexpected Alab launcher requires `--force`.

See [USAGE.md](./USAGE.md) for setup, command behavior, and limitations. See [docs/integration.md](./docs/integration.md) for the internal tool interface and update procedure.

## Build
Expand Down
8 changes: 8 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ alab review cleanup [--dir <path>] [--project <id>] [--json]

`review start` takes a directory only and serves it on 127.0.0.1 with the comment overlay injected in memory. Served HTML on disk is never rewritten. It prints the project id and URL and blocks until interrupted. Human comments persist to `<dir>/.alab/review.json` with sequential c1, c2 ids that are never reused; the agent reads that file and edits the HTML. `--project <id>` resumes a known folder. `--clean` clears all comments but keeps the project; `cleanup` deletes `<dir>/.alab/` entirely.

### Upgrade

```text
alab upgrade [--check] [--version <tag>] [--force]
```

`upgrade` uses the latest stable GitHub release by default. `--version` selects a specific tag. It downloads the archive for the running macOS or Linux CPU, verifies its SHA-256 digest against the release manifest, checks the extracted executable, and replaces the current executable in its directory. It prints the old and new Alab, Pages, and Review versions. `--check` reports the available release without writing a file. A non-native executable or unexpected Alab launcher requires `--force`. The install directory must be writable.

`put` copies a static directory into the local full snapshot and deploys it. Repeating it for the same directory uses `.alab/pages.json` to retain the same ID and URL. `remove` deletes the locally known page and redeploys the remaining snapshot. A deployment failure does not roll back the preceding local change, so retry the same command after fixing access.

`--dry-run` skips Cloudflare deployment but still changes local state. `--skip-deploy` changes only local state. Use isolated `ALAB_HOME` and `ALAB_PAGES_CONTENT` directories for experiments.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "bun build --compile --minify --outfile dist/alab src/cli.ts",
"build:all": "bun scripts/build-all.ts",
"test": "bun test tests/router.test.ts",
"test": "bun test tests/router.test.ts tests/upgrade.test.ts",
"test:binary": "bash scripts/smoke-binary.sh dist/alab",
"verify:pins": "bun scripts/verify-pins.ts"
},
Expand Down
15 changes: 13 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import pagesPackage from "../vendor/pages/package.json";
import reviewPackage from "../vendor/review/package.json";
import { createPagesCommand } from "../vendor/pages/src/program.ts";
import { createReviewCommand } from "../vendor/review/src/program.ts";
import { upgrade } from "./upgrade.ts";

const HELP = `Alab ${alabPackage.version}

Expand All @@ -14,10 +15,12 @@ Usage: alab <tool> [command] [options]
Tools:
pages Publish static sites with AgentLab Pages
review Review static HTML folders with click-to-comment
upgrade Upgrade Alab from GitHub Releases

Commands:
alab pages --help Show Pages commands and first-use guidance
alab review --help Show Review commands and first-use guidance
alab upgrade --help Show upgrade options
alab --version Show Alab and integrated tool versions
alab help Show this help
`;
Expand All @@ -29,9 +32,17 @@ export function versionText(): string {
export function createAlabProgram(): Command {
const program = new Command();
program.exitOverride();
program.name("alab").description("AgentLab tools").version(versionText());
program.name("alab").description("AgentLab tools");
program.addCommand(createPagesCommand());
program.addCommand(createReviewCommand());
program.command("upgrade")
.description("Upgrade Alab from GitHub Releases")
.option("--version <tag>", "Install a specific release")
.option("--check", "Report the available release without installing")
.option("--force", "Allow replacing a non-native executable or reinstalling the same version")
.action(async (options: { version?: string; check?: boolean; force?: boolean }) => {
await upgrade(options, { currentVersion: versionText() });
});
return program;
}

Expand All @@ -45,7 +56,7 @@ export async function main(argv = process.argv): Promise<number> {
console.log(versionText());
return 0;
}
if (args[0] !== "pages" && args[0] !== "review") {
if (args[0] !== "pages" && args[0] !== "review" && args[0] !== "upgrade") {
console.error(`Error: unknown tool '${args[0]}'. Run 'alab --help'.`);
return 1;
}
Expand Down
133 changes: 133 additions & 0 deletions src/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { createHash, randomUUID } from "node:crypto";
import { chmod, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
import { gunzipSync } from "node:zlib";

const RELEASES = "https://api.github.com/repos/agentlab-in/alab-cli/releases";

type Asset = { name: string; browser_download_url: string };
type Release = { tag_name: string; assets: Asset[] };

export type UpgradeOptions = { version?: string; check?: boolean; force?: boolean };
export type UpgradeDependencies = {
fetch?: typeof fetch;
platform?: string;
arch?: string;
executablePath?: string;
currentVersion: string;
print?: (text: string) => void;
};

export function releaseTarget(platform: string, arch: string): string {
if (platform !== "darwin" && platform !== "linux") {
throw new Error(`Unsupported operating system '${platform}'. Alab releases support macOS and Linux only.`);
}
if (arch !== "arm64" && arch !== "x64") {
throw new Error(`Unsupported CPU architecture '${arch}' on ${platform}. Alab releases support arm64 and x64 only.`);
}
return `${platform}-${arch}`;
}

export function verifyArchive(archive: Uint8Array, sums: string, filename: string): void {
const entries = sums.split(/\r?\n/).filter((line) => line.trim());
const matches = entries.map((line) => /^([a-fA-F0-9]{64})\s+\*?(.+)$/.exec(line))
.filter((entry) => entry?.[2] === filename || entry?.[2] === `dist/${filename}`);
if (matches.length !== 1) throw new Error(`SHA256SUMS has no unique entry for ${filename}.`);
const actual = createHash("sha256").update(archive).digest("hex");
if (actual !== matches[0]![1]!.toLowerCase()) throw new Error(`SHA-256 mismatch for ${filename}. The installed binary was not changed.`);
}

function extractBinary(archive: Uint8Array): Uint8Array {
const tar = gunzipSync(archive);
let binary: Uint8Array | undefined;
for (let offset = 0; offset + 512 <= tar.length;) {
const header = tar.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const name = Buffer.from(header.subarray(0, 100)).toString("utf8").replace(/\0.*$/, "");
const sizeText = Buffer.from(header.subarray(124, 136)).toString("ascii").replace(/\0.*$/, "").trim();
const size = Number.parseInt(sizeText, 8);
if (!Number.isSafeInteger(size) || size < 0) throw new Error("Release archive has an invalid tar entry.");
const end = offset + 512 + size;
if (end > tar.length) throw new Error("Release archive is truncated.");
if (name === "alab" || name === "./alab") {
if (binary || (header[156] !== 0 && header[156] !== 48)) throw new Error("Release archive has an invalid alab entry.");
binary = tar.subarray(offset + 512, end);
}
offset += 512 + Math.ceil(size / 512) * 512;
}
if (!binary?.length) throw new Error("Release archive does not contain an alab binary.");
return binary;
}

async function download(url: string, request: typeof fetch): Promise<Uint8Array> {
const response = await request(url, { headers: { "User-Agent": "alab-upgrade", Accept: "application/octet-stream" } });
if (!response.ok) throw new Error(`Download failed (${response.status}) for ${url}.`);
return new Uint8Array(await response.arrayBuffer());
}

function releaseAsset(release: Release, name: string): Asset {
const asset = release.assets.find((item) => item.name === name);
if (!asset?.browser_download_url) throw new Error(`Release ${release.tag_name} does not contain ${name}.`);
return asset;
}

export async function upgrade(options: UpgradeOptions, dependencies: UpgradeDependencies): Promise<void> {
const target = releaseTarget(dependencies.platform ?? process.platform, dependencies.arch ?? process.arch);
const request = dependencies.fetch ?? fetch;
const print = dependencies.print ?? console.log;
const version = options.version?.startsWith("v") ? options.version : options.version ? `v${options.version}` : undefined;
if (version && !/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid release version '${options.version}'. Use a version such as v1.0.0.`);
}
const url = version ? `${RELEASES}/tags/${encodeURIComponent(version)}` : `${RELEASES}/latest`;
const response = await request(url, { headers: { "User-Agent": "alab-upgrade", Accept: "application/vnd.github+json" } });
if (!response.ok) throw new Error(`Could not fetch Alab release (${response.status}).`);
const release = await response.json() as Release;
if (!release.tag_name || !Array.isArray(release.assets)) throw new Error("GitHub returned invalid release metadata.");
const archiveName = `alab-${target}.tar.gz`;
const archiveAsset = releaseAsset(release, archiveName);
releaseAsset(release, "SHA256SUMS");

if (options.check) {
print(`Current:\n${dependencies.currentVersion}\nAvailable: ${release.tag_name} (${archiveName})`);
return;
}

const currentTag = `v${dependencies.currentVersion.match(/^alab (\S+)/)?.[1] ?? ""}`;
if (release.tag_name === currentTag && !options.force) {
print(`Already up to date.\n${dependencies.currentVersion}`);
return;
}

const executable = await realpath(dependencies.executablePath ?? process.execPath);
if (!dependencies.executablePath && /^(?:bun|node)(?:-[0-9.]+)?$/.test(basename(executable))) {
throw new Error("Run upgrade from a compiled Alab executable, not a Bun or Node source launcher.");
}
const currentBytes = await readFile(executable);
const magic = currentBytes.subarray(0, 4).toString("hex");
const nativeBinary = magic === "7f454c46" || ["feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca"].includes(magic);
if (!nativeBinary && !options.force) throw new Error(`Refusing to overwrite ${executable}: it is not a native executable. Use --force only if this is your Alab install.`);
const old = spawnSync(executable, ["--version"], { encoding: "utf8" });
if ((old.status !== 0 || !old.stdout.startsWith("alab ")) && !options.force) {
throw new Error(`Refusing to overwrite ${executable}: it does not run as Alab. Use --force only if this is your Alab install.`);
}

const archive = await download(archiveAsset.browser_download_url, request);
const sums = Buffer.from(await download(releaseAsset(release, "SHA256SUMS").browser_download_url, request)).toString("utf8");
verifyArchive(archive, sums, archiveName);
const binary = extractBinary(archive);
const temporary = join(dirname(executable), `.${basename(executable)}.upgrade-${process.pid}-${randomUUID()}`);
try {
await writeFile(temporary, binary, { mode: 0o755, flag: "wx" });
await chmod(temporary, 0o755);
const next = spawnSync(temporary, ["--version"], { encoding: "utf8" });
if (next.status !== 0 || !next.stdout.startsWith(`alab ${release.tag_name.replace(/^v/, "")}\n`)) {
throw new Error(`Downloaded binary does not report ${release.tag_name}. The installed binary was not changed.`);
}
await rename(temporary, executable);
print(`Before:\n${old.status === 0 && old.stdout.startsWith("alab ") ? old.stdout.trimEnd() : dependencies.currentVersion}\nAfter:\n${next.stdout.trimEnd()}`);
} finally {
await rm(temporary, { force: true });
}
}
8 changes: 8 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,12 @@ describe("Alab router", () => {
expect(result.stdout.toString()).toBe("");
expect(result.stderr.toString()).toBe("Error: unknown tool 'start'. Run 'alab --help'.\n");
});

test("routes upgrade --version to the release selector", () => {
const result = run(["upgrade", "--version", "invalid", "--check"]);

expect(result.exitCode).toBe(1);
expect(result.stdout.toString()).toBe("");
expect(result.stderr.toString()).toBe("Error: Invalid release version 'invalid'. Use a version such as v1.0.0.\n");
});
});
92 changes: 92 additions & 0 deletions tests/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createHash } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { describe, expect, test } from "bun:test";
import { releaseTarget, upgrade, verifyArchive } from "../src/upgrade.ts";

describe("Alab upgrade", () => {
test("selects exactly the four published platforms", () => {
expect(releaseTarget("darwin", "arm64")).toBe("darwin-arm64");
expect(releaseTarget("darwin", "x64")).toBe("darwin-x64");
expect(releaseTarget("linux", "arm64")).toBe("linux-arm64");
expect(releaseTarget("linux", "x64")).toBe("linux-x64");
expect(() => releaseTarget("win32", "x64")).toThrow("macOS and Linux only");
expect(() => releaseTarget("linux", "riscv64")).toThrow("arm64 and x64 only");
});

test("rejects a mismatched archive checksum", () => {
const archive = new TextEncoder().encode("archive");
const filename = "alab-linux-x64.tar.gz";
const valid = createHash("sha256").update(archive).digest("hex");
expect(() => verifyArchive(archive, `${valid} ${filename}\n`, filename)).not.toThrow();
expect(() => verifyArchive(archive, `${valid} dist/${filename}\n`, filename)).not.toThrow();
expect(() => verifyArchive(archive, `${"0".repeat(64)} ${filename}\n`, filename)).toThrow("SHA-256 mismatch");
expect(() => verifyArchive(archive, `${valid} another.tar.gz\n`, filename)).toThrow("no unique entry");
});

test("--check reports the release without downloading or changing the binary", async () => {
const calls: string[] = [];
const output: string[] = [];
const request = async (input: string | URL | Request): Promise<Response> => {
calls.push(String(input));
return Response.json({
tag_name: "v1.1.0",
assets: [
{ name: "alab-darwin-arm64.tar.gz", browser_download_url: "https://example.test/archive" },
{ name: "SHA256SUMS", browser_download_url: "https://example.test/sums" },
],
});
};
await upgrade({ check: true }, {
platform: "darwin",
arch: "arm64",
executablePath: "/does/not/exist",
currentVersion: "alab 1.0.0\npages 3.0.0 (pages-pin)\nreview 0.1.0 (review-pin)",
fetch: request as typeof fetch,
print: (text) => output.push(text),
});
expect(calls).toEqual(["https://api.github.com/repos/agentlab-in/alab-cli/releases/latest"]);
expect(output).toEqual(["Current:\nalab 1.0.0\npages 3.0.0 (pages-pin)\nreview 0.1.0 (review-pin)\nAvailable: v1.1.0 (alab-darwin-arm64.tar.gz)"]);
});

test("verifies and swaps a candidate beside the current executable", async () => {
const directory = await mkdtemp(join(tmpdir(), "alab-upgrade-test-"));
const executable = join(directory, "alab");
const before = "#!/bin/sh\necho 'alab 1.0.0'\necho 'pages old'\necho 'review old'\n";
const after = "#!/bin/sh\necho 'alab 1.1.0'\necho 'pages new'\necho 'review new'\n";
const header = Buffer.alloc(512);
header.write("alab", 0);
header.write(`${Buffer.byteLength(after).toString(8).padStart(11, "0")}\0`, 124);
header[156] = 48;
const tar = Buffer.concat([header, Buffer.from(after), Buffer.alloc((512 - Buffer.byteLength(after) % 512) % 512), Buffer.alloc(1024)]);
const archive = gzipSync(tar);
const archiveName = "alab-linux-x64.tar.gz";
const checksum = createHash("sha256").update(archive).digest("hex");
const output: string[] = [];
try {
await writeFile(executable, before);
await chmod(executable, 0o755);
const request = async (input: string | URL | Request): Promise<Response> => {
const url = String(input);
if (url.endsWith("/latest")) return Response.json({ tag_name: "v1.1.0", assets: [
{ name: archiveName, browser_download_url: "https://example.test/archive" },
{ name: "SHA256SUMS", browser_download_url: "https://example.test/sums" },
] });
if (url.endsWith("/archive")) return new Response(archive);
if (url.endsWith("/sums")) return new Response(`${checksum} ${archiveName}\n`);
throw new Error(`Unexpected request: ${url}`);
};
await upgrade({ force: true }, {
platform: "linux", arch: "x64", executablePath: executable,
currentVersion: "alab 1.0.0\npages old\nreview old",
fetch: request as typeof fetch, print: (text) => output.push(text),
});
expect(await readFile(executable, "utf8")).toBe(after);
expect(output).toEqual(["Before:\nalab 1.0.0\npages old\nreview old\nAfter:\nalab 1.1.0\npages new\nreview new"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
Loading