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 +0 -0
- package/docs/CHANGELOG.md +8 -0
- package/lib/cli/update.js +83 -34
- package/package.json +1 -1
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
|
-
*
|
|
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)
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* `['
|
|
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
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
686
|
-
|
|
687
|
-
|
|
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`
|
|
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
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1255
|
-
*
|
|
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
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
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