@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 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
- // The native binary selects its verb from argv[0]'s basename (nub vs nubx); the
29
- // healed trampoline exec's bin/<verb> in the platform package (which ships both
30
- // names), so no argv0 override is needed past the heal.
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
- // bin/nubx may be absent on an older platform package; fall back to bin/nub.
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), every quoted path it references is $basedir-resolved
122
- // and realpath'd; one must equal our launcher. Comparing realpaths (not substrings)
123
- // matches pnpm's fresh AND regenerated shim forms and rejects comment-only mentions.
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
- const quoted = body.match(/"([^"]+)"/g) || [];
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
- // This call still runs through Node; spawn the native binary. argv0 basename of
204
- // binPath is the verb (bin/nub or bin/nubx), so the Rust CLI dispatches correctly
205
- // without an argv0 override. We use async `spawn` (not `spawnSync`) ONLY so this
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) opts.argv0 = argv0Name; // belt-and-suspenders for the bin/nub fallback path
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.6.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.6.0",
40
- "@nubjs/nub-darwin-x64": "0.6.0",
41
- "@nubjs/nub-linux-x64": "0.6.0",
42
- "@nubjs/nub-linux-x64-musl": "0.6.0",
43
- "@nubjs/nub-linux-arm64": "0.6.0",
44
- "@nubjs/nub-linux-arm64-musl": "0.6.0",
45
- "@nubjs/nub-win32-x64": "0.6.0",
46
- "@nubjs/nub-win32-arm64": "0.6.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` / `bin/nubx` extract 0o644
28
- // (no +x). Something must add it back.
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
- continue; // an older platform package may not ship bin/nubx.
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
  }