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
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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user