Files
gitea-axi/test/setup.test.ts
alexion a1e68dc530
All checks were successful
CI / test (22) (pull_request) Successful in 50s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 47s
CI / test (true, 24) (push) Successful in 1m4s
CI / flake (push) Successful in 3s
feat(setup): report an unwritable target as a structured error (task 0044)
Both halves of `setup` assumed the files they manage are writable. A
declaratively managed target — read-only because a configuration manager
owns it, because a file is flagged immutable, or because the path is
root-owned — made the skill install raise a raw filesystem exception and
the hook install surface the underlying message with no guidance.

Both now fail with `TARGET_NOT_WRITABLE`, naming the file and pointing at
the general remedy: it appears to be managed by another tool, so declare
the skill or hook through that configuration instead. The error names no
particular manager, because read-only is not diagnostic of one.

A target already byte-identical to the bundled copy still succeeds —
nothing needs writing, so its being read-only is beside the point.
2026-07-20 13:25:03 -04:00

323 lines
12 KiB
TypeScript

import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { type CliResult, runCliTest } from "./harness.js";
let tempHome: string;
/** Restore write permission everywhere under `dir` so the tree can be removed. */
function restorePermissions(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
chmodSync(path, entry.isDirectory() ? 0o700 : 0o600);
if (entry.isDirectory()) {
restorePermissions(path);
}
}
}
afterEach(() => {
// Not every test creates a HOME, so this may be a directory an earlier test
// already removed.
if (tempHome && existsSync(tempHome)) {
// The read-only-target tests leave files and directories unwritable, and
// an unwritable directory cannot have its entries unlinked.
chmodSync(tempHome, 0o700);
restorePermissions(tempHome);
rmSync(tempHome, { recursive: true, force: true });
}
tempHome = "";
});
/**
* Permission bits are not enforced for root, so the read-only-target tests
* cannot express their premise there and are skipped rather than passing
* vacuously.
*/
const itUnlessRoot = process.getuid?.() === 0 ? it.skip : it;
/**
* Assert the error's wording infers no particular configuration manager.
*
* Read-only is not diagnostic of one, so naming one would be wrong for most
* readers who hit this. The paths the error quotes are exempt — they are the
* user's own, and here the temp directory sits under a `nix-shell` TMPDIR.
*/
function expectNamesNoManager(stdout: string, home: string): void {
const wording = stdout.split(home).join("<home>");
expect(wording).not.toMatch(/\b(nix|home-manager|nixos|chezmoi|ansible|stow|guix)\b/i);
}
/**
* Run `body` with `process.env.PATH` replaced. `setup hooks` reads PATH from the
* process rather than the injected environment, because it has to agree with
* the agent SDK's own probing — so this is the seam that decides whether the
* recorded hook command is the bare name or the absolute entrypoint path.
*/
async function withPath(path: string, body: () => Promise<CliResult>): Promise<CliResult> {
const original = process.env.PATH;
process.env.PATH = path;
try {
return await body();
} finally {
process.env.PATH = original;
}
}
/**
* The entrypoint `setup hooks` resolves for itself — `src/main.js` here, since
* the dist layout mirrors src/ and setup.ts locates it relative to its own
* module.
*/
function entrypointPath(): string {
return fileURLToPath(new URL("../src/main.js", import.meta.url));
}
/** Write an executable `gitea-axi` into a fresh `dir`, and return that dir. */
function writeFakeBinary(dir: string, contents: string): string {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "gitea-axi"), contents, { mode: 0o755 });
return dir;
}
/** The single command string recorded in the Claude Code SessionStart hook. */
function recordedHookCommand(home: string): string {
const settings = JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
return settings.hooks.SessionStart[0].hooks[0].command;
}
describe("setup", () => {
it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
// 1. First run on a clean HOME → installed, file now on disk.
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
expect(first.stdout).toContain("setup:");
expect(first.stdout).toContain("skill: gitea-axi");
expect(first.stdout).toContain("status: installed");
expect(existsSync(installedPath)).toBe(true);
// 2. Immediate second run, nothing changed → unchanged, still exit 0.
const second = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(second.exitCode).toBe(0);
expect(second.stdout).toContain("status: unchanged");
// 3. Tamper with the installed file → next run reports updated and restores it.
writeFileSync(installedPath, "tampered");
const third = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(third.exitCode).toBe(0);
expect(third.stdout).toContain("status: updated");
expect(readFileSync(installedPath, "utf8")).not.toBe("tampered");
});
itUnlessRoot("reports a read-only skill target as a structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
// A declaratively managed install in miniature: the file is present, its
// content differs from the bundled copy, and it cannot be written.
mkdirSync(join(tempHome, ".claude", "skills", "gitea-axi"), { recursive: true });
writeFileSync(installedPath, "managed elsewhere\n");
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(installedPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
// The bundled copy is untouched by a failed run.
expect(readFileSync(installedPath, "utf8")).toBe("managed elsewhere\n");
});
itUnlessRoot("names the directory when it is the directory that is read-only", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const skillsDir = join(tempHome, ".claude", "skills");
// Nothing installed yet, and no new entry can be created here — so the
// blocked path is the directory, not the file that would have gone in it.
mkdirSync(skillsDir, { recursive: true });
chmodSync(skillsDir, 0o555);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(join(skillsDir, "gitea-axi"));
expectNamesNoManager(stdout, tempHome);
});
itUnlessRoot("succeeds on a read-only skill target that is already up to date", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
// Same bytes the command would write, so there is nothing to write and the
// target's being read-only is beside the point.
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(0);
expect(stdout).toContain("status: unchanged");
});
});
describe("setup hooks", () => {
it("registers the SessionStart hook for all three integrations and reports a restart hint", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(0);
expect(stdout).toContain("hooks:");
expect(stdout).toContain("status: installed");
expect(stdout).toContain("Claude Code");
expect(stdout).toContain("Codex");
expect(stdout).toContain("OpenCode");
expect(stdout.toLowerCase()).toContain("restart");
// Claude Code: settings.json with a non-empty SessionStart hook array.
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
expect(existsSync(claudeSettingsPath)).toBe(true);
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
expect(Array.isArray(claudeSettings.hooks?.SessionStart)).toBe(true);
expect(claudeSettings.hooks.SessionStart.length).toBeGreaterThan(0);
// Codex: hooks.json and config.toml.
expect(existsSync(join(tempHome, ".codex", "hooks.json"))).toBe(true);
expect(existsSync(join(tempHome, ".codex", "config.toml"))).toBe(true);
// OpenCode: plugin file.
expect(
existsSync(join(tempHome, ".config", "opencode", "plugins", "axi-gitea-axi.js")),
).toBe(true);
});
it("updates the managed entry in place on re-run rather than appending a duplicate", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const first = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
const second = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
expect(second.exitCode).toBe(0);
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
expect(claudeSettings.hooks.SessionStart).toHaveLength(1);
expect(claudeSettings.hooks.SessionStart[0].hooks).toHaveLength(1);
});
it("records the bare binary name when a wrapper on PATH runs this entrypoint", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
// A wrapper-based install in miniature — a script that *invokes* the
// entrypoint, so its realpath is itself and the SDK could never match it
// from the entrypoint's side. This is the shape Nix installs.
const binDir = writeFakeBinary(
join(tempHome, "wrapper"),
`#!/bin/sh\nexec node ${entrypointPath()} "$@"\n`,
);
const { exitCode } = await withPath(`${binDir}${delimiter}${process.env.PATH ?? ""}`, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
expect(recordedHookCommand(tempHome)).toBe("gitea-axi");
});
it("falls back to the absolute entrypoint path when gitea-axi is not on PATH", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const emptyDir = join(tempHome, "empty");
mkdirSync(emptyDir, { recursive: true });
const { exitCode } = await withPath(emptyDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
const command = recordedHookCommand(tempHome);
expect(isAbsolute(command)).toBe(true);
expect(command).toBe(entrypointPath());
});
it("falls back rather than recording a name for some unrelated gitea-axi", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
// Same name on PATH, different program. Recording the bare name here would
// point the hook at a binary the user never asked to install.
const binDir = writeFakeBinary(
join(tempHome, "impostor"),
"#!/bin/sh\nexec node /somewhere/else/dist/main.js \"$@\"\n",
);
const { exitCode } = await withPath(binDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
expect(recordedHookCommand(tempHome)).toBe(entrypointPath());
});
itUnlessRoot("reports a read-only hook target as the same structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const settingsPath = join(tempHome, ".claude", "settings.json");
mkdirSync(join(tempHome, ".claude"), { recursive: true });
writeFileSync(settingsPath, "{}\n");
chmodSync(settingsPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(settingsPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
});
});
describe("setup dispatch", () => {
it("prints setup usage for setup --help", async () => {
const { stdout, exitCode } = await runCliTest(["setup", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("usage: gitea-axi setup");
});
it("rejects an unknown setup subcommand with a VALIDATION_ERROR", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const { stdout, exitCode } = await runCliTest(["setup", "bogus"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("bogus");
});
});