diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 73d1a2a37..4cff7d336 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -13,6 +13,20 @@ set -euo pipefail # Logs go to stderr because SessionStart hook stdout enters the model's context. log() { echo "[install-deps] $*" >&2; } +# Explicit command callers print the selected Node bin: +# - --node-bin selects Node without installing. +# - --prepare-node-bin also runs dependency setup. +# Claude hook invocations omit the argument and keep stdout empty. +output_mode="${1:-}" +case "${output_mode}" in + ""|--node-bin|--prepare-node-bin) ;; + *) log "unknown operation '${output_mode}'"; exit 2 ;; +esac +if (($# > 1)); then + log "expected at most one operation argument" + exit 2 +fi + # A hook's non-interactive shell never sources .bashrc, so nvm is loaded here. # - Without it, npm can resolve to a system Node (cloud images ship Node 22 at # /opt/node22). @@ -28,7 +42,7 @@ for dir in "${HOME}/.nvm" /root/.nvm; do # version is not installed. set +eu # shellcheck disable=SC1091 - . "${dir}/nvm.sh" + . "${dir}/nvm.sh" >&2 set -eu break fi @@ -53,6 +67,7 @@ payload_cwd="$(read_payload_field cwd)" checkout_lookup_dir="${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" checkout="$(git -C "${checkout_lookup_dir}" rev-parse --show-toplevel 2>/dev/null)" || { log "no git checkout resolved from '${checkout_lookup_dir}' — skipping" + [[ -z "${output_mode}" ]] || exit 1 exit 0 } @@ -138,7 +153,23 @@ persist_node_on_path() { fi } -persist_node_on_path +if [[ -n "${output_mode}" ]]; then + # Resolve inside the checkout: a version-manager shim can select another + # Node when invoked from the original shell's working directory. + physical_node_path="$(cd "${checkout}" && node -p process.execPath 2>/dev/null)" || { + log "could not resolve the selected Node in ${checkout}" + exit 1 + } + if [[ "${physical_node_path}" != /* || ! -x "${physical_node_path}" ]]; then + log "selected Node in ${checkout} returned an invalid executable path" + exit 1 + fi + node_bin="$(dirname "${physical_node_path}")" + printf '%s\n' "${node_bin}" + [[ "${output_mode}" != --node-bin ]] || exit 0 +else + persist_node_on_path +fi # The stamp, marker, and lock (each described below) live in the checkout's # git directory, which is per-worktree and never tracked. A .claude/ location diff --git a/.claude/hooks/prepare-command.sh b/.claude/hooks/prepare-command.sh new file mode 100755 index 000000000..1b653abf5 --- /dev/null +++ b/.claude/hooks/prepare-command.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Repository command callers send the checkout and a non-SessionStart event +# as JSON on stdin. Only the selected physical Node bin goes to stdout. +set -euo pipefail + +case "${1:-}" in + environment) installer_operation=--node-bin ;; + prepare) installer_operation=--prepare-node-bin ;; + *) echo "[prepare-command] expected environment or prepare" >&2; exit 2 ;; +esac +if (($# != 1)); then + echo "[prepare-command] expected exactly one operation argument" >&2 + exit 2 +fi + +hook_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec bash "${hook_directory}/install-deps.sh" "${installer_operation}" diff --git a/.gitignore b/.gitignore index 3e9e36875..53d1afaf7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ CLAUDE.local.md !.claude/hooks/ .claude/hooks/* !.claude/hooks/install-deps.sh +!.claude/hooks/prepare-command.sh TASKS.md .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 54feb0ffa..4ac588bca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,9 @@ on a folder for the full list. server.json # MCP server registry manifest render.yaml # Render Blueprint; stays at the repo root, the only place Render reads it Dockerfile # Two-target build: local (default) + remote -.claude/ # Committed Claude Code hook setup only (the rest is gitignored): settings.json registers the hook below - hooks/install-deps.sh # Runs at session start and worktree entry: loads nvm, installs dependencies when needed (npm ci, sst install), and at session start puts the checkout's Node (the .nvmrc version when nvm has it, else nvm's default alias) first on PATH for the session's later Bash commands +.claude/ # Committed dependency hooks (the rest is gitignored); settings.json registers Claude Code setup + hooks/install-deps.sh # Claude session/worktree setup; loads the checkout's Node and installs dependencies/SST types when needed + hooks/prepare-command.sh # Optional command interface: environment selects Node; prepare also runs dependency setup obsidian-headless/ # Lockfile-pinned obsidian-headless Sync CLI for the :remote image rootfs/ # Container filesystem overlay for the :remote image: s6 init chain and services in etc/s6-overlay/, and usr/local/bin/get-sync-token, an in-container terminal sign-in to Obsidian that prints the Sync token for .env templates/memory/ # About Me/ memory file templates for new vaults @@ -416,6 +417,10 @@ change path separator behavior. Native Windows execution (without Docker) is not a supported deployment and would break path handling throughout the codebase. +## Dependency setup for agent commands + +Before integrating agent command setup, read [Agent command setup](./CONTRIBUTING.md#agent-command-setup). + ## Code style @@ -968,7 +973,7 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): `init-setup-vault.test.ts`, `check-sync-vault.test.ts` (the vault check `init-setup-vault` runs), and `print-derived-env.test.ts` (the derivation that `init-derive-env` publishes) - - the Claude Code hook in `.claude/hooks/` → + - the dependency hooks in `.claude/hooks/` → `src/__tests__/install-deps.test.ts` - the release scripts in `.github/scripts/` → `src/__tests__/`: `generate-notes.test.ts`, `generate-cli-notes.test.ts`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71b50e88f..36a8f3652 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,33 @@ npx @modelcontextprotocol/inspector See the [README](./README.md#development) for full details on each mode. +## Agent command setup + +Claude Code uses the committed session/worktree hook. Other clients can opt in +through `.claude/hooks/prepare-command.sh`: + +| Operation | Behavior | +| ------------- | ------------------------------------------------- | +| `environment` | Select Node without writing install state | +| `prepare` | Select Node and wait for dependency and SST setup | + +1. Invoke the entry point with the chosen operation. Send JSON with the + checkout's `cwd` and `hook_event_name: "PreToolUse"` on stdin. +2. Require a successful exit and stdout containing one nonempty absolute + directory with an executable `node`. Both operations print only the + selected physical Node bin directory. +3. Prepend the validated directory to the original command's PATH. + +Ordinary `install-deps.sh` calls keep empty stdout and their existing behavior. +With nvm, selection uses the checkout's `.nvmrc` version when installed, then +nvm's default alias. If neither lookup finds an executable, or nvm is +unavailable, selection uses the current PATH. Setup never downloads Node. + +Setup keeps the existing per-worktree lock and nonblocking failure behavior; +an exit of zero does not prove dependencies are ready. Check the actual test or +build result. Callers must allow time for setup and its lock wait. A checkout +without this entry point has not opted in. + ## The `cli/` Package `cli/` is a separate npm package (`npx vault-cortex@latest init`) that scaffolds the diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index e168b428c..df2d1d931 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it, onTestFinished } from "vitest" /** Every shell script in the repo, sorted: what a run with no paths checks. */ const ALL_SHELL_SCRIPTS = [ ".claude/hooks/install-deps.sh", + ".claude/hooks/prepare-command.sh", ".github/scripts/generate-cli-notes.sh", ".github/scripts/generate-notes.sh", ".github/scripts/update-changelog.sh", diff --git a/src/__tests__/install-deps.test.ts b/src/__tests__/install-deps.test.ts index 0ce848ad4..8c78c28a2 100644 --- a/src/__tests__/install-deps.test.ts +++ b/src/__tests__/install-deps.test.ts @@ -13,7 +13,7 @@ import { writeFileSync, } from "node:fs" import { tmpdir } from "node:os" -import { basename, join, resolve } from "node:path" +import { basename, dirname, join, resolve } from "node:path" import { describe, expect, it, onTestFinished } from "vitest" @@ -28,6 +28,7 @@ import { describe, expect, it, onTestFinished } from "vitest" */ const HOOK_PATH = resolve(import.meta.dirname, "../../.claude/hooks/install-deps.sh") +const COMMAND_PATH = resolve(import.meta.dirname, "../../.claude/hooks/prepare-command.sh") /** Stub `npm`: appends its arguments to STUB_NPM_LOG, one call per line. An * `ls` call exits with STUB_NPM_LS_STATUS and a `ci` call with @@ -105,6 +106,7 @@ type HookRun = { type HookRunOptions = { fixture: HookFixture + operation?: "environment" | "prepare" /** What the stub `npm ls` exits with. Defaults to 0, a complete tree. */ npmLsStatus?: number /** What the stub `npm ci` exits with. Defaults to 0, a finished install. */ @@ -277,7 +279,8 @@ const hookEnv = (options: HookRunOptions): Record => { } const runHook = (options: HookRunOptions): HookRun => { - const result = spawnSync("bash", [HOOK_PATH], { + const command = options.operation ? [COMMAND_PATH, options.operation] : [HOOK_PATH] + const result = spawnSync("bash", command, { cwd: options.fixture.outsideDir, input: hookPayload(options), encoding: "utf8", @@ -423,6 +426,10 @@ const createNodeBinDir = (fixture: HookFixture, version: keyof typeof STUB_NODE_ ` echo ${STUB_NODE_ABIS[version]}`, " exit 0", "fi", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + ` echo "${join(binDir, "node")}"`, + " exit 0", + "fi", `exec "${process.execPath}" "$@"`, "", ].join("\n"), @@ -434,7 +441,380 @@ const createNodeBinDir = (fixture: HookFixture, version: keyof typeof STUB_NODE_ return binDir } +/** A version-manager shim chooses a different real executable in the target + * checkout than in the shell's original working directory. */ +const createDirectoryKeyedNodeShim = ( + fixture: HookFixture, +): { shimBin: string; checkoutBin: string } => { + const checkoutBin = createNodeBinDir(fixture, "v24") + const outsideBin = createNodeBinDir(fixture, "v20") + const shimBin = join(fixture.outsideDir, "shims") + mkdirSync(shimBin) + writeExecutable( + join(shimBin, "node"), + [ + "#!/bin/sh", + `if [ "$PWD" = "${fixture.checkout}" ]; then`, + ` exec "${join(checkoutBin, "node")}" "$@"`, + "fi", + `exec "${join(outsideBin, "node")}" "$@"`, + "", + ].join("\n"), + ) + return { shimBin, checkoutBin } +} + describe("install-deps hook", () => { + describe("repository command interface", () => { + it.each([ + { + label: "a missing wrapper operation", + path: COMMAND_PATH, + arguments: [], + message: "[prepare-command] expected environment or prepare\n", + }, + { + label: "an unknown wrapper operation", + path: COMMAND_PATH, + arguments: ["unknown"], + message: "[prepare-command] expected environment or prepare\n", + }, + { + label: "an unknown installer operation", + path: HOOK_PATH, + arguments: ["--unknown"], + message: "[install-deps] unknown operation '--unknown'\n", + }, + { + label: "trailing arguments after wrapper environment", + path: COMMAND_PATH, + arguments: ["environment", "--verbose"], + message: "[prepare-command] expected exactly one operation argument\n", + }, + { + label: "trailing arguments after wrapper prepare", + path: COMMAND_PATH, + arguments: ["prepare", "--verbose"], + message: "[prepare-command] expected exactly one operation argument\n", + }, + { + label: "trailing arguments after installer environment selection", + path: HOOK_PATH, + arguments: ["--node-bin", "--verbose"], + message: "[install-deps] expected at most one operation argument\n", + }, + { + label: "trailing arguments after installer preparation", + path: HOOK_PATH, + arguments: ["--prepare-node-bin", "--verbose"], + message: "[install-deps] expected at most one operation argument\n", + }, + ])( + "rejects $label before touching install state", + ({ path, arguments: commandArguments, message }) => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + + const run = spawnSync("bash", [path, ...commandArguments], { + cwd: fixture.outsideDir, + input: hookPayload({ fixture }), + encoding: "utf8", + env: hookEnv({ fixture }), + }) + + expect({ + status: run.status, + stdout: run.stdout, + stderr: run.stderr, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 2, + stdout: "", + stderr: message, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }, + ) + + it.each([ + { label: "environment selection", operation: "environment" }, + { label: "dependency preparation", operation: "prepare" }, + { label: "a Claude hook", operation: undefined }, + ] as const)("keeps nvm startup output off stdout for $label", ({ operation }) => { + const fixture = createHookFixture() + writeFileSync( + join(fixture.outsideDir, ".nvm", "nvm.sh"), + "echo 'nvm startup output'\nfalse\n", + ) + + const run = runHook({ fixture, ...(operation ? { operation } : {}) }) + + expect({ ...run, npmCalls: recordedNpmCalls(fixture) }).toEqual({ + status: 0, + stdout: operation ? `${dirname(realpathSync(process.execPath))}\n` : "", + stderr: + "nvm startup output\n" + + (operation === "environment" + ? "" + : `[install-deps] node_modules current in ${fixture.checkout} — nothing to do\n`), + npmCalls: [], + }) + }) + + it("diagnoses a failed runtime probe before printing a bin or touching install state", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + writeExecutable( + join(fixture.stubBinDir, "node"), + [ + "#!/bin/sh", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + " echo 'runtime probe failed' >&2", + " exit 9", + "fi", + `exec "${process.execPath}" "$@"`, + "", + ].join("\n"), + ) + + const run = runHook({ fixture, operation: "prepare" }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] could not resolve the selected Node in ${fixture.checkout}\n`, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }) + + it("selects a valid Node bin without touching missing dependencies, SST types or interrupted-install state", () => { + const fixture = createHookFixture() + rmSync(join(fixture.checkout, "node_modules"), { recursive: true }) + rmSync(join(fixture.checkout, ".sst"), { recursive: true }) + leaveMarker(fixture) + leaveOlderStamp(fixture) + const claudeEnvFile = join(fixture.outsideDir, "foreign-env") + writeFileSync(claudeEnvFile, "keep this file\n") + + const run = runHook({ fixture, operation: "environment", claudeEnvFile }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + dependenciesCreated: existsSync(join(fixture.checkout, "node_modules")), + sstCreated: existsSync(join(fixture.checkout, ".sst")), + envFile: readFileSync(claudeEnvFile, "utf8"), + }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: "", + npmCalls: [], + marker: `${fixture.installIdentity}\n`, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + dependenciesCreated: false, + sstCreated: false, + envFile: "keep this file\n", + }) + }) + + it.each([{ dependencyState: "missing" }, { dependencyState: "stale" }])( + "prepares $dependencyState dependencies and missing SST types while printing only the selected bin", + ({ dependencyState }) => { + const fixture = createHookFixture() + + if (dependencyState === "missing") { + rmSync(join(fixture.checkout, "node_modules"), { recursive: true }) + } else { + leaveOlderStamp(fixture) + } + rmSync(join(fixture.checkout, ".sst"), { recursive: true }) + writeExecutable( + join(fixture.stubBinDir, "npm"), + NPM_STUB.replace('case "$*" in', 'echo "npm output"\ncase "$*" in'), + ) + writeExecutable( + join(fixture.stubBinDir, "npx"), + NPX_STUB.replace("exit 0\n", "echo 'sst output'\nexit 0\n"), + ) + const claudeEnvFile = join(fixture.outsideDir, "foreign-env") + writeFileSync(claudeEnvFile, "keep this file\n") + + const run = runHook({ fixture, operation: "prepare", claudeEnvFile }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + envFile: readFileSync(claudeEnvFile, "utf8"), + }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: [ + `[install-deps] installing dependencies in ${fixture.checkout} (node ${process.version})`, + "npm output", + "[install-deps] install complete", + `[install-deps] installing SST platform types in ${fixture.checkout}`, + "sst output", + "", + ].join("\n"), + npmCalls: ["ci", "npx sst install"], + marker: null, + stamp: `${fixture.installIdentity}\n`, + envFile: "keep this file\n", + }) + }, + ) + + it("reports the selected bin after a failed install while preserving the retry marker", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + + const run = runHook({ fixture, operation: "prepare", npmCiStatus: 1 }) + + expect({ ...run, npmCalls: recordedNpmCalls(fixture), ...installState(fixture) }).toEqual({ + status: 0, + stdout: `${dirname(realpathSync(process.execPath))}\n`, + stderr: [ + `[install-deps] installing dependencies in ${fixture.checkout} (node ${process.version})`, + "[install-deps] npm ci failed — the session continues without dependencies; the marker forces a retry next session", + "", + ].join("\n"), + npmCalls: ["ci"], + marker: `${fixture.installIdentity}\n`, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + }) + }) + + it.each([ + { label: "a relative runtime path", pathKind: "relative" }, + { label: "a missing runtime executable", pathKind: "missing" }, + { label: "a non-executable runtime file", pathKind: "non-executable" }, + ])("rejects $label without printing a bin or touching install state", ({ pathKind }) => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + const runtimePath = + pathKind === "relative" ? "relative/node" : join(fixture.outsideDir, "runtime") + + if (pathKind === "relative") { + mkdirSync(join(fixture.outsideDir, "relative")) + writeExecutable(join(fixture.outsideDir, runtimePath), "#!/bin/sh\nexit 0\n") + } + if (pathKind === "non-executable") writeFileSync(runtimePath, "not executable\n") + writeExecutable( + join(fixture.stubBinDir, "node"), + [ + "#!/bin/sh", + 'if [ "$1" = "-p" ] && [ "$2" = "process.execPath" ]; then', + ` echo "${runtimePath}"`, + " exit 0", + "fi", + `exec "${process.execPath}" "$@"`, + "", + ].join("\n"), + ) + + const run = runHook({ fixture, operation: "prepare" }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] selected Node in ${fixture.checkout} returned an invalid executable path\n`, + npmCalls: [], + marker: null, + stamp: `${OLDER_INSTALL_IDENTITY}\n`, + lockCreated: false, + }) + }) + + it.each([{ operation: "environment" }, { operation: "prepare" }] as const)( + "uses the .nvmrc Node despite the wrong initial PATH for $operation", + ({ operation }) => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + writeFileSync(join(fixture.checkout, ".nvmrc"), "24\n") + writeNvmStandIn(fixture) + const launchingBin = createNodeBinDir(fixture, "v22") + const nvmrcBin = createNodeBinDir(fixture, "v24") + const defaultBin = createNodeBinDir(fixture, "v20") + + const run = runHook({ + fixture, + operation, + path: `${launchingBin}:${fixture.stubBinDir}:${runnerPath()}`, + nvmAnswers: { nvmrcNode: join(nvmrcBin, "node"), defaultNode: join(defaultBin, "node") }, + }) + + expect({ + status: run.status, + stdout: run.stdout, + npmCalls: recordedNpmCalls(fixture), + }).toEqual({ + status: 0, + stdout: `${nvmrcBin}\n`, + npmCalls: operation === "prepare" ? ["v24 ci"] : [], + }) + }, + ) + + it("returns the checkout's physical runtime so a prefix command outside the checkout uses the installer's Node", () => { + const fixture = createHookFixture() + leaveOlderStamp(fixture) + const { shimBin, checkoutBin } = createDirectoryKeyedNodeShim(fixture) + const path = `${shimBin}:${fixture.stubBinDir}:${runnerPath()}` + writeExecutable( + join(fixture.stubBinDir, "npm"), + '#!/bin/sh\nprintf "abi-%s %s\\n" "$(node -p process.versions.modules)" "$*" >> "$STUB_NPM_LOG"\n', + ) + + const run = runHook({ fixture, operation: "prepare", path }) + const executionOptions = { + cwd: fixture.outsideDir, + encoding: "utf8" as const, + env: hookEnv({ fixture, path: `${run.stdout.trimEnd()}:${path}` }), + } + const runtime = spawnSync("node", ["-p", "process.versions.modules"], executionOptions) + const command = spawnSync("npm", ["--prefix", fixture.checkout, "test"], executionOptions) + + expect({ + status: run.status, + stdout: run.stdout, + runtime: { status: runtime.status, stdout: runtime.stdout, stderr: runtime.stderr }, + command: { status: command.status, stdout: command.stdout, stderr: command.stderr }, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + }).toEqual({ + status: 0, + stdout: `${checkoutBin}\n`, + runtime: { status: 0, stdout: "137\n", stderr: "" }, + command: { status: 0, stdout: "", stderr: "" }, + npmCalls: ["abi-137 ci", `v24 --prefix ${fixture.checkout} test`], + marker: null, + stamp: `${fixture.lockfileHash} node-abi-137\n`, + }) + }) + }) + it("resolves npm and npx to the stubs, with no nvm command, after loading the HOME nvm.sh the way the hook does", () => { const fixture = createHookFixture() // The hook's own condition: it loads $HOME/.nvm/nvm.sh only when the file @@ -494,15 +874,48 @@ describe("install-deps hook", () => { const run = runHook({ fixture, payloadCwd: null, claudeProjectDir: fixture.outsideDir }) expect({ - status: run.status, - stderr: run.stderr, + ...run, npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), }).toEqual({ status: 0, + stdout: "", stderr: `[install-deps] no git checkout resolved from '${fixture.outsideDir}' — skipping\n`, npmCalls: [], + marker: null, + stamp: null, + lockCreated: false, }) }) + + it.each([{ operation: "environment" }, { operation: "prepare" }] as const)( + "reports no checkout for $operation without touching install state", + ({ operation }) => { + const fixture = createHookFixture() + + const run = runHook({ + fixture, + payloadCwd: fixture.outsideDir, + operation, + }) + + expect({ + ...run, + npmCalls: recordedNpmCalls(fixture), + ...installState(fixture), + lockCreated: existsSync(join(fixture.stateDir, "install-deps.lock")), + }).toEqual({ + status: 1, + stdout: "", + stderr: `[install-deps] no git checkout resolved from '${fixture.outsideDir}' — skipping\n`, + npmCalls: [], + marker: null, + stamp: null, + lockCreated: false, + }) + }, + ) }) describe("with a marker left by an interrupted install", () => { @@ -1165,20 +1578,7 @@ describe("install-deps hook", () => { it("records the ABI of the Node a directory-keyed shim picks in the checkout, where npm ci runs", () => { const fixture = createHookFixture() leaveOlderStamp(fixture) - const shimBin = join(fixture.outsideDir, "shims") - mkdirSync(shimBin) - writeExecutable( - join(shimBin, "node"), - [ - "#!/bin/sh", - 'if [ "$1" = "-p" ] && [ "$2" = "process.versions.modules" ]; then', - ` if [ "$PWD" = "${fixture.checkout}" ]; then echo ${STUB_NODE_ABIS.v24}; else echo ${STUB_NODE_ABIS.v20}; fi`, - " exit 0", - "fi", - `exec "${process.execPath}" "$@"`, - "", - ].join("\n"), - ) + const { shimBin } = createDirectoryKeyedNodeShim(fixture) const run = runHook({ fixture, path: `${shimBin}:${fixture.stubBinDir}:${runnerPath()}` })