2c68b44c1a
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---
180 lines
7.3 KiB
TypeScript
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();
|
|
}
|
|
}); |