mandrel 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/mandrel.js CHANGED
File without changes
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.3.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.2.0...mandrel-v2.3.0) (2026-07-17)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **test:** resolve Windows drive-letter path bug in quality-preview test ([#4614](https://github.com/dsj1984/mandrel/issues/4614)) ([a7da94d](https://github.com/dsj1984/mandrel/commit/a7da94d1c4a83b9b721e632e468bb34df07465dd))
11
+ * **update:** make post-install bin re-exec pnpm/layout-agnostic ([#4613](https://github.com/dsj1984/mandrel/issues/4613)) ([#4616](https://github.com/dsj1984/mandrel/issues/4616)) ([82dc5a2](https://github.com/dsj1984/mandrel/commit/82dc5a2e9ce662f9b9c0c7880ea684fd370a507f))
12
+
5
13
  ## [2.2.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.1.0...mandrel-v2.2.0) (2026-07-17)
6
14
 
7
15
 
package/lib/cli/update.js CHANGED
@@ -36,7 +36,9 @@
36
36
  * ## Re-exec of post-install phases (Story #4034)
37
37
  *
38
38
  * Steps 4–6 execute as **child processes spawned from the newly-installed
39
- * binary** (`<cwd>/node_modules/.bin/mandrel`) rather than in the running
39
+ * bin script** (`node <cwd>/node_modules/mandrel/bin/mandrel.js`; Story #4613
40
+ * resolves the script layout-agnostically rather than via the `.bin` shim)
41
+ * rather than in the running
40
42
  * process. Node cannot hot-swap a `require`d module mid-process, so without
41
43
  * re-exec, the still-running old binary's `runSync`/`runMigrations`/`runDoctor`
42
44
  * code would materialise the old payload even though the package on disk has
@@ -120,11 +122,12 @@
120
122
  * resolved semver string — see `lib/install-cmd-parser.js` for the shared
121
123
  * tokenize-and-spawn rationale this module reuses (no duplicated workaround).
122
124
  *
123
- * The `spawnPhase` default (Story #4034) similarly uses `shell: true` only on
124
- * Windows: the new binary resolves from `node_modules/.bin/mandrel` (a fixed,
125
- * non-operator-supplied path) and the per-phase argv vector is a constant
126
- * fixed list (e.g. `['sync']`, `['migrate', '--from', v, '--to', v]`,
127
- * `['doctor']`) with no injection risk regardless of the shell flag.
125
+ * The `spawnPhase` default (Story #4034) does **not** use the win32 shell flag:
126
+ * it spawns `process.execPath` (node) against the resolved `bin/mandrel.js`
127
+ * script (Story #4613), so it never touches a `.cmd` shim and needs no
128
+ * shell on any platform. The per-phase argv vector is a constant fixed list
129
+ * (e.g. `['sync']`, `['migrate', '--from', v, '--to', v]`, `['doctor']`) with
130
+ * no operator-supplied data.
128
131
  */
129
132
 
130
133
  import { spawnSync } from 'node:child_process';
@@ -674,29 +677,55 @@ function parseChangelogSections(raw) {
674
677
  }
675
678
 
676
679
  /**
677
- * Resolve the path to the `mandrel` binary inside `node_modules/.bin/` for the
678
- * given project root. On Windows the binary is a `.cmd` shim; on POSIX it is a
679
- * plain executable. The resolved path is used as the target for the post-install
680
- * phase re-exec (Story #4034).
680
+ * Resolve the newly-installed `mandrel` bin **script**
681
+ * (`<packageRoot>/bin/mandrel.js`) from the consumer project root. This is the
682
+ * target for the post-install phase re-exec (Story #4034), spawned via
683
+ * `process.execPath` (node) rather than executed directly — see
684
+ * {@link defaultSpawnPhase}.
685
+ *
686
+ * It deliberately does **not** return the `node_modules/.bin/mandrel` shim.
687
+ * That shim only works because npm chmods the bin target `+x` at install time:
688
+ * `bin/mandrel.js` ships non-executable in the published tarball, and pnpm
689
+ * symlinks `.bin/mandrel` straight at it, so spawning the shim directly fails
690
+ * with `EACCES` under pnpm (Story #4613). Spawning node against the resolved
691
+ * `.js` script removes the dependency on the exec bit, the shebang, and the
692
+ * Windows `.cmd` shim entirely.
693
+ *
694
+ * Resolution reuses the same consumer-anchored resolver
695
+ * (`defaultResolvePackageRoot`) that {@link resolveCurrentVersionForUpdate}
696
+ * uses, so it points at the consumer's install rather than a copy hoisted next
697
+ * to this CLI module. The `mandrel` package directory is version-invariant
698
+ * (`node_modules/mandrel/`), so resolving it before the in-place `npm-update`
699
+ * step still yields the directory whose `bin/mandrel.js` the install overwrites.
681
700
  *
682
701
  * @param {string} projectRoot - Absolute path to the consumer project.
683
- * @returns {string} Absolute path to the new binary.
702
+ * @param {{ resolvePackageRoot?: (fromDir: string) => string }} [opts] - test
703
+ * seam for the `node_modules` resolution; defaults to the real
704
+ * `defaultResolvePackageRoot` from `sync.js`.
705
+ * @returns {string} Absolute path to the new bin script.
684
706
  */
685
- export function resolveNewBinPath(projectRoot) {
686
- const binName = process.platform === 'win32' ? 'mandrel.cmd' : 'mandrel';
687
- return path.join(projectRoot, 'node_modules', '.bin', binName);
707
+ export function resolveNewBinScriptPath(
708
+ projectRoot,
709
+ { resolvePackageRoot = defaultResolvePackageRoot } = {},
710
+ ) {
711
+ const packageRoot = resolvePackageRoot(projectRoot);
712
+ return path.join(packageRoot, 'bin', 'mandrel.js');
688
713
  }
689
714
 
690
715
  /**
691
716
  * Default `spawnPhase` seam (Story #4034): spawn a post-install phase from the
692
- * newly-installed `mandrel` binary and stream its stdout/stderr through the
717
+ * newly-installed `mandrel` bin script and stream its stdout/stderr through the
693
718
  * parent's write sinks. Each phase runs as an isolated child process so the
694
719
  * newly-installed module code (not the currently-loaded old module) executes.
695
720
  *
696
- * The spawn uses `shell: true` only on Windows where the binary is a `.cmd`
697
- * shim (CVE-2024-27980 parity). The argv vector is a fixed constant list
698
- * per phase no operator-supplied data enters the vector, so the shell flag
699
- * carries no injection risk (security-baseline § Output & Rendering).
721
+ * The child is spawned as `process.execPath <binScript> <phase> …` node run
722
+ * against the resolved `bin/mandrel.js` (see {@link resolveNewBinScriptPath}).
723
+ * Spawning node against a plain `.js` file removes any dependency on the bin's
724
+ * exec bit, its shebang, or a Windows `.cmd` shim, so **no** `shell` flag is
725
+ * needed on any platform (this is the pnpm/layout-agnostic fix, Story #4613,
726
+ * that retired the former win32-only `shell: true` branch). The argv vector is
727
+ * a fixed constant list per phase — no operator-supplied data enters it
728
+ * (security-baseline § Output & Rendering).
700
729
  *
701
730
  * Throws when the child exits non-zero so the orchestrator can surface the
702
731
  * failure to the operator.
@@ -709,7 +738,8 @@ export function resolveNewBinPath(projectRoot) {
709
738
  * write: (s: string) => void,
710
739
  * writeErr: (s: string) => void,
711
740
  * spawnFn?: typeof spawnSync,
712
- * }} opts
741
+ * }} opts - `binPath` is the resolved bin **script** path (not the
742
+ * `node_modules/.bin` shim); it becomes node's first argv entry.
713
743
  * @returns {{ ok: boolean, stdout: string, stderr: string }}
714
744
  */
715
745
  export function defaultSpawnPhase(
@@ -718,10 +748,9 @@ export function defaultSpawnPhase(
718
748
  { binPath, cwd, write, writeErr, spawnFn = spawnSync },
719
749
  ) {
720
750
  const argv = [phase, ...args];
721
- const r = spawnFn(binPath, argv, {
751
+ const r = spawnFn(process.execPath, [binPath, ...argv], {
722
752
  cwd,
723
753
  encoding: 'utf8',
724
- shell: process.platform === 'win32',
725
754
  });
726
755
  const stdout = typeof r.stdout === 'string' ? r.stdout : '';
727
756
  const stderr = typeof r.stderr === 'string' ? r.stderr : '';
@@ -980,7 +1009,7 @@ async function resolveDrift(checkDrift) {
980
1009
  * npmUpdate: ((version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>) | undefined,
981
1010
  * spawnPhase: ((phase: string, args: string[], opts: object) => { ok: boolean } | Promise<{ ok: boolean }>) | undefined,
982
1011
  * surfaceChangelog: ((version: string) => unknown | Promise<unknown>) | undefined,
983
- * binPath: string,
1012
+ * resolveBinScript: (projectRoot: string) => string,
984
1013
  * projectRoot: string,
985
1014
  * write: (s: string) => void,
986
1015
  * writeErr: (s: string) => void,
@@ -995,7 +1024,7 @@ async function executePlan({
995
1024
  npmUpdate,
996
1025
  spawnPhase,
997
1026
  surfaceChangelog,
998
- binPath,
1027
+ resolveBinScript,
999
1028
  projectRoot,
1000
1029
  write,
1001
1030
  writeErr,
@@ -1004,6 +1033,17 @@ async function executePlan({
1004
1033
  const stepsRun = [];
1005
1034
  let doctorOk = true;
1006
1035
 
1036
+ // Resolve the new bin script lazily and once, on the first spawn phase.
1037
+ // Deferring it past the `npm-update` step means (a) a missing `npmUpdate`
1038
+ // seam surfaces its own clear error first, and (b) resolution reflects the
1039
+ // just-installed package. The `mandrel` package directory is version-stable,
1040
+ // so resolving after the in-place bump yields the same directory either way.
1041
+ let binPath;
1042
+ const binScript = () => {
1043
+ if (binPath === undefined) binPath = resolveBinScript(projectRoot);
1044
+ return binPath;
1045
+ };
1046
+
1007
1047
  for (const step of steps) {
1008
1048
  if (step.kind === 'npm-update') {
1009
1049
  // Bump the dependency. The lockfile change is left STAGED on disk; this
@@ -1024,7 +1064,7 @@ async function executePlan({
1024
1064
  // package's module code — not the old loaded module — executes.
1025
1065
  // eslint-disable-next-line no-await-in-loop
1026
1066
  const result = await spawnPhase(step.phase, step.args, {
1027
- binPath,
1067
+ binPath: binScript(),
1028
1068
  cwd: projectRoot,
1029
1069
  write,
1030
1070
  writeErr,
@@ -1080,6 +1120,7 @@ async function executePlan({
1080
1120
  * writeErr?: (s: string) => void,
1081
1121
  * exit?: (code: number) => void,
1082
1122
  * cwd?: () => string,
1123
+ * resolveBinScript?: (projectRoot: string) => string,
1083
1124
  * }} [opts]
1084
1125
  * @returns {Promise<{
1085
1126
  * ok: boolean,
@@ -1102,6 +1143,7 @@ export async function runUpdate({
1102
1143
  writeErr = (s) => process.stderr.write(s),
1103
1144
  exit = (code) => process.exit(code),
1104
1145
  cwd = () => process.cwd(),
1146
+ resolveBinScript = resolveNewBinScriptPath,
1105
1147
  } = {}) {
1106
1148
  const dryRun = argv.includes('--dry-run');
1107
1149
  const installCmd = parseInstallCmdFlag(argv);
@@ -1176,7 +1218,6 @@ export async function runUpdate({
1176
1218
 
1177
1219
  // --- resynced / updated: execute the phase plan ---------------------------
1178
1220
  const projectRoot = cwd();
1179
- const binPath = resolveNewBinPath(projectRoot);
1180
1221
 
1181
1222
  if (plan.action === 'resynced') {
1182
1223
  write(
@@ -1193,7 +1234,7 @@ export async function runUpdate({
1193
1234
  npmUpdate,
1194
1235
  spawnPhase,
1195
1236
  surfaceChangelog,
1196
- binPath,
1237
+ resolveBinScript,
1197
1238
  projectRoot,
1198
1239
  write,
1199
1240
  writeErr,
@@ -1251,8 +1292,9 @@ export async function runUpdate({
1251
1292
  * through the shared `runInstallCommand` helper — no git mutation;
1252
1293
  * lockfile left staged.
1253
1294
  * - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
1254
- * post-install phase (sync, sync-commands, migrate, doctor) from the
1255
- * newly-installed binary (`node_modules/.bin/mandrel`). This is the
1295
+ * post-install phase (sync, sync-commands, migrate, doctor) as
1296
+ * `node <packageRoot>/bin/mandrel.js …` (Story #4613 — the resolved bin
1297
+ * script, not the `node_modules/.bin` shim). This is the
1256
1298
  * Story #4034 fix: the new bin loads the new package's module code and
1257
1299
  * resolves paths against the new install dir, so these phases can never
1258
1300
  * observe the old payload.
@@ -1289,6 +1331,7 @@ export async function runUpdate({
1289
1331
  * fetchChangelog?: (version: string) => Promise<string>,
1290
1332
  * runUpdate?: typeof runUpdate,
1291
1333
  * cwd?: () => string,
1334
+ * resolveBinScript?: (projectRoot: string) => string,
1292
1335
  * checkDrift?: () => (boolean | Promise<boolean>),
1293
1336
  * write?: (s: string) => void,
1294
1337
  * writeErr?: (s: string) => void,
@@ -1313,6 +1356,7 @@ export default async function run(argv = [], deps = {}) {
1313
1356
  exit = (code) => process.exit(code),
1314
1357
  log,
1315
1358
  cwd,
1359
+ resolveBinScript,
1316
1360
  checkDrift,
1317
1361
  } = deps;
1318
1362
 
@@ -1323,11 +1367,13 @@ export default async function run(argv = [], deps = {}) {
1323
1367
  const current =
1324
1368
  deps.currentVersion ?? resolveCurrentVersionForUpdate(cwdFn(), fs);
1325
1369
 
1326
- // The production spawnPhase: spawn each post-install phase from
1327
- // node_modules/.bin/mandrel (the newly-installed binary). This is the sole
1328
- // post-install execution path (No-Shim Story #4182 retired the in-process
1329
- // runSync/runMigrations/runDoctor seam set). spawnFn is injectable so tests
1330
- // can stub the spawn boundary without running a real child process.
1370
+ // The production spawnPhase: spawn each post-install phase as
1371
+ // `node <packageRoot>/bin/mandrel.js …` (the newly-installed bin script,
1372
+ // resolved layout-agnostically per Story #4613 not the node_modules/.bin
1373
+ // shim). This is the sole post-install execution path (No-Shim — Story #4182
1374
+ // retired the in-process runSync/runMigrations/runDoctor seam set). spawnFn
1375
+ // is injectable so tests can stub the spawn boundary without running a real
1376
+ // child process.
1331
1377
  const productionSpawnPhase = (phase, args, opts) =>
1332
1378
  defaultSpawnPhase(phase, args, {
1333
1379
  ...opts,
@@ -1370,5 +1416,8 @@ export default async function run(argv = [], deps = {}) {
1370
1416
  writeErr,
1371
1417
  exit,
1372
1418
  cwd: cwdFn,
1419
+ // Pass through undefined in production so runUpdate applies its default
1420
+ // resolver (resolveNewBinScriptPath); tests inject a stub for a fake root.
1421
+ resolveBinScript,
1373
1422
  });
1374
1423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",