diff --git a/.agents/pm/history/ops-mqdi.jsonl b/.agents/pm/history/ops-mqdi.jsonl new file mode 100644 index 00000000..a4f3afee --- /dev/null +++ b/.agents/pm/history/ops-mqdi.jsonl @@ -0,0 +1,3 @@ +{"hash_algorithm":"sha256","ts":"2026-09-22T20:52:55.599Z","author":"claude-hub","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"e0824cdc8d5a73748e09fe93","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"medium","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null,"version":{"value":"2.1.280","source":"probe"}},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"ops-mqdi"},{"op":"add","path":"/metadata/title","value":"Guarded merge-driver launcher: omit-dev clone installs complete instead of failing on the devDependency import"},{"op":"add","path":"/metadata/description","value":"The canonical consumer launcher statically imports pm-ops/merge-driver, but pm-ops is a devDependency. npm install --omit=dev with scripts enabled (a source checkout) fails with ERR_MODULE_NOT_FOUND in prepare before any fallback runs. Raised by CodeRabbit on pm-web#156 and Greptile on pm-slack-standup#89 and pm-changelog#207; hub item pm-cli-website-xy19."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["fleet","launcher","merge-driver","omit-dev"]},{"op":"add","path":"/metadata/created_at","value":"2026-09-22T20:52:55.599Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-09-22T20:52:55.599Z"},{"op":"add","path":"/metadata/author","value":"claude-hub"},{"op":"add","path":"/metadata/acceptance_criteria","value":"templates/prepare-merge-driver.ts ships in the package and imports only node builtins; pm-ops/merge-driver/prepare is an exported entry; fixture tests run the shipped template for: pm-ops absent (exit 0, exactly one notice), current (runs pm merge install), failing pm (status propagated), stale pm-ops without the export and pm-ops with a missing entry file (both fail loudly); release:check exits 0 with 100/100/100/100 coverage"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"2ff4c90651f24c68d5d25f6e7f8be6d6b56e8f07794223995d8ef80eca6e1cac","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"eda2c585a739e2262aa5aa5618eae9d6fbae5ce462f5bce7dfda924e7f8bb546"} +{"hash_algorithm":"sha256","ts":"2026-09-22T20:53:02.266Z","author":"claude-hub","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"e0824cdc8d5a73748e09fe93","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"medium","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.280","source":"probe"}},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-22T20:53:02.266Z"},{"op":"add","path":"/metadata/files","value":[{"path":"merge-driver-prepare.ts","scope":"project"},{"path":"templates/prepare-merge-driver.ts","scope":"project"},{"path":"test/merge-driver-launcher.test.ts","scope":"project"}]}],"before_hash":"2ff4c90651f24c68d5d25f6e7f8be6d6b56e8f07794223995d8ef80eca6e1cac","after_hash":"b8f1e6c5026583483b5b79e02f5c37a692bc640d02e256c0ad1d470fa179a2fc","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"2f6d0bbb7445f9dd9deb72bf5cd5826a71935753e78cb0c5aa3ceb293621e309"} +{"hash_algorithm":"sha256","ts":"2026-09-22T20:53:02.793Z","author":"claude-hub","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5-5","agent_model_source":"probe","agent_instance":"e0824cdc8d5a73748e09fe93","agent_provenance":{"model":{"value":"claude-opus-5-5","source":"probe"},"effort":{"value":"medium","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.280","source":"probe"}},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-22T20:53:02.793Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/merge-driver-launcher.test.ts","scope":"project","provenance":{"author":"claude-hub","created_at":"2026-09-22T20:53:02.768Z","source_kind":"local_mutation","source_ref":"guarded-merge-driver-launcher-omit-dev"}}]}],"before_hash":"b8f1e6c5026583483b5b79e02f5c37a692bc640d02e256c0ad1d470fa179a2fc","after_hash":"0f5806329a2aaf916f0334a5469e19def79152de52c02c0c009661491eb2de5b","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"resolved","resolver":"claude_session_file","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"049394a38822ffb6ef192ed5aa89f255a05469e63332a278efb5e9cfc991c735"} diff --git a/.agents/pm/issues/ops-mqdi.toon b/.agents/pm/issues/ops-mqdi.toon new file mode 100644 index 00000000..eaa58fb0 --- /dev/null +++ b/.agents/pm/issues/ops-mqdi.toon @@ -0,0 +1,18 @@ +id: ops-mqdi +title: "Guarded merge-driver launcher: omit-dev clone installs complete instead of failing on the devDependency import" +description: "The canonical consumer launcher statically imports pm-ops/merge-driver, but pm-ops is a devDependency. npm install --omit=dev with scripts enabled (a source checkout) fails with ERR_MODULE_NOT_FOUND in prepare before any fallback runs. Raised by CodeRabbit on pm-web#156 and Greptile on pm-slack-standup#89 and pm-changelog#207; hub item pm-cli-website-xy19." +type: Issue +status: in_progress +priority: 1 +tags[4]: fleet,launcher,merge-driver,omit-dev +created_at: "2026-09-22T20:52:55.599Z" +updated_at: "2026-09-22T20:53:02.793Z" +author: claude-hub +acceptance_criteria: "templates/prepare-merge-driver.ts ships in the package and imports only node builtins; pm-ops/merge-driver/prepare is an exported entry; fixture tests run the shipped template for: pm-ops absent (exit 0, exactly one notice), current (runs pm merge install), failing pm (status propagated), stale pm-ops without the export and pm-ops with a missing entry file (both fail loudly); release:check exits 0 with 100/100/100/100 coverage" +files[3]{path,scope}: + merge-driver-prepare.ts,project + templates/prepare-merge-driver.ts,project + test/merge-driver-launcher.test.ts,project +tests[1]{command,scope,provenance{author,created_at,source_kind,source_ref}}: + node --test test/merge-driver-launcher.test.ts,project,claude-hub,"2026-09-22T20:53:02.768Z",local_mutation,guarded-merge-driver-launcher-omit-dev +body: "" diff --git a/README.md b/README.md index fd062476..ed1e80f0 100644 --- a/README.md +++ b/README.md @@ -294,19 +294,28 @@ Fleet totals are intentionally **not** pre-aggregated — expose per-repo series Git never clones `.git/config`, so every fleet package has to run `pm merge install` from its npm `prepare` script or concurrent agents conflict on tracker files. This package is the one -canonical implementation. Consumers ship a one-line launcher instead of vendoring JavaScript: +canonical implementation. -```ts -// scripts/prepare-merge-driver.ts -import { runPrepareMergeDriver } from "pm-ops/merge-driver"; -process.exitCode = runPrepareMergeDriver(); -``` +Consumers copy [`templates/prepare-merge-driver.ts`](templates/prepare-merge-driver.ts) unchanged to +`scripts/prepare-merge-driver.ts` and set `"prepare": "node scripts/prepare-merge-driver.ts"`. The +template imports **nothing** from pm-ops. pm-ops is a devDependency, so a checkout installed with +`npm install --omit=dev` (scripts enabled) does not have it, and a static +`import … from "pm-ops/merge-driver"` would fail at module load, before any fallback could run. +Dynamic `import()` is forbidden by the fleet lint gate. Instead the template resolves +`pm-ops/merge-driver/prepare` from the package root and runs it in a child process: + +- **pm-ops not installed** (`--omit=dev`): exits `0` after exactly one notice line +- **pm-ops too old to export the entry, or its entry file missing**: fails the install loudly, and never skips +- **pm-ops installed**: runs `runPrepareMergeDriver` (below) and propagates its exit status + +Fixture tests in `test/merge-driver-launcher.test.ts` execute the shipped template against each of +those consumer layouts with a stub `pm` on PATH. -`runPrepareMergeDriver` resolves the real `pm` launcher on the supplied PATH/platform, then: +`runPrepareMergeDriver` (also exported from `pm-ops/merge-driver`) resolves the real `pm` launcher on the supplied PATH/platform, then: -- **missing `pm`** — exits `0` and prints one notice line, so production / `--omit=dev` installs are not broken -- **present `pm` whose `pm merge install` fails** — non-zero exit with that command's output -- **Windows** — honours quoted PATH entries and PATHEXT shims, and sets `shell: true` only on `win32` so `.cmd` launchers run; the POSIX path is never faked +- **missing `pm`**: exits `0` and prints one notice line +- **present `pm` whose `pm merge install` fails**: non-zero exit with that command's output +- **Windows**: honours quoted PATH entries and PATHEXT shims, and sets `shell: true` only on `win32` so `.cmd` launchers run; the POSIX path is never faked This repository's own `prepare` script is that launcher (with an `isMainInvocation` guard so the suite can import it). To (re)run manually: `npm run merge:install`. ## Canonical code-quality exports diff --git a/dist/merge-driver-prepare.d.ts b/dist/merge-driver-prepare.d.ts new file mode 100644 index 00000000..3ab39cc5 --- /dev/null +++ b/dist/merge-driver-prepare.d.ts @@ -0,0 +1,15 @@ +/** + * Executable entry that registers pm's merge drivers for a consumer's npm + * `prepare` hook, published as `pm-ops/merge-driver/prepare`. + * + * Consumer launchers must not import pm-ops: it is a devDependency, so an + * `npm install --omit=dev` checkout does not have it and a static import fails + * before any fallback can run. The canonical launcher + * (`templates/prepare-merge-driver.ts`) instead resolves this subpath and runs + * it in a child process. Running it performs {@link runPrepareMergeDriver} and + * reports its status as the process exit code. + * + * @packageDocumentation + */ +export {}; +//# sourceMappingURL=merge-driver-prepare.d.ts.map \ No newline at end of file diff --git a/dist/merge-driver-prepare.d.ts.map b/dist/merge-driver-prepare.d.ts.map new file mode 100644 index 00000000..09eaee82 --- /dev/null +++ b/dist/merge-driver-prepare.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"merge-driver-prepare.d.ts","sourceRoot":"","sources":["../merge-driver-prepare.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"} \ No newline at end of file diff --git a/dist/merge-driver-prepare.js b/dist/merge-driver-prepare.js new file mode 100644 index 00000000..12ef26f9 --- /dev/null +++ b/dist/merge-driver-prepare.js @@ -0,0 +1,16 @@ +/** + * Executable entry that registers pm's merge drivers for a consumer's npm + * `prepare` hook, published as `pm-ops/merge-driver/prepare`. + * + * Consumer launchers must not import pm-ops: it is a devDependency, so an + * `npm install --omit=dev` checkout does not have it and a static import fails + * before any fallback can run. The canonical launcher + * (`templates/prepare-merge-driver.ts`) instead resolves this subpath and runs + * it in a child process. Running it performs {@link runPrepareMergeDriver} and + * reports its status as the process exit code. + * + * @packageDocumentation + */ +import { runPrepareMergeDriver } from "./merge-driver.js"; +process.exitCode = runPrepareMergeDriver(); +//# sourceMappingURL=merge-driver-prepare.js.map \ No newline at end of file diff --git a/dist/merge-driver-prepare.js.map b/dist/merge-driver-prepare.js.map new file mode 100644 index 00000000..b503ca79 --- /dev/null +++ b/dist/merge-driver-prepare.js.map @@ -0,0 +1 @@ +{"version":3,"file":"merge-driver-prepare.js","sourceRoot":"","sources":["../merge-driver-prepare.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,OAAO,CAAC,QAAQ,GAAG,qBAAqB,EAAE,CAAC"} \ No newline at end of file diff --git a/merge-driver-prepare.ts b/merge-driver-prepare.ts new file mode 100644 index 00000000..1edabe29 --- /dev/null +++ b/merge-driver-prepare.ts @@ -0,0 +1,17 @@ +/** + * Executable entry that registers pm's merge drivers for a consumer's npm + * `prepare` hook, published as `pm-ops/merge-driver/prepare`. + * + * Consumer launchers must not import pm-ops: it is a devDependency, so an + * `npm install --omit=dev` checkout does not have it and a static import fails + * before any fallback can run. The canonical launcher + * (`templates/prepare-merge-driver.ts`) instead resolves this subpath and runs + * it in a child process. Running it performs {@link runPrepareMergeDriver} and + * reports its status as the process exit code. + * + * @packageDocumentation + */ + +import { runPrepareMergeDriver } from "./merge-driver.ts"; + +process.exitCode = runPrepareMergeDriver(); diff --git a/package.json b/package.json index 45c19b70..863fb570 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "types": "./dist/merge-driver.d.ts", "default": "./dist/merge-driver.js" }, + "./merge-driver/prepare": { + "types": "./dist/merge-driver-prepare.d.ts", + "default": "./dist/merge-driver-prepare.js" + }, "./lifecycle-policy": { "types": "./dist/lifecycle-policy.d.ts", "default": "./dist/lifecycle-policy.js" @@ -53,6 +57,7 @@ }, "files": [ "dist/", + "templates/", "manifest.json", "README.md", "CHANGELOG.md" diff --git a/templates/prepare-merge-driver.ts b/templates/prepare-merge-driver.ts new file mode 100644 index 00000000..66e512e9 --- /dev/null +++ b/templates/prepare-merge-driver.ts @@ -0,0 +1,42 @@ +/** + * npm `prepare` hook that registers pm's field-aware Git merge drivers. + * + * Git never clones `.git/config`, so every clone must register the drivers + * `.gitattributes` declares. The installer lives in the devDependency pm-ops, + * which an `npm install --omit=dev` checkout does not have. This launcher + * therefore imports nothing from pm-ops: it resolves the installer entry from + * the package root and runs it in a child process. Only a missing pm-ops package skips, with one + * notice; any other resolution failure (for example a pm-ops too old to export + * the entry) and any installer failure fail the install. + * + * Canonical copy: `pm-ops/templates/prepare-merge-driver.ts`. Copy it + * unchanged to `scripts/prepare-merge-driver.ts`. + */ + +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +// npm runs `prepare` from the package root, so pm-ops is resolved from there. +const resolver = createRequire(join(process.cwd(), "package.json")); +let installer: string | undefined; +try { + installer = resolver.resolve("pm-ops/merge-driver/prepare"); +} catch (error) { + // Only an absent pm-ops package may skip. Probing its package.json tells that + // apart from an installed pm-ops that cannot serve the entry (exports without + // it, no exports map, a missing file): those resolve or fail differently, and + // the original error is rethrown. + let packagePresent = true; + try { + resolver.resolve("pm-ops/package.json"); + } catch (probe) { + packagePresent = !(probe instanceof Error && "code" in probe && probe.code === "MODULE_NOT_FOUND"); + } + if (packagePresent) throw error; +} +if (installer === undefined) { + console.error("pm-ops is not installed (omit-dev install); skipping merge-driver install"); +} else { + process.exitCode = spawnSync(process.execPath, [installer], { stdio: "inherit" }).status ?? 1; +} diff --git a/test/coverage-gate.test.ts b/test/coverage-gate.test.ts index ba6710bf..84c3bdc0 100644 --- a/test/coverage-gate.test.ts +++ b/test/coverage-gate.test.ts @@ -135,6 +135,8 @@ test("coverage gate defaults to its package root and native process boundaries", "assurance.ts", "invocation-audit.ts", "merge-driver.ts", + "merge-driver-prepare.ts", + "templates/prepare-merge-driver.ts", "eslint.ts", "duplication.ts", "scripts/coverage-gate.ts", @@ -198,13 +200,13 @@ test("coverage gate direct entrypoint executes against an explicit package root" if (process.platform === "win32") { writeFileSync( join(bin, "npx.cmd"), - `@echo off\r\nmkdir coverage 2>nul\r\n(echo SF:index.ts& echo end_of_record& echo SF:attestation.ts& echo end_of_record& echo SF:shell-scan.ts& echo end_of_record& echo SF:docstrings.ts& echo end_of_record& echo SF:assurance.ts& echo end_of_record& echo SF:invocation-audit.ts& echo end_of_record& echo SF:merge-driver.ts& echo end_of_record& echo SF:eslint.ts& echo end_of_record& echo SF:duplication.ts& echo end_of_record& echo SF:scripts/coverage-gate.ts& echo end_of_record& echo SF:scripts/lint.ts& echo end_of_record& echo SF:scripts/duplication-gate.ts& echo end_of_record& echo SF:scripts/docstring-gate.ts& echo end_of_record& echo SF:scripts/main-invocation.ts& echo end_of_record& echo SF:scripts/prepare-merge-driver.ts& echo end_of_record& echo SF:scripts/shell-command-scan.ts& echo end_of_record& echo SF:scripts/verify-release-changelog-date.ts& echo end_of_record& echo SF:scripts/verify-release-completeness.ts& echo end_of_record& echo SF:scripts/verify-release-publish-attestation.ts& echo end_of_record& echo SF:lifecycle-policy.ts& echo end_of_record& echo SF:scripts/verify-lifecycle-policy.ts& echo end_of_record)>coverage\\lcov.info\r\n`, + `@echo off\r\nmkdir coverage 2>nul\r\n(echo SF:index.ts& echo end_of_record& echo SF:attestation.ts& echo end_of_record& echo SF:shell-scan.ts& echo end_of_record& echo SF:docstrings.ts& echo end_of_record& echo SF:assurance.ts& echo end_of_record& echo SF:invocation-audit.ts& echo end_of_record& echo SF:merge-driver.ts& echo end_of_record& echo SF:merge-driver-prepare.ts& echo end_of_record& echo SF:templates/prepare-merge-driver.ts& echo end_of_record& echo SF:eslint.ts& echo end_of_record& echo SF:duplication.ts& echo end_of_record& echo SF:scripts/coverage-gate.ts& echo end_of_record& echo SF:scripts/lint.ts& echo end_of_record& echo SF:scripts/duplication-gate.ts& echo end_of_record& echo SF:scripts/docstring-gate.ts& echo end_of_record& echo SF:scripts/main-invocation.ts& echo end_of_record& echo SF:scripts/prepare-merge-driver.ts& echo end_of_record& echo SF:scripts/shell-command-scan.ts& echo end_of_record& echo SF:scripts/verify-release-changelog-date.ts& echo end_of_record& echo SF:scripts/verify-release-completeness.ts& echo end_of_record& echo SF:scripts/verify-release-publish-attestation.ts& echo end_of_record& echo SF:lifecycle-policy.ts& echo end_of_record& echo SF:scripts/verify-lifecycle-policy.ts& echo end_of_record)>coverage\\lcov.info\r\n`, ); } else { writeFileSync( join(bin, "npx"), - "#!/usr/bin/env sh\nmkdir -p coverage\nprintf 'SF:index.ts\\nend_of_record\\nSF:attestation.ts\\nend_of_record\\nSF:shell-scan.ts\\nend_of_record\\nSF:docstrings.ts\\nend_of_record\\nSF:assurance.ts\\nend_of_record\\nSF:invocation-audit.ts\\nend_of_record\\nSF:merge-driver.ts\\nend_of_record\\nSF:eslint.ts\\nend_of_record\\nSF:duplication.ts\\nend_of_record\\nSF:scripts/coverage-gate.ts\\nend_of_record\\nSF:scripts/lint.ts\\nend_of_record\\nSF:scripts/duplication-gate.ts\\nend_of_record\\nSF:scripts/docstring-gate.ts\\nend_of_record\\nSF:scripts/main-invocation.ts\\nend_of_record\\nSF:scripts/prepare-merge-driver.ts\\nend_of_record\\nSF:scripts/shell-command-scan.ts\\nend_of_record\\nSF:scripts/verify-release-changelog-date.ts\\nend_of_record\\nSF:scripts/verify-release-completeness.ts\\nend_of_record\\nSF:scripts/verify-release-publish-attestation.ts\\nend_of_record\\nSF:lifecycle-policy.ts\\nend_of_record\\nSF:scripts/verify-lifecycle-policy.ts\\nend_of_record\\n' > coverage/lcov.info\n", + "#!/usr/bin/env sh\nmkdir -p coverage\nprintf 'SF:index.ts\\nend_of_record\\nSF:attestation.ts\\nend_of_record\\nSF:shell-scan.ts\\nend_of_record\\nSF:docstrings.ts\\nend_of_record\\nSF:assurance.ts\\nend_of_record\\nSF:invocation-audit.ts\\nend_of_record\\nSF:merge-driver.ts\\nend_of_record\\nSF:merge-driver-prepare.ts\\nend_of_record\\nSF:templates/prepare-merge-driver.ts\\nend_of_record\\nSF:eslint.ts\\nend_of_record\\nSF:duplication.ts\\nend_of_record\\nSF:scripts/coverage-gate.ts\\nend_of_record\\nSF:scripts/lint.ts\\nend_of_record\\nSF:scripts/duplication-gate.ts\\nend_of_record\\nSF:scripts/docstring-gate.ts\\nend_of_record\\nSF:scripts/main-invocation.ts\\nend_of_record\\nSF:scripts/prepare-merge-driver.ts\\nend_of_record\\nSF:scripts/shell-command-scan.ts\\nend_of_record\\nSF:scripts/verify-release-changelog-date.ts\\nend_of_record\\nSF:scripts/verify-release-completeness.ts\\nend_of_record\\nSF:scripts/verify-release-publish-attestation.ts\\nend_of_record\\nSF:lifecycle-policy.ts\\nend_of_record\\nSF:scripts/verify-lifecycle-policy.ts\\nend_of_record\\n' > coverage/lcov.info\n", ); } diff --git a/test/merge-driver-launcher.test.ts b/test/merge-driver-launcher.test.ts new file mode 100644 index 00000000..56b05079 --- /dev/null +++ b/test/merge-driver-launcher.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test, { after, before } from "node:test"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const template = join(packageRoot, "templates", "prepare-merge-driver.ts"); +const prepareEntry = join(packageRoot, "merge-driver-prepare.ts"); + +let root: string; + +before(() => { + root = mkdtempSync(join(tmpdir(), "pm-ops-launcher-")); +}); + +after(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** + * Create a consumer checkout root. The canonical launcher runs in place with + * this directory as its working directory, exactly as npm runs `prepare`, so a + * copy in `scripts/` behaves identically and coverage is attributed to the + * shipped template itself. + * + * `pmOps` selects what `node_modules/pm-ops` is: absent (an omit-dev install), + * this package itself, or a stale pm-ops whose exports predate the entry. + */ +function consumer(name: string, pmOps: "absent" | "current" | "stale"): string { + const directory = join(root, name); + mkdirSync(directory); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name, type: "module" })); + if (pmOps !== "absent") mkdirSync(join(directory, "node_modules")); + if (pmOps === "current") symlinkSync(packageRoot, join(directory, "node_modules", "pm-ops"), "dir"); + if (pmOps === "stale") { + mkdirSync(join(directory, "node_modules", "pm-ops")); + writeFileSync( + join(directory, "node_modules", "pm-ops", "package.json"), + JSON.stringify({ name: "pm-ops", type: "module", exports: { "./merge-driver": "./merge-driver.js" } }), + ); + } + return directory; +} + +/** + * Put a stub `pm` on a fresh PATH directory that records its arguments and + * exits with `status`, and return that directory plus the record file. + */ +function stubPm(name: string, status: number, body = ""): { bin: string; record: string } { + const bin = join(root, `${name}-bin`); + const record = join(root, `${name}-args.txt`); + mkdirSync(bin); + writeFileSync(join(bin, "pm"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${record}'\n${body}\nexit ${status}\n`); + chmodSync(join(bin, "pm"), 0o755); + return { bin, record }; +} + +/** Run a script with only `bin` on PATH, as npm's prepare hook would from `cwd`. */ +function run(cwd: string, script: string, bin: string): { status: number | null; stderr: string } { + const result = spawnSync(process.execPath, [script], { + cwd, + encoding: "utf8", + env: { ...process.env, PATH: bin }, + }); + return { status: result.status, stderr: result.stderr }; +} + +test("an omit-dev checkout without pm-ops skips with exactly one notice and succeeds", { skip: process.platform === "win32" }, () => { + const directory = consumer("omit-dev", "absent"); + const { bin, record } = stubPm("omit-dev", 0); + const result = run(directory, template, bin); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, "pm-ops is not installed (omit-dev install); skipping merge-driver install\n"); + assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); +}); + +test("a full install runs pm merge install through the pm-ops entry", { skip: process.platform === "win32" }, () => { + const directory = consumer("full", "current"); + const { bin, record } = stubPm("full", 0); + const result = run(directory, template, bin); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(record, "utf8"), "merge install\n"); +}); + +test("a failing pm merge install fails the launcher with the same status", { skip: process.platform === "win32" }, () => { + const directory = consumer("broken-pm", "current"); + const { bin, record } = stubPm("broken-pm", 7); + const result = run(directory, template, bin); + assert.equal(result.status, 7, result.stderr); + assert.equal(readFileSync(record, "utf8"), "merge install\n"); +}); + +test("an installer killed by a signal fails the install instead of reporting success", { skip: process.platform === "win32" }, () => { + const directory = consumer("killed", "current"); + // The stub's parent is the pm-ops installer process the launcher spawned. + const { bin, record } = stubPm("killed", 0, "kill -9 $PPID"); + const result = run(directory, template, bin); + assert.equal(result.status, 1, result.stderr); + assert.equal(readFileSync(record, "utf8"), "merge install\n"); +}); + +test("a pm-ops too old to export the entry fails loudly instead of skipping", { skip: process.platform === "win32" }, () => { + const directory = consumer("stale", "stale"); + const { bin, record } = stubPm("stale", 0); + const result = run(directory, template, bin); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ERR_PACKAGE_PATH_NOT_EXPORTED/); + assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); +}); + +test("a pm-ops whose entry file is missing fails loudly instead of skipping", { skip: process.platform === "win32" }, () => { + const directory = consumer("no-entry", "stale"); + writeFileSync( + join(directory, "node_modules", "pm-ops", "package.json"), + JSON.stringify({ name: "pm-ops", type: "module", exports: { "./merge-driver/prepare": "./missing.js" } }), + ); + const { bin, record } = stubPm("no-entry", 0); + const result = run(directory, template, bin); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /missing\.js/); + assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); +}); + +test("an installed pm-ops without an exports map fails loudly instead of skipping", { skip: process.platform === "win32" }, () => { + const directory = consumer("no-exports", "stale"); + writeFileSync(join(directory, "node_modules", "pm-ops", "package.json"), JSON.stringify({ name: "pm-ops" })); + const { bin, record } = stubPm("no-exports", 0); + const result = run(directory, template, bin); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot find module 'pm-ops\/merge-driver\/prepare'/); + assert.throws(() => readFileSync(record, "utf8"), /ENOENT/); +}); + +test("the prepare entry itself skips with a notice when pm is not on PATH", { skip: process.platform === "win32" }, () => { + const empty = join(root, "empty-bin"); + mkdirSync(empty); + const result = run(root, prepareEntry, empty); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, "pm is not on PATH; skipping merge-driver install\n"); +});