Files
nextcraft/apps/cli/tests/install-script.test.ts
T
CIAgent 2c68b44c1a merge: milestone/v0.4-distribution → main (v0.4 Distribution & Bootstrap CLI complete)
The nextcraft bootstrap CLI ships: doctor/bootstrap/verify/dev commands, a
one-liner install script with checksum + version integrity gates, and linux
x64 SEA binaries published on every release going forward (v0.3.2 onward).
Fresh-clone E2E proven; 34 CLI tests + full monorepo gates green.

Escalation note: merge_to_main hook — proceeding per full autonomy + founder
directive D-016 (streamlined install + bootstrap CLI + ongoing binaries,
recorded at P0 SPECIFY).

---ci---
phase: 4
milestone: v0.4
status: complete
requirements:
  covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005]
  partial: []
---/ci---
2026-09-12 23:17:43 +00:00

180 lines
7.3 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const installSh = join(repoRoot, "scripts", "install.sh");
function sha256(p: string): string {
return createHash("sha256").update(readFileSync(p)).digest("hex");
}
async function serve(
setup: (srvDir: string, base: () => string) => void,
): Promise<{ url: string; stop: () => void }> {
const srvDir = mkdtempSync(join(tmpdir(), "nc-srv-"));
const port = 30000 + Math.floor(Math.random() * 20000);
const base = () => `http://127.0.0.1:${port}`;
setup(srvDir, base);
const child = spawn("python3", ["-m", "http.server", String(port), "--directory", srvDir], {
stdio: "ignore",
});
for (let i = 0; i < 50; i++) {
const probe = spawnSync("curl", ["-fsS", `${base()}/api/v1/repos/coreci/nextcraft/releases/latest`], {
encoding: "utf8",
});
if (probe.status === 0) break;
await new Promise((r) => setTimeout(r, 100));
}
return { url: base(), stop: () => child.kill("SIGTERM") };
}
function apiManifestDir(srvDir: string, manifest: object): string {
const dir = join(srvDir, "api/v1/repos/coreci/nextcraft/releases");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "latest"), JSON.stringify(manifest));
return dir;
}
async function runInstall(home: string, srvUrl: string) {
return spawnSync("sh", [installSh], {
env: { ...process.env, HOME: home, NEXTCRAFT_FORGE_BASE: srvUrl, DEST: join(home, "bin") },
encoding: "utf8",
timeout: 30000,
});
}
test("install.sh: checksum mismatch = hard stop, no install", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-"));
const srv = await serve((srvDir, base) => {
writeFileSync(join(srvDir, "ping"), "pong");
writeFileSync(join(srvDir, "nextcraft-linux-x64"), "#!/bin/sh\necho fake\n");
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), "deadbeef nextcraft-linux-x64\n");
apiManifestDir(srvDir, {
tag_name: "v9.9.9",
assets: [
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
],
});
});
try {
const res = await runInstall(home, srv.url);
assert.notEqual(res.status, 0);
assert.ok(res.stderr.includes("CHECKSUM MISMATCH"));
assert.ok(!existsSync(join(home, "bin", "nextcraft")));
} finally {
srv.stop();
}
});
test("install.sh: valid checksum installs binary and reports version", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-ok-"));
const srv = await serve((srvDir, base) => {
const bin = join(srvDir, "nextcraft-linux-x64");
writeFileSync(join(srvDir, "ping"), "pong");
writeFileSync(bin, "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v9.9.9; exit 0; fi\necho v9.9.9-installed\nexit 0\n");
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), `${sha256(bin)} nextcraft-linux-x64\n`);
apiManifestDir(srvDir, {
tag_name: "v9.9.9",
assets: [
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
],
});
});
try {
const res = await runInstall(home, srv.url);
assert.equal(res.status, 0, res.stderr);
assert.ok(existsSync(join(home, "bin", "nextcraft")));
const run = spawnSync(join(home, "bin", "nextcraft"), [], { encoding: "utf8" });
assert.ok(run.stdout.includes("v9.9.9-installed"));
} finally {
srv.stop();
}
});
test("install.sh: version-mismatching binary is rejected (G-102 install-time integrity)", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-mm-"));
const srv = await serve((srvDir, base) => {
const bin = join(srvDir, "nextcraft-linux-x64");
writeFileSync(bin, "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v0.0.0-wrong; exit 0; fi\nexit 0\n");
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), `${sha256(bin)} nextcraft-linux-x64\n`);
apiManifestDir(srvDir, {
tag_name: "v9.9.9",
assets: [
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
],
});
});
try {
const res = await runInstall(home, srv.url);
assert.notEqual(res.status, 0, "mismatched version must not install cleanly");
assert.ok(res.stderr.includes("integrity mismatch"), `stderr: ${res.stderr}`);
} finally {
srv.stop();
}
});
test("install.sh: release without binary assets degrades to source instructions, exit 0", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-nb-"));
const srv = await serve((srvDir) => {
writeFileSync(join(srvDir, "ping"), "pong");
apiManifestDir(srvDir, { tag_name: "v0.2.8", assets: [] });
});
try {
const res = await runInstall(home, srv.url);
assert.equal(res.status, 0);
assert.ok(res.stdout.includes("git clone"));
assert.ok(!existsSync(join(home, "bin", "nextcraft")));
} finally {
srv.stop();
}
});
test("release-assets.sh: token resolution reads .env* files only — poisoned shell env is never used", async () => {
const tmpHome = mkdtempSync(join(tmpdir(), "nc-home-token-"));
mkdirSync(join(tmpHome, ".ciagent"), { recursive: true });
writeFileSync(join(tmpHome, ".ciagent", ".env.secrets"), "GITEA_TOKEN=real-token-from-file\n");
const fakeBin = join(tmpHome, "apps/cli/dist");
mkdirSync(fakeBin, { recursive: true });
writeFileSync(join(fakeBin, "nextcraft-linux-x64"), "fake-binary-bytes\n");
writeFileSync(join(fakeBin, "nextcraft-linux-x64.sha256"), "abc123 nextcraft-linux-x64\n");
const srv = await serve((srvDir) => {
const tagsDir = join(srvDir, "api/v1/repos/coreci/nextcraft/releases/tags");
mkdirSync(tagsDir, { recursive: true });
writeFileSync(join(tagsDir, "v0.3.2"), JSON.stringify({ id: 42, tag_name: "v0.3.2" }));
});
try {
const env: Record<string, string> = {
PATH: process.env.PATH ?? "",
HOME: tmpHome,
GITEA_TOKEN: "poisoned-shell-token",
NEXTCRAFT_FORGE_BASE: srv.url,
};
const res = spawnSync(
"bash",
[
"-c",
`NEXTCRAFT_FORGE_BASE='${srv.url}' GITEA_TOKEN=poisoned-shell-token bash '${join(repoRoot, "scripts", "release-assets.sh")}' v0.3.2 --dry-run`,
],
{ cwd: tmpHome, env, encoding: "utf8", timeout: 20000 },
);
assert.equal(res.stdout.includes("poisoned-shell-token"), false, "poisoned token never in stdout");
assert.equal(res.stderr.includes("poisoned-shell-token"), false, "poisoned token never in stderr");
assert.equal(res.status, 0, `dry-run should succeed against fixture, stderr: ${res.stderr}`);
assert.ok(res.stdout.includes("DRY-RUN would upload"), `expected dry-run upload lines, got: ${res.stdout}`);
assert.ok(res.stdout.includes("nextcraft-linux-x64"));
} finally {
srv.stop();
}
});