cast/test/layout-cli.test.ts

229 lines
7.9 KiB
TypeScript
Raw Permalink Normal View History

refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
import { execFile, spawn } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
import { tmp } from "./helpers/tmp.js";
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
const run = promisify(execFile);
// The layout verbs — cast versions / use / uninstall — exercised on REAL
// installed sandboxes: two versions land via the real install.sh (npm
// shimmed, as in install-sh.test.ts), then every verb runs through the PATH
// chain the way an operator's would. Assertions read symlinks and trees,
// plus each refusal's exit code and message.
const INSTALL_SH = join(process.cwd(), "install.sh");
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
const FAKE_CLI = `const path = require("path");
const root = path.resolve(__dirname, "..");
const pkg = require(path.join(root, "package.json"));
const [cmd] = process.argv.slice(2);
if (cmd === "--version" || cmd === "-V") {
console.log("cast " + pkg.version + " (" + root + ")");
process.exit(0);
}
console.log("fake-cast " + pkg.version);
`;
const NPM_SHIM = `#!/usr/bin/env bash
case "\${1:-}" in
run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
esac
`;
type Sandbox = {
root: string;
dest: string;
bindir: string;
env: Record<string, string>;
};
async function installedSandbox(versions: string[]): Promise<Sandbox> {
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites The suite allocated temp dirs at 68 sites across 21 files and removed none, accumulating ~6700 directories and 189MB per machine-day, some holding age keys. All 68 now go through a single `tmp()` helper allocating inside a per-run root that vitest's globalSetup teardown removes wholesale, and a class-guard test fails if `mkdtempSync` appears under test/ outside the helpers. The per-worker `process.once("exit")` reaper that suggests itself here does not work under vitest and fails silently: the pool recycles workers by killing them, so exit handlers registered in a test file never run. Measured — a probe test writing from an exit hook produced no file, and a full run with per-worker hooks still left 750 directories. globalSetup's teardown runs in the main process, after every worker, and vitest awaits it. Separately, and contrary to #117's framing that "cast itself does not leak": resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo into it, and never removes it, so every `cast apply`/`diff`/`capture` without --path leaked a full clone. The box that reported #117 was holding 602 such directories, 73MB of real .git trees, from the same day. The leak fires on the failure path too, since the dir is created before the clone runs. Ephemeral checkouts are now reaped on process exit — the lifetime that fits, since callers read the tree after resolveCheckout returns; a --path checkout is the operator's own tree and is never registered. Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full `npm test`, against 750 with the exit-hook design. 626 tests green. Refs #117
2026-07-19 23:38:53 +00:00
const root = tmp("cast-layout-");
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
const stubs = join(root, "stubs");
const home = join(root, "home");
const dest = join(root, "cast-home");
const bindir = join(root, "bin");
mkdirSync(stubs);
mkdirSync(home);
const fakeCli = join(root, "fake-cli.js");
writeFileSync(fakeCli, FAKE_CLI);
writeFileSync(join(stubs, "npm"), NPM_SHIM);
chmodSync(join(stubs, "npm"), 0o755);
const env = {
PATH: `${stubs}:${process.env.PATH}`,
HOME: home,
SHELL: "/bin/bash",
CAST_HOME: dest,
CAST_BIN: bindir,
CAST_NO_MODIFY_PATH: "1",
CAST_TEST_FAKECLI: fakeCli,
};
for (const version of versions) {
const src = join(root, `src-${version}`);
mkdirSync(join(src, "bin"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
writeFileSync(
join(src, "package.json"),
`${JSON.stringify({ name: "cast", version })}\n`,
);
await run("bash", [INSTALL_SH], {
env: { ...env, CAST_INSTALL_SOURCE: src },
});
}
return { root, dest, bindir, env };
}
// Through the chain, like an operator: $BINDIR/cast -> current -> version.
async function cast(
sb: Sandbox,
args: string[],
extraEnv: Record<string, string> = {},
) {
return run(join(sb.bindir, "cast"), args, {
env: { ...sb.env, ...extraEnv },
});
}
function currentVersion(sb: Sandbox): string {
return realpathSync(join(sb.dest, "current")).split("/").pop() ?? "";
}
describe("cast versions", () => {
it("lists installed versions, marking (current) and (running)", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["versions"]);
// 0.6.0 installed last, so it is the default — and, invoked through the
// chain, also the tree answering this very command.
expect(stdout).toContain(`VERSIONS (${sb.dest}`);
expect(stdout).toMatch(/^ {2}0\.5\.0$/m);
expect(stdout).toMatch(/^ {2}0\.6\.0 \(current\) \(running\)$/m);
expect(stdout).toContain("switch the default: cast use <version>");
});
it("refuses to run from a working tree — this repo checkout is not an install", async () => {
await expect(run(REAL_BIN_CAST, ["versions"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("not a versioned install"),
});
});
});
describe("cast use", () => {
it("switches the default atomically and asserts the chain answers the new version", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
expect(currentVersion(sb)).toBe("0.6.0");
const { stdout } = await cast(sb, ["use", "0.5.0"]);
expect(stdout).toContain("switched to 0.5.0 (current -> versions/0.5.0)");
expect(currentVersion(sb)).toBe("0.5.0");
const { stdout: v } = await cast(sb, ["--version"]);
expect(v).toContain("cast 0.5.0");
});
it("refuses a version that is not installed, an insane name, and a missing argument", async () => {
const sb = await installedSandbox(["0.5.0"]);
await expect(cast(sb, ["use", "9.9.9"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("no such version: 9.9.9"),
});
// The gate fires BEFORE any path is built from the name.
await expect(cast(sb, ["use", "../evil"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("not a sane version name: '../evil'"),
});
await expect(cast(sb, ["use"])).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("use needs a version"),
});
});
});
describe("cast uninstall", () => {
it("removes one non-current version and proves the absence", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["uninstall", "0.5.0"], {
CAST_YES: "1",
});
expect(stdout).toContain("removed version 0.5.0 (the default stays 0.6.0)");
expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(false);
expect(currentVersion(sb)).toBe("0.6.0");
});
it("refuses to remove the CURRENT version", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
await expect(
cast(sb, ["uninstall", "0.6.0"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("0.6.0 is the CURRENT version"),
});
expect(existsSync(join(sb.dest, "versions/0.6.0"))).toBe(true);
});
it("refuses without consent when there is no terminal to confirm on", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
// No CAST_YES, no --force, stdin is a pipe — the consent contract says
// refuse rather than assume.
const result = await new Promise<{ code: number; stderr: string }>(
(resolve) => {
const child = spawn(join(sb.bindir, "cast"), ["uninstall", "0.5.0"], {
env: sb.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (d) => {
stderr += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, stderr }));
},
);
expect(result.code).toBe(2);
expect(result.stderr).toContain("refusing to remove cast version 0.5.0");
expect(existsSync(join(sb.dest, "versions/0.5.0"))).toBe(true);
});
it("--all removes every version, current, and the PATH symlink — then re-checks", async () => {
const sb = await installedSandbox(["0.5.0", "0.6.0"]);
const { stdout } = await cast(sb, ["uninstall", "--all"], {
CAST_YES: "1",
});
expect(stdout).toContain("uninstalled — removed:");
expect(existsSync(sb.dest)).toBe(false);
// The PATH symlink resolved into this install root, so it went too —
// as a link, not just as a resolvable file.
expect(existsSync(join(sb.bindir, "cast"))).toBe(false);
const gone = await run("bash", [
"-c",
`[ ! -L '${join(sb.bindir, "cast")}' ] && echo really-gone`,
]);
expect(gone.stdout.trim()).toBe("really-gone");
});
it("a version plus --all is ambiguous, and unknown options are refused", async () => {
const sb = await installedSandbox(["0.5.0"]);
await expect(
cast(sb, ["uninstall", "0.5.0", "--all"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("ambiguous"),
});
await expect(
cast(sb, ["uninstall", "--purge"], { CAST_YES: "1" }),
).rejects.toMatchObject({
code: 2,
stderr: expect.stringContaining("unknown option: --purge"),
});
});
});