From d16b47b6849eb729d48c85a0d4aa4bbbd2dc7940 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:51:20 +0200 Subject: [PATCH 1/2] fix(merge): portable Node-based merge-driver prepare hook (cross-shell) Replace the guarded POSIX prepare hook with a Node script (scripts/prepare-merge-driver.mjs) so npm install/ci wire pm-cli's field-aware merge drivers identically on POSIX shells and Windows cmd.exe. The guarded POSIX 'if command -v pm; then pm merge install; fi' form cannot be parsed by Windows cmd.exe (Greptile P1 + CodeRabbit on the xydx wave), so install failed at prepare on native Windows; the bare form broke POSIX --omit=dev. The Node guard resolves pm presence via a PATH scan (PATHEXT-aware, never executes pm): absent -> silent skip; present -> 'pm merge install' fail-loud (a broken CLI surfaces non-zero). Fleet standard, tracked in companion pm-cli-website-5cx1; validated real-data on canary pm-beads PR #40. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- package.json | 2 +- scripts/prepare-merge-driver.mjs | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 scripts/prepare-merge-driver.mjs diff --git a/README.md b/README.md index f611c70..8221079 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,6 @@ This package is release-ready for GitHub, npm, and Bun-compatible installs. CI r This repo tracks its project management in `.agents/pm/` and ships a committed `.gitattributes` that maps those tracker artifacts to pm-cli's field-aware Git merge drivers, so concurrent-branch tracker edits merge cleanly. The driver definitions live in per-clone Git config; `npm install` / -`npm ci` wires them automatically via the `prepare` script (`pm merge install`). To (re)run +`npm ci` wires them automatically via the `prepare` script (a portable Node guard, `scripts/prepare-merge-driver.mjs`: it runs `pm merge install` only when the `pm` CLI is on `PATH`, and no-ops cleanly otherwise so production / `--omit=dev` installs are not broken; being Node-based it behaves identically on POSIX shells and Windows `cmd.exe`). To (re)run manually: `npm run merge:install`. After merging a branch that touched `.agents/pm/`, run `pm history-repair --all` to reconcile history verification. diff --git a/package.json b/package.json index ff7e923..57dac79 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "release:check": "npm run typecheck && npm run build && npm test && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", "prepublishOnly": "npm run release:check", "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --pm-bin ./node_modules/.bin/pm --github-step-summary", - "prepare": "if command -v pm >/dev/null 2>&1; then pm merge install; fi", + "prepare": "node scripts/prepare-merge-driver.mjs", "merge:install": "pm merge install" }, "peerDependencies": { diff --git a/scripts/prepare-merge-driver.mjs b/scripts/prepare-merge-driver.mjs new file mode 100644 index 0000000..f7a8a33 --- /dev/null +++ b/scripts/prepare-merge-driver.mjs @@ -0,0 +1,40 @@ +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, delimiter } from 'node:path'; + +// Wire pm-cli's field-aware Git merge drivers into this clone's local Git config on +// install/clone, but only when the `pm` CLI is actually available. Implemented in Node +// (not a POSIX `if ...; then ...; fi` shell guard) so it runs identically on POSIX shells +// and Windows cmd.exe (npm's default script shell) with no shell-operator parsing. + +/** + * Is the `pm` executable resolvable on PATH? Resolved by inspecting PATH directly + * (never by executing `pm`), so a present-but-broken CLI is NOT mistaken for "absent": + * absence => silent skip, presence => run fail-loud below. npm prepends + * `node_modules/.bin` to PATH for lifecycle scripts, so a devDep-installed pm is found. + */ +function pmOnPath() { + const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean); + // Windows resolves executables via PATHEXT (.CMD/.EXE/...); every other platform uses + // the bare name. The filesystem is case-insensitive on Windows, so one case suffices. + const exts = + process.platform === 'win32' + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim()) + : ['']; + for (const dir of dirs) { + for (const ext of exts) { + if (existsSync(join(dir, `pm${ext}`))) return true; + } + } + return false; +} + +if (!pmOnPath()) { + // `pm` is not installed (e.g. a production / `--omit=dev` install, or a consumer + // machine without the CLI) — skip merge-driver wiring silently, don't fail install. + process.exit(0); +} + +// `pm` IS present: wire the drivers. If this genuinely fails (e.g. a broken or +// incompatible CLI), surface it fail-loud (non-zero exit) rather than swallowing it. +execSync('pm merge install', { stdio: 'inherit' }); From 614e0b28aa4a6ef8cc34fb4ed1c09f067f9f7280 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:26:18 +0200 Subject: [PATCH 2/2] fix(merge): harden PATH resolution in prepare hook (shell-accurate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile findings on the fan-out wave — make the PATH-presence scan match shell command resolution: - reject non-executable candidates (directories / non-exec files) via statSync().isFile() + accessSync(X_OK) on POSIX, so a stray `pm` dir or data file no longer makes `execSync` fail the whole install (P2 "Existence Is Not Executability"); - treat an empty POSIX PATH entry as the current directory instead of dropping it (P1 "Empty PATH Entry Gets Dropped"); - strip surrounding double quotes from Windows PATH entries before join() (P2 "Quoted PATH Entry Skips CLI"). Re-validated real-data: present->wire; absent->skip; broken pm->fail-loud; pm-dir/non-exec-pm on PATH->skip (no fail-loud); empty-entry+pm-in-cwd->runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/prepare-merge-driver.mjs | 57 +++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/scripts/prepare-merge-driver.mjs b/scripts/prepare-merge-driver.mjs index f7a8a33..68caddb 100644 --- a/scripts/prepare-merge-driver.mjs +++ b/scripts/prepare-merge-driver.mjs @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { accessSync, statSync, constants } from 'node:fs'; import { join, delimiter } from 'node:path'; // Wire pm-cli's field-aware Git merge drivers into this clone's local Git config on @@ -7,23 +7,50 @@ import { join, delimiter } from 'node:path'; // (not a POSIX `if ...; then ...; fi` shell guard) so it runs identically on POSIX shells // and Windows cmd.exe (npm's default script shell) with no shell-operator parsing. -/** - * Is the `pm` executable resolvable on PATH? Resolved by inspecting PATH directly - * (never by executing `pm`), so a present-but-broken CLI is NOT mistaken for "absent": - * absence => silent skip, presence => run fail-loud below. npm prepends - * `node_modules/.bin` to PATH for lifecycle scripts, so a devDep-installed pm is found. - */ +const isWindows = process.platform === 'win32'; + +/** A PATH candidate counts only if it is a regular, executable file — mirroring how a + * shell resolves a bare command name. Rejects directories and (on POSIX) non-executable + * files, so a stray `pm` dir/data file never makes `execSync` fail the whole install. */ +function isExecutableFile(p) { + try { + if (!statSync(p).isFile()) return false; + } catch { + return false; // ENOENT / not accessible + } + if (isWindows) return true; // Windows keys executability off PATHEXT, not a mode bit + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Is the `pm` executable resolvable on PATH? Resolved by inspecting PATH directly + * (never by executing `pm`), so a present-but-broken CLI is NOT mistaken for "absent": + * absence => silent skip, presence => run fail-loud below. npm prepends + * `node_modules/.bin` to PATH for lifecycle scripts, so a devDep-installed pm is found. + * PATH parsing mirrors shell semantics: an empty POSIX entry means the current + * directory, and Windows entries may be wrapped in double quotes. */ function pmOnPath() { - const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean); - // Windows resolves executables via PATHEXT (.CMD/.EXE/...); every other platform uses - // the bare name. The filesystem is case-insensitive on Windows, so one case suffices. - const exts = - process.platform === 'win32' - ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim()) - : ['']; + const dirs = (process.env.PATH || '') + .split(delimiter) + .map((dir) => { + let d = dir; + if (isWindows && d.length >= 2 && d.startsWith('"') && d.endsWith('"')) { + d = d.slice(1, -1); + } + // Empty component: current dir on POSIX; ignored on Windows. + return d === '' ? (isWindows ? '' : '.') : d; + }) + .filter((d) => d !== ''); + const exts = isWindows + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim()).filter(Boolean) + : ['']; for (const dir of dirs) { for (const ext of exts) { - if (existsSync(join(dir, `pm${ext}`))) return true; + if (isExecutableFile(join(dir, `pm${ext}`))) return true; } } return false;