@nubjs/nub 0.6.0 → 0.7.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/launch.js +180 -14
- package/bin/nubx +0 -0
- package/package.json +9 -9
- package/postinstall.js +41 -3
package/bin/launch.js
CHANGED
|
@@ -25,9 +25,18 @@
|
|
|
25
25
|
// polyglot 0/600. So the heal needs no lock: it is best-effort, atomic (write temp +
|
|
26
26
|
// rename), verify-before-clobber, and a no-op on Windows.
|
|
27
27
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// VERB SELECTION. The platform package ships ONE binary, `bin/nub` — shipping a
|
|
29
|
+
// byte-identical second copy under the name `nubx` doubled every platform package
|
|
30
|
+
// (77-99 MiB) and put them over cnpm/npmmirror's 80 MiB sync cap, breaking installs
|
|
31
|
+
// behind that mirror. So the verb travels in `__NUB_ARGV0`, which BOTH dispatch paths
|
|
32
|
+
// set: the healed sh trampoline as a prefix assignment, and the Node spawn below via
|
|
33
|
+
// `opts.env`. The Rust side reads it, then erases it so it survives exactly one
|
|
34
|
+
// process (Argv0::capture_argv0_override in crates/nub-cli/src/cli.rs).
|
|
35
|
+
//
|
|
36
|
+
// argv[0]'s basename remains the FALLBACK, and still carries every direct invocation:
|
|
37
|
+
// the curl installer's `~/.nub/bin/nubx` symlink, `nub pm shim` hardlinks, `nubx-dev`.
|
|
38
|
+
// It cannot serve the healed path because POSIX `sh` has no portable argv[0] override
|
|
39
|
+
// (`exec -a` is a bash/zsh-ism; dash — `/bin/sh` on Debian and Ubuntu — rejects it).
|
|
31
40
|
const { spawn } = require("child_process");
|
|
32
41
|
const os = require("os");
|
|
33
42
|
const fs = require("fs");
|
|
@@ -44,7 +53,11 @@ function resolveBinary(verb) {
|
|
|
44
53
|
try {
|
|
45
54
|
return require.resolve(`${pkg}/bin/${verb}${ext}`);
|
|
46
55
|
} catch {
|
|
47
|
-
//
|
|
56
|
+
// Expected for `nubx` on any current platform package — only `bin/nub` ships now,
|
|
57
|
+
// and the verb rides in `__NUB_ARGV0` (see the header). The `<verb>` probe above
|
|
58
|
+
// is kept so a NEWER launcher still works against an OLDER platform package that
|
|
59
|
+
// does carry `bin/nubx`: a PM can pin the two to mismatched versions, and picking
|
|
60
|
+
// the verb-named file there costs nothing and stays correct.
|
|
48
61
|
try {
|
|
49
62
|
return require.resolve(`${pkg}/bin/nub${ext}`);
|
|
50
63
|
} catch {
|
|
@@ -118,18 +131,57 @@ function ensureExecutable(binPath, verb) {
|
|
|
118
131
|
// Verify a PATH entry demonstrably resolves to OUR launcher before replacing it —
|
|
119
132
|
// never clobber an unrelated `nub` (there is an unrelated nub@1.0.0 on npm). For a
|
|
120
133
|
// symlink, realpath(entry) must equal our launcher's realpath. For a pnpm cmd-shim
|
|
121
|
-
// (a regular #!/bin/sh file)
|
|
122
|
-
//
|
|
123
|
-
//
|
|
134
|
+
// (a regular #!/bin/sh file) the target is recovered two ways — pnpm >=11's own
|
|
135
|
+
// `# cmd-shim-target=` declaration, then every quoted path — each $basedir-resolved
|
|
136
|
+
// and realpath'd against our launcher. Comparing realpaths rather than substrings is
|
|
137
|
+
// what matches pnpm's fresh AND regenerated shim forms without matching a file that
|
|
138
|
+
// merely NAMES us; the declaration is additionally trusted only in a file that execs,
|
|
139
|
+
// so a non-dispatching file that mentions our path does not qualify either.
|
|
140
|
+
//
|
|
141
|
+
// The quote scan MUST tolerate empty pairs (`[^"]*`, not `[^"]+`). pnpm 11's cmd-shim
|
|
142
|
+
// opens with `exe=""` / `msys=""`; a `+` class cannot match `""`, so those two lines
|
|
143
|
+
// consumed one quote each and re-paired every subsequent quote off-by-one — the real
|
|
144
|
+
// target token was never produced and the heal silently never fired under pnpm 11
|
|
145
|
+
// (pnpm 10, whose template has no empty assignment, was unaffected). That is a SILENT
|
|
146
|
+
// perf regression, not a crash: every call kept paying the ~50ms node hop forever.
|
|
124
147
|
function leadsToUs(entry, st, ourReal) {
|
|
125
148
|
try {
|
|
126
149
|
if (st.isSymbolicLink()) {
|
|
127
150
|
try { return fs.realpathSync(entry) === ourReal; } catch { return false; }
|
|
128
151
|
}
|
|
129
152
|
if (st.isFile()) {
|
|
153
|
+
// A PATH `nub` that is a regular file is usually a PM shim — but it can also be a
|
|
154
|
+
// REAL 45 MB nub binary (curl-install at ~/.nub/bin alongside an npm install). Reading
|
|
155
|
+
// that as utf8 and regexing it costs ~1.1s: measured 379ms to read, 41,781 quoted
|
|
156
|
+
// matches, 17,355 realpath syscalls, vs 0ms on an actual shim. Under a PM whose heal
|
|
157
|
+
// never lands, that is paid on EVERY call. Every shim shape we handle is ~0.5-2 KB and
|
|
158
|
+
// starts with `#!`. The size cap rejects the binary off the `lstat` healPathEntry
|
|
159
|
+
// already took, for zero extra syscalls; only a file that passes the cap is opened at
|
|
160
|
+
// all. Cap is deliberately far above any real shim rather than tight, and matches
|
|
161
|
+
// aube's own MAX_BIN_SHIM_BYTES (aube-linker/src/sys.rs) for the same reason.
|
|
162
|
+
if (st.size > 64 * 1024) return false;
|
|
130
163
|
const body = fs.readFileSync(entry, "utf8");
|
|
164
|
+
if (!body.startsWith("#!")) return false;
|
|
131
165
|
const basedir = path.dirname(entry);
|
|
132
|
-
|
|
166
|
+
// pnpm >=11 declares its own target in a `# cmd-shim-target=` trailer. Prefer it: the
|
|
167
|
+
// shim naming what it dispatches to cannot drift with template churn the way
|
|
168
|
+
// quote-scraping does. Two guards on trusting it, both because healPathEntry's rename
|
|
169
|
+
// is unrecoverable — require an `exec`, so a file that merely MENTIONS our path is a
|
|
170
|
+
// mention and not a target; and `[ \t]*` rather than `\s*`, which spans newlines and
|
|
171
|
+
// would pair a bare `#` line with a following `cmd-shim-target=` line. Resolve against
|
|
172
|
+
// the SHIM's dir as pnpm's own reader does (`path.resolve(path.dirname(shShim),
|
|
173
|
+
// target)`, engine/pm/commands/src/self-updater/selfUpdate.ts): every pnpm writer path
|
|
174
|
+
// emits an absolute value, so that is defensive, but bare `realpathSync` would resolve
|
|
175
|
+
// a relative one against CWD — and the quoted branch below already resolves relatives
|
|
176
|
+
// against basedir, so the two must agree.
|
|
177
|
+
const declared = /\bexec\b/.test(body)
|
|
178
|
+
? body.match(/^#[ \t]*cmd-shim-target=(.+)$/m)
|
|
179
|
+
: null;
|
|
180
|
+
if (declared) {
|
|
181
|
+
const target = path.resolve(basedir, declared[1].trim());
|
|
182
|
+
try { if (fs.realpathSync(target) === ourReal) return true; } catch {}
|
|
183
|
+
}
|
|
184
|
+
const quoted = body.match(/"([^"]*)"/g) || [];
|
|
133
185
|
for (const q of quoted) {
|
|
134
186
|
let p = q.slice(1, -1).replace(/\$\{?basedir\}?/g, basedir);
|
|
135
187
|
if (!p.includes("/")) continue;
|
|
@@ -141,6 +193,100 @@ function leadsToUs(entry, st, ourReal) {
|
|
|
141
193
|
return false;
|
|
142
194
|
}
|
|
143
195
|
|
|
196
|
+
// Does the npm-generated `<verb>.cmd` in `dir` demonstrably dispatch to OUR launcher?
|
|
197
|
+
//
|
|
198
|
+
// The Windows analogue of leadsToUs, and needed for the same reason: healWindowsBinDir
|
|
199
|
+
// drops a `nub.exe` into a directory on the user's PATH, and there is a real unrelated
|
|
200
|
+
// `nub@1.0.0` on npm. Matching on the NAME alone would shadow someone else's tool with
|
|
201
|
+
// our binary — worse than the POSIX case, because PATHEXT makes our `.exe` win over
|
|
202
|
+
// their `.cmd` silently.
|
|
203
|
+
//
|
|
204
|
+
// npm's batch shim references its target as `"%dp0%\..\<pkg>\bin\nub"`, so the scan is
|
|
205
|
+
// the same shape as the sh one: pull quoted tokens, expand the basedir variable, realpath,
|
|
206
|
+
// compare. `%dp0%` already ends in a separator (`%~dp0` expands with a trailing slash),
|
|
207
|
+
// hence the `\\?` in the pattern.
|
|
208
|
+
function cmdShimLeadsToUs(dir, verb, ourReal) {
|
|
209
|
+
try {
|
|
210
|
+
const body = fs.readFileSync(path.join(dir, `${verb}.cmd`), "utf8");
|
|
211
|
+
for (const q of body.match(/"([^"]*)"/g) || []) {
|
|
212
|
+
let p = q.slice(1, -1).replace(/%dp0%\\?/gi, `${dir}${path.sep}`);
|
|
213
|
+
if (!p.includes(path.sep) && !p.includes("/")) continue;
|
|
214
|
+
if (!path.isAbsolute(p)) p = path.resolve(dir, p);
|
|
215
|
+
try { if (fs.realpathSync(p) === ourReal) return true; } catch {}
|
|
216
|
+
}
|
|
217
|
+
} catch {}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// WINDOWS: put a real `<verb>.exe` next to npm's shims and CHANGE NOTHING ELSE.
|
|
222
|
+
//
|
|
223
|
+
// The heal below is POSIX-only because there is no shebang or symlink fast path on
|
|
224
|
+
// Windows — every call goes cmd.exe -> nub.cmd -> node -> spawn nub.exe, and the node
|
|
225
|
+
// boot is ~58 ms of it. A hardlinked `nub.exe` in the same directory is resolved AHEAD
|
|
226
|
+
// of `nub.cmd` by PATHEXT, so cmd.exe reaches the binary directly. Measured on
|
|
227
|
+
// windows-latest, N=40: 95.6 -> 35.8 ms.
|
|
228
|
+
//
|
|
229
|
+
// DELIBERATELY ADD-ONLY. npm's `.ps1` and extensionless shims are left exactly as
|
|
230
|
+
// generated: we are not the first package to start editing files npm owns (checked —
|
|
231
|
+
// esbuild, bun and @pnpm/exe all modify only files inside their OWN package and never
|
|
232
|
+
// touch the global bin dir). The cost is that PowerShell and every sh-family shell keep
|
|
233
|
+
// preferring those shims and see no improvement — including nub's OWN Windows script
|
|
234
|
+
// shell, the bundled busybox (cli.rs `resolve_bundled_busybox`), measured 170.3 -> 169.0
|
|
235
|
+
// ms, i.e. nothing. `nub run` therefore does not benefit. That trade was made explicitly.
|
|
236
|
+
//
|
|
237
|
+
// TWO RESIDUES THIS SHAPE OWNS, both from the `.exe` being a file npm does not track:
|
|
238
|
+
//
|
|
239
|
+
// UNINSTALL. `npm uninstall -g @nubjs/nub` removes only the shims npm generated;
|
|
240
|
+
// cmd-shim never created `<verb>.exe` and npm has run no uninstall lifecycle script
|
|
241
|
+
// since v7, so there is no hook to clean it up. The file STAYS ON PATH and keeps
|
|
242
|
+
// answering `nub` from cmd.exe after the user believes nub is gone — and on the
|
|
243
|
+
// hardlink path the surviving link also keeps the binary's bytes on disk. This is a
|
|
244
|
+
// real user-visible residue, not merely wasted space; do not describe it as "npm's
|
|
245
|
+
// uninstall is unaffected".
|
|
246
|
+
//
|
|
247
|
+
// UPGRADE. Once the `.exe` wins PATHEXT, cmd.exe never dispatches through npm's `.cmd`
|
|
248
|
+
// again, so THIS FUNCTION NEVER RUNS AGAIN for the users it serves and its currency
|
|
249
|
+
// check below cannot fire for them. `postinstall.js` (dropStaleWindowsExe) removes the
|
|
250
|
+
// file on every install so the next call re-heals against the new binary — but that
|
|
251
|
+
// only runs when lifecycle scripts do, so an `--ignore-scripts` upgrade still leaves
|
|
252
|
+
// cmd.exe executing the previous version silently.
|
|
253
|
+
//
|
|
254
|
+
// Best-effort and silent, like every other heal step: any failure leaves a working
|
|
255
|
+
// (slower) install rather than a broken one.
|
|
256
|
+
function healWindowsBinDir(verb, nativePath) {
|
|
257
|
+
if (process.platform !== "win32") return;
|
|
258
|
+
try {
|
|
259
|
+
const ourBin = path.join(__dirname, verb);
|
|
260
|
+
let ourReal; try { ourReal = fs.realpathSync(ourBin); } catch { ourReal = ourBin; }
|
|
261
|
+
let nativeReal; try { nativeReal = fs.realpathSync(nativePath); } catch { nativeReal = nativePath; }
|
|
262
|
+
let src; try { src = fs.statSync(nativeReal); } catch { return; }
|
|
263
|
+
|
|
264
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
265
|
+
if (!dir) continue;
|
|
266
|
+
if (!cmdShimLeadsToUs(dir, verb, ourReal)) continue;
|
|
267
|
+
const dest = path.join(dir, `${verb}.exe`);
|
|
268
|
+
// Idempotent, and correct across an upgrade: `npm i -g` extracts a NEW binary at a
|
|
269
|
+
// new inode, so an existing .exe from a previous version is stale and must be
|
|
270
|
+
// re-linked. Comparing ino+dev is exact for a hardlink; the size fallback covers
|
|
271
|
+
// the copy path, where ino necessarily differs.
|
|
272
|
+
try {
|
|
273
|
+
const cur = fs.statSync(dest);
|
|
274
|
+
if ((cur.ino && cur.ino === src.ino && cur.dev === src.dev) || cur.size === src.size) return;
|
|
275
|
+
fs.rmSync(dest, { force: true });
|
|
276
|
+
} catch {}
|
|
277
|
+
try {
|
|
278
|
+
fs.linkSync(nativeReal, dest);
|
|
279
|
+
} catch {
|
|
280
|
+
// EXDEV (prefix on a different volume from the store) or a filesystem without
|
|
281
|
+
// hardlinks: fall back to a copy. Costs the binary's size on disk once, which is
|
|
282
|
+
// why it is the fallback and not the default.
|
|
283
|
+
try { fs.copyFileSync(nativeReal, dest); } catch {}
|
|
284
|
+
}
|
|
285
|
+
break; // the first PATH entry that dispatches to us is the one that matters
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
|
|
144
290
|
// Best-effort, never throws. Rewrite the on-PATH `<verb>` entry that dispatched us
|
|
145
291
|
// into a minimal sh trampoline -> the native binary. POSIX only.
|
|
146
292
|
function healPathEntry(verb, nativePath) {
|
|
@@ -157,10 +303,19 @@ function healPathEntry(verb, nativePath) {
|
|
|
157
303
|
// fallback (spawn native) instead of choking on sh-as-JS. So the heal is race-free
|
|
158
304
|
// on symlink-to-node-shim PMs (npm/bun/yarn) too, the guarantee pnpm gets for free.
|
|
159
305
|
// Measured: pure-sh heal ~6%/200 concurrent first-call failures; polyglot 0/600.
|
|
306
|
+
//
|
|
307
|
+
// Both branches carry the verb in `__NUB_ARGV0`. The platform package ships ONE
|
|
308
|
+
// binary, so `nativeReal` is `bin/nub` for BOTH verbs and its basename can no
|
|
309
|
+
// longer distinguish them — and POSIX `sh` cannot set argv[0] portably (`exec -a`
|
|
310
|
+
// is a bash/zsh-ism; dash, i.e. `/bin/sh` on Debian and Ubuntu, rejects it). The
|
|
311
|
+
// Rust side reads this var, then erases it so it survives exactly one process
|
|
312
|
+
// (Argv0::capture_argv0_override). Without it the healed `nubx` entry silently
|
|
313
|
+
// runs `nub` — exit 0, wrong command, which is why this is not optional.
|
|
314
|
+
const envAssign = `__NUB_ARGV0=${shq(verb)} `;
|
|
160
315
|
const content =
|
|
161
316
|
`#!/bin/sh\n` +
|
|
162
|
-
`":" //# nub launcher; exec ${shq(nativeReal)} "$@"\n` +
|
|
163
|
-
`var r=require("child_process").spawnSync(${JSON.stringify(nativeReal)},process.argv.slice(2),{stdio:"inherit"});process.exit(r.status==null?1:r.status)\n`;
|
|
317
|
+
`":" //# nub launcher; ${envAssign}exec ${shq(nativeReal)} "$@"\n` +
|
|
318
|
+
`var r=require("child_process").spawnSync(${JSON.stringify(nativeReal)},process.argv.slice(2),{stdio:"inherit",env:Object.assign({},process.env,{__NUB_ARGV0:${JSON.stringify(verb)}})});process.exit(r.status==null?1:r.status)\n`;
|
|
164
319
|
|
|
165
320
|
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
166
321
|
if (!dir) continue;
|
|
@@ -200,9 +355,17 @@ module.exports = function launch(argv0Name) {
|
|
|
200
355
|
const binPath = ensureExecutable(resolved, verb);
|
|
201
356
|
// Self-heal the PATH entry on first POSIX call so later calls skip Node entirely.
|
|
202
357
|
healPathEntry(verb, binPath);
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
358
|
+
// The Windows counterpart. Separate function rather than a branch inside healPathEntry
|
|
359
|
+
// because the two do genuinely different things: POSIX REWRITES the entry that
|
|
360
|
+
// dispatched us, Windows only ADDS a sibling `.exe` and leaves npm's shims untouched.
|
|
361
|
+
healWindowsBinDir(verb, binPath);
|
|
362
|
+
// This call still runs through Node; spawn the native binary. The platform package
|
|
363
|
+
// ships ONE binary, so binPath's basename is `nub` for both verbs and cannot carry
|
|
364
|
+
// the mode — `__NUB_ARGV0` does, below. We set `argv0` too, but the env var is the
|
|
365
|
+
// load-bearing one: Node documents argv0 as affecting only the process TITLE on
|
|
366
|
+
// Windows, and Windows is precisely where this path runs on EVERY call (the heal is
|
|
367
|
+
// POSIX-only). The env var makes the two platforms agree instead of resting on
|
|
368
|
+
// per-OS argv0 semantics. We use async `spawn` (not `spawnSync`) ONLY so this
|
|
206
369
|
// Node launcher can forward terminating signals to the native child: `spawnSync`
|
|
207
370
|
// blocks the event loop, so a SIGTERM (docker stop on a `nub run` entrypoint whose
|
|
208
371
|
// first-ever call hasn't been healed to the sh trampoline yet) would terminate
|
|
@@ -211,7 +374,10 @@ module.exports = function launch(argv0Name) {
|
|
|
211
374
|
// status. (Subsequent calls skip Node entirely via the healed trampoline's `exec`,
|
|
212
375
|
// where signals reach the binary directly.)
|
|
213
376
|
const opts = { stdio: "inherit", windowsHide: true };
|
|
214
|
-
if (argv0Name)
|
|
377
|
+
if (argv0Name) {
|
|
378
|
+
opts.argv0 = argv0Name;
|
|
379
|
+
opts.env = Object.assign({}, process.env, { __NUB_ARGV0: argv0Name });
|
|
380
|
+
}
|
|
215
381
|
const child = spawn(binPath, process.argv.slice(2), opts);
|
|
216
382
|
let forwarding = true;
|
|
217
383
|
const forward = (sig) => {
|
package/bin/nubx
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubjs/nub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "TypeScript-first developer supertool — a fast script runner and TS runtime powered by Node.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "https://github.com/nubjs/nub",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"LICENSE"
|
|
37
37
|
],
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@nubjs/nub-darwin-arm64": "0.
|
|
40
|
-
"@nubjs/nub-darwin-x64": "0.
|
|
41
|
-
"@nubjs/nub-linux-x64": "0.
|
|
42
|
-
"@nubjs/nub-linux-x64-musl": "0.
|
|
43
|
-
"@nubjs/nub-linux-arm64": "0.
|
|
44
|
-
"@nubjs/nub-linux-arm64-musl": "0.
|
|
45
|
-
"@nubjs/nub-win32-x64": "0.
|
|
46
|
-
"@nubjs/nub-win32-arm64": "0.
|
|
39
|
+
"@nubjs/nub-darwin-arm64": "0.7.0",
|
|
40
|
+
"@nubjs/nub-darwin-x64": "0.7.0",
|
|
41
|
+
"@nubjs/nub-linux-x64": "0.7.0",
|
|
42
|
+
"@nubjs/nub-linux-x64-musl": "0.7.0",
|
|
43
|
+
"@nubjs/nub-linux-arm64": "0.7.0",
|
|
44
|
+
"@nubjs/nub-linux-arm64-musl": "0.7.0",
|
|
45
|
+
"@nubjs/nub-win32-x64": "0.7.0",
|
|
46
|
+
"@nubjs/nub-win32-arm64": "0.7.0"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/postinstall.js
CHANGED
|
@@ -24,8 +24,8 @@ function platformPkg() {
|
|
|
24
24
|
// npm normalizes file modes on extract: a file referenced by a package's `bin`
|
|
25
25
|
// field lands 0o755, everything else 0o644. The platform packages
|
|
26
26
|
// (`@nubjs/nub-<platform>`) deliberately declare NO `bin` field — they're carriers
|
|
27
|
-
// selected by npm's os/cpu filters — so their `bin/nub`
|
|
28
|
-
//
|
|
27
|
+
// selected by npm's os/cpu filters — so their `bin/nub` extracts 0o644 (no +x).
|
|
28
|
+
// Something must add it back.
|
|
29
29
|
//
|
|
30
30
|
// `bin/launch.js` also chmods at runtime, but that runs as the END user and chmod
|
|
31
31
|
// only succeeds for the file's OWNER. The canonical container/CI pattern installs
|
|
@@ -46,7 +46,10 @@ function chmodExecutable(pkg) {
|
|
|
46
46
|
try {
|
|
47
47
|
binPath = require.resolve(`${pkg}/bin/${verb}${ext}`);
|
|
48
48
|
} catch {
|
|
49
|
-
|
|
49
|
+
// `nubx` is expected to miss: current platform packages ship only `bin/nub`
|
|
50
|
+
// and the verb rides in `__NUB_ARGV0`. Still probed so a newer launcher paired
|
|
51
|
+
// with an older platform package chmods that package's `bin/nubx` too.
|
|
52
|
+
continue;
|
|
50
53
|
}
|
|
51
54
|
try {
|
|
52
55
|
// Preserve read/write bits, add execute for user/group/other (umask-free —
|
|
@@ -166,8 +169,43 @@ function refreshShims(pkg) {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
// Drop any `<binDir>\<verb>.exe` that a previous version's heal installed.
|
|
173
|
+
//
|
|
174
|
+
// bin/launch.js drops a hardlinked `nub.exe` beside npm's shims on Windows so PATHEXT
|
|
175
|
+
// reaches the binary directly. That is the point — and it is also why the heal cannot
|
|
176
|
+
// maintain itself: once the `.exe` wins PATHEXT, cmd.exe never dispatches through npm's
|
|
177
|
+
// `.cmd` again, so launch.js never runs again, so its "is this link current?" check is
|
|
178
|
+
// structurally unreachable for exactly the users the feature serves. After
|
|
179
|
+
// `npm i -g @nubjs/nub@<newer>` the old hardlink still pins the PREVIOUS version's inode
|
|
180
|
+
// and those users silently keep executing the old binary — no error, no version warning.
|
|
181
|
+
//
|
|
182
|
+
// Removing it here is the self-correcting fix rather than re-linking: the next `nub` call
|
|
183
|
+
// finds no `.exe`, falls through npm's shim into launch.js, and the heal recreates the
|
|
184
|
+
// link against the new binary. All the PATH-walk and verify-before-clobber logic stays in
|
|
185
|
+
// one place instead of being duplicated here.
|
|
186
|
+
//
|
|
187
|
+
// KNOWN GAP: this runs only when lifecycle scripts do. Under `--ignore-scripts` (or npm
|
|
188
|
+
// v12's default) an upgrade leaves the stale `.exe` in place and cmd.exe keeps running the
|
|
189
|
+
// old binary. Same class as every other postinstall-dependent step here, and the reason
|
|
190
|
+
// the launcher's own recovery paths never rely on this file having run.
|
|
191
|
+
function dropStaleWindowsExe() {
|
|
192
|
+
if (process.platform !== "win32") return;
|
|
193
|
+
const path = require("path");
|
|
194
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
195
|
+
if (!dir) continue;
|
|
196
|
+
for (const verb of ["nub", "nubx"]) {
|
|
197
|
+
// Only where npm's own shim for that verb still sits: that pairing is what marks the
|
|
198
|
+
// directory as ours. A bare `<verb>.exe` in some unrelated PATH dir is not ours to
|
|
199
|
+
// delete — there is a real unrelated `nub@1.0.0` on npm.
|
|
200
|
+
if (!fs.existsSync(path.join(dir, `${verb}.cmd`))) continue;
|
|
201
|
+
try { fs.rmSync(path.join(dir, `${verb}.exe`), { force: true }); } catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
169
206
|
const pkg = platformPkg();
|
|
170
207
|
if (pkg) {
|
|
171
208
|
chmodExecutable(pkg);
|
|
172
209
|
refreshShims(pkg); // after chmod, so the linked inode already carries +x
|
|
210
|
+
dropStaleWindowsExe(); // the next launch.js run re-heals against the new binary
|
|
173
211
|
}
|