1a88ab3199
The v0.8 milestone: installable on any Debian-based server via docker (Gitea registry image, honest sandbox degrade), deb (amd64+arm64, advisory postinst), and arch-aware binary — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113, machine-made releases with the full 8-asset matrix + registry image), the CLI carrying the full suite (guide, channel-aware doctor), and the admin runbook (deploy/README.md, evidence-backed recipes). ---ci--- phase: 6 milestone: v0.8 status: complete requirements: covered: [REQ-8-001, REQ-8-002, REQ-8-003, REQ-8-004, REQ-8-005] partial: [] ---/ci---
183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
// In-container CLI semantics (v0.8 P2, D-066): refusals + doctor/verify
|
|
// container branches. Detection: NC_CONTAINER=1 or /.dockerenv — the
|
|
// tests set the marker env (the image sets it) with a stubbed exists() so
|
|
// no real /.dockerenv is consulted.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import type { SpawnOpts } from "../src/ctx.ts";
|
|
import type { SpawnResult } from "../src/lib/spawn.ts";
|
|
import { run } from "../src/index.ts";
|
|
import { testCtx, type TestCtx } from "./helpers.ts";
|
|
|
|
function containerCtx(overrides: {
|
|
spawn?: (cmd: string, args: string[], opts: SpawnOpts) => Promise<SpawnResult>;
|
|
envExtra?: Record<string, string | undefined>;
|
|
}): TestCtx {
|
|
const env = {
|
|
...process.env,
|
|
NC_CONTAINER: "1",
|
|
AI_WEB_STATIC_DIR: "/opt/nextcraft/web-out",
|
|
...overrides.envExtra,
|
|
} as Record<string, string | undefined>;
|
|
return testCtx({
|
|
env,
|
|
exists: (p: string) =>
|
|
p === "/opt/nextcraft/web-out/index.html" ||
|
|
p === "/.dockerenv" ||
|
|
p === "/opt/nextcraft/web-out",
|
|
spawn:
|
|
overrides.spawn ??
|
|
(async () => ({ code: 0, signal: null, timedOut: false, stdout: "", stderr: "" })),
|
|
});
|
|
}
|
|
|
|
// -- refusals: the repo/daemon commands refuse honestly, exit 1 ----------
|
|
|
|
test("container: bootstrap refuses with remediation", async () => {
|
|
const ctx = containerCtx({});
|
|
const code = await run(["bootstrap"], ctx);
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.err().includes("bootstrap"));
|
|
assert.ok(ctx.err().includes("docker pull"));
|
|
assert.ok(ctx.err().includes("doctor"));
|
|
});
|
|
|
|
test("container: deploy refuses with remediation", async () => {
|
|
const ctx = containerCtx({});
|
|
const code = await run(["deploy"], ctx);
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.err().includes("deploy"));
|
|
assert.ok(ctx.err().includes("docker pull"));
|
|
});
|
|
|
|
test("container: dev refuses with remediation (incl. -d)", async () => {
|
|
const ctx = containerCtx({});
|
|
assert.equal(await run(["dev"], ctx), 1);
|
|
assert.ok(ctx.err().includes("dev"));
|
|
assert.equal(await run(["dev", "-d"], ctx), 1);
|
|
});
|
|
|
|
test("container: stop refuses with remediation", async () => {
|
|
const ctx = containerCtx({});
|
|
assert.equal(await run(["stop"], ctx), 1);
|
|
assert.ok(ctx.err().includes("stop"));
|
|
});
|
|
|
|
test("container: log refuses with remediation", async () => {
|
|
const ctx = containerCtx({});
|
|
assert.equal(await run(["log"], ctx), 1);
|
|
assert.ok(ctx.err().includes("log"));
|
|
});
|
|
|
|
test("container: refusal text names the image-immutability + upgrade path", async () => {
|
|
const ctx = containerCtx({});
|
|
await run(["deploy"], ctx);
|
|
const err = ctx.err();
|
|
assert.ok(err.includes("immutable"), "names immutability");
|
|
assert.ok(err.includes("docker pull"), "names the docker upgrade path");
|
|
assert.ok(err.includes("nextcraft doctor"), "points at the in-container commands");
|
|
});
|
|
|
|
// -- doctor container branch -----------------------------------------------
|
|
|
|
test("container: doctor reports the docker channel + export mount", async () => {
|
|
const ctx = containerCtx({
|
|
spawn: async (cmd: string, args: string[]) => {
|
|
if (cmd === "python3" && args[0] === "--version") {
|
|
return { code: 0, signal: null, timedOut: false, stdout: "Python 3.11.9\n", stderr: "" };
|
|
}
|
|
if (cmd === "which") {
|
|
// unshare absent inside the image — the documented degrade
|
|
return { code: 1, signal: null, timedOut: false, stdout: "", stderr: "" };
|
|
}
|
|
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
|
},
|
|
});
|
|
const code = await run(["doctor"], ctx);
|
|
// unshare fails = the documented degrade → doctor exits 1 with the warn
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.out().includes("docker image"), "reports the channel");
|
|
assert.ok(ctx.out().includes("web export mounted"), "checks the export mount");
|
|
assert.ok(ctx.out().includes("documented degrade"), "names the degrade honestly");
|
|
});
|
|
|
|
test("container: doctor fails when AI_WEB_STATIC_DIR is wrong", async () => {
|
|
const ctx = containerCtx({
|
|
envExtra: { AI_WEB_STATIC_DIR: "/nope" },
|
|
spawn: async (cmd: string, args: string[]) =>
|
|
cmd === "which"
|
|
? { code: 1, signal: null, timedOut: false, stdout: "", stderr: "" }
|
|
: cmd === "python3" && args[0] === "--version"
|
|
? { code: 0, signal: null, timedOut: false, stdout: "Python 3.11.9\n", stderr: "" }
|
|
: { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" },
|
|
});
|
|
const code = await run(["doctor"], ctx);
|
|
assert.equal(code, 1);
|
|
assert.ok(
|
|
ctx.err().includes("AI_WEB_STATIC_DIR") && ctx.err().includes("export"),
|
|
"names the export failure",
|
|
);
|
|
});
|
|
|
|
// -- verify container branch ----------------------------------------------
|
|
|
|
test("container: verify probes mount + import + live listener", async () => {
|
|
const ctx = containerCtx({
|
|
spawn: async (cmd: string, args: string[]) => {
|
|
const script = args.join(" ");
|
|
if (cmd === "python3" && script.includes("import ai_service")) {
|
|
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
|
}
|
|
if (cmd === "python3" && script.includes("urllib.request")) {
|
|
return { code: 0, signal: null, timedOut: false, stdout: "HTML\n", stderr: "" };
|
|
}
|
|
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
|
},
|
|
});
|
|
const code = await run(["verify"], ctx);
|
|
assert.equal(code, 0);
|
|
assert.ok(ctx.out().includes("docker image"), "reports the channel");
|
|
assert.ok(ctx.out().includes("web export"), "checks the export");
|
|
assert.ok(ctx.out().includes("importable"), "checks the venv import");
|
|
assert.ok(ctx.out().includes("live mount probe"), "probes the listener");
|
|
});
|
|
|
|
test("container: verify catches the JSON-404 site-down class", async () => {
|
|
const ctx = containerCtx({
|
|
spawn: async (cmd: string, args: string[]) => {
|
|
const script = args.join(" ");
|
|
if (cmd === "python3" && script.includes("urllib.request")) {
|
|
return {
|
|
code: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "OTHER:{\"detail\":\"Not Found\"}\n",
|
|
stderr: "",
|
|
};
|
|
}
|
|
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
|
},
|
|
});
|
|
const code = await run(["verify"], ctx);
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.err().includes("JSON 404"), "names the site-down class");
|
|
});
|
|
|
|
// -- non-container path unchanged -------------------------------------------
|
|
|
|
test("host: commands run as before when NC_CONTAINER is unset", async () => {
|
|
const env = { ...process.env } as Record<string, string | undefined>;
|
|
delete env.NC_CONTAINER;
|
|
const ctx = testCtx({
|
|
env,
|
|
exists: () => false,
|
|
cwd: "/definitely/not/a/repo",
|
|
});
|
|
const code = await run(["bootstrap"], ctx);
|
|
// Not refused-as-container: it fails on the repo check instead (exit 1,
|
|
// clone hint — the v0.4 behavior)
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.err().includes("not inside a nextcraft clone"));
|
|
});
|