fix(setup): record the session-start hook as a search-path name (task 0043)
All checks were successful
CI / test (22) (pull_request) Successful in 47s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s
All checks were successful
CI / test (22) (pull_request) Successful in 47s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s
The SDK records a bare, upgrade-stable binary name only when a PATH entry realpath-matches the execPath it is handed. From the module-relative entrypoint that can only succeed under npm, which symlinks its bin entry straight at it; a wrapper-based install never can, because a script that invokes a file does not resolve to that file. So every wrapper install -- Nix, a shim, a generated .cmd -- recorded an absolute path that moves on upgrade, and a session-start hook that cannot execute fails silently. `setup hooks` now resolves gitea-axi on PATH itself and hands that location to the SDK, so the bare name is recorded. A candidate qualifies only if it resolves to the running entrypoint -- by realpath for a symlink, or by naming it in its text for a wrapper, following the chain, since a Nix install is two hops. A same-named binary that is some other program does not qualify, and the absolute entrypoint path stands as the fallback exactly as before. The related defect on the same line goes too: the SDK recognises its hook by finding the marker inside the recorded command, so an entrypoint path without "gitea-axi" in it made a re-run append a duplicate rather than update in place. `setup hooks` now prunes duplicates by matching the exact command it records, which is independent of that command's shape and cannot mistake another tool's hook for its own. With the coupling gone, package.nix no longer renames its build tree; the fast tier runs from /build/source and its idempotency test passes there. The help text's instruction to re-run hooks after an upgrade is deleted, having become false. Verified against the built Nix binary and a globally npm-installed pack: both record the bare name, both fall back to the absolute path when the name is absent, an impostor on PATH is refused, and re-runs leave one entry. Decision recorded as ADR 0019; ADR 0009's addendum is amended.
This commit was merged in pull request #52.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { delimiter, isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runCliTest } from "./harness.js";
|
||||
import { type CliResult, runCliTest } from "./harness.js";
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
@@ -12,6 +13,46 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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-"));
|
||||
@@ -86,6 +127,58 @@ describe("setup hooks", () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup dispatch", () => {
|
||||
|
||||
Reference in New Issue
Block a user