feat(setup): report an unwritable target as a structured error (task 0044)
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

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.
This commit was merged in pull request #53.
This commit is contained in:
2026-07-20 13:25:03 -04:00
parent 27aad04984
commit a1e68dc530
5 changed files with 283 additions and 23 deletions

View File

@@ -1,4 +1,13 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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";
@@ -7,12 +16,49 @@ 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(() => {
if (tempHome) {
// 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
@@ -78,6 +124,60 @@ describe("setup", () => {
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", () => {
@@ -179,6 +279,25 @@ describe("setup hooks", () => {
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", () => {