@bobfrankston/npmglobalize 1.0.196 → 1.0.197

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.
Files changed (3) hide show
  1. package/lib.d.ts +8 -0
  2. package/lib.js +171 -25
  3. package/package.json +3 -3
package/lib.d.ts CHANGED
@@ -457,6 +457,14 @@ export declare function fixPuppeteerCorruptedCache(combined: string): {
457
457
  repaired: string[];
458
458
  removed: string[];
459
459
  };
460
+ /** Every package in `dir`'s dependency closure that shares its scope — our
461
+ * own packages, published by this same tool. Walks node_modules for
462
+ * transitive own-scope deps, since a cascade dep can carry its own
463
+ * postinstall and only its installed copy lists its dependencies.
464
+ *
465
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
466
+ * package of ours from a third-party one by name alone. */
467
+ export declare function ownScopePackages(dir: string): string[];
460
468
  /** Run a command and return success status */
461
469
  export declare function runCommand(cmd: string, args: string[], options?: {
462
470
  silent?: boolean;
package/lib.js CHANGED
@@ -3271,18 +3271,142 @@ export function fixPuppeteerCorruptedCache(combined) {
3271
3271
  }
3272
3272
  return { repaired: [], removed: [] };
3273
3273
  }
3274
- /** npm 11.17+ gates lifecycle scripts behind `allowScripts` non-covered
3275
- * scripts are skipped and the install still exits 0. A skipped postinstall
3276
- * can leave the package broken at runtime (puppeteer without its browser,
3277
- * native modules unbuilt), so surface the warning instead of burying it in
3278
- * the captured output. */
3279
- function warnSkippedScripts(combined) {
3274
+ // ─── allowScripts policy: trust our own packages, gate everyone else ──
3275
+ //
3276
+ // npm 11.17+ skips install-time lifecycle scripts (preinstall/install/
3277
+ // postinstall) for packages that aren't on an allowlist, and a global
3278
+ // install has no project package.json to record approvals in — so a
3279
+ // postinstall we wrote ourselves silently never runs while the install
3280
+ // still exits 0. (@bobfrankston/msger's postinstall copies its native
3281
+ // binary into the per-user bin dir its launcher reads from; skipping it
3282
+ // leaves a stale exe behind a green checkmark.)
3283
+ //
3284
+ // npmglobalize only ever installs a package it just built and published
3285
+ // from the user's own source, so own-scope packages are trusted. Third-
3286
+ // party deps are not: they stay gated and keep surfacing through
3287
+ // warnSkippedScripts. That's deliberately narrower than a blanket
3288
+ // --dangerously-allow-all-scripts — it keeps the warning meaningful
3289
+ // instead of turning it into dead code.
3290
+ //
3291
+ // The policy matcher has no scope wildcard (`@scope/*` matches nothing —
3292
+ // see arborist's script-allowed.js), so the allowlist must name every
3293
+ // own-scope package in the tree explicitly.
3294
+ let _allowScriptsSupported = null;
3295
+ let _wslAllowScriptsSupported = null;
3296
+ /** Probe output for a config key npm doesn't define. */
3297
+ const isUnknownConfig = (out) => {
3298
+ const v = out.trim();
3299
+ return v === '' || v === 'undefined';
3300
+ };
3301
+ /** Does this npm know the allowScripts policy (11.17+)? Older npm runs all
3302
+ * install scripts unconditionally, and passing the flag there just prints
3303
+ * "Unknown cli config" on every install. Probed once per process.
3304
+ *
3305
+ * Probes the boolean sibling flag rather than `allow-scripts` itself: an
3306
+ * unset list config also prints empty, which is indistinguishable from
3307
+ * "not supported". Both flags landed in the same npm release. */
3308
+ function npmSupportsAllowScripts() {
3309
+ if (_allowScriptsSupported === null) {
3310
+ const r = runCommand('npm', ['config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3311
+ _allowScriptsSupported = r.success && !isUnknownConfig(r.output);
3312
+ }
3313
+ return _allowScriptsSupported;
3314
+ }
3315
+ /** Same probe against WSL's npm, which is a separate install and can lag
3316
+ * behind the Windows one. */
3317
+ async function wslNpmSupportsAllowScripts() {
3318
+ if (_wslAllowScriptsSupported === null) {
3319
+ const r = await runCommandAsync('wsl', ['npm', 'config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3320
+ _wslAllowScriptsSupported = r.success && !isUnknownConfig(r.output);
3321
+ }
3322
+ return _wslAllowScriptsSupported;
3323
+ }
3324
+ /** Every package in `dir`'s dependency closure that shares its scope — our
3325
+ * own packages, published by this same tool. Walks node_modules for
3326
+ * transitive own-scope deps, since a cascade dep can carry its own
3327
+ * postinstall and only its installed copy lists its dependencies.
3328
+ *
3329
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
3330
+ * package of ours from a third-party one by name alone. */
3331
+ export function ownScopePackages(dir) {
3332
+ let pkg;
3333
+ try {
3334
+ pkg = readPackageJson(dir);
3335
+ }
3336
+ catch {
3337
+ return [];
3338
+ }
3339
+ const name = pkg.name || '';
3340
+ const slash = name.indexOf('/');
3341
+ if (!name.startsWith('@') || slash < 2)
3342
+ return [];
3343
+ const scope = name.slice(0, slash + 1); // '@bobfrankston/'
3344
+ // npm hoists, so a dep's own deps usually sit in the root node_modules
3345
+ // rather than nested under it — check both.
3346
+ const resolveDep = (dep, from) => {
3347
+ for (const cand of [path.join(from, 'node_modules', dep), path.join(dir, 'node_modules', dep)]) {
3348
+ if (fs.existsSync(path.join(cand, 'package.json')))
3349
+ return cand;
3350
+ }
3351
+ return null;
3352
+ };
3353
+ const found = new Set([name]);
3354
+ const walk = (manifest, from) => {
3355
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.optionalDependencies || {}) };
3356
+ for (const dep of Object.keys(deps)) {
3357
+ if (!dep.startsWith(scope) || found.has(dep))
3358
+ continue;
3359
+ found.add(dep);
3360
+ const depDir = resolveDep(dep, from);
3361
+ if (!depDir)
3362
+ continue; // not installed here — the name is still allowed
3363
+ try {
3364
+ walk(JSON.parse(fs.readFileSync(path.join(depDir, 'package.json'), 'utf-8')), depDir);
3365
+ }
3366
+ catch { /* unreadable dep manifest — the name is still allowed */ }
3367
+ }
3368
+ };
3369
+ walk(pkg, dir);
3370
+ return [...found];
3371
+ }
3372
+ /** npm args allowing install scripts for our own packages only. Empty when
3373
+ * npm predates the policy (it runs the scripts anyway) or the package is
3374
+ * unscoped.
3375
+ *
3376
+ * Note: the CLI layer *replaces* the .npmrc layer rather than merging with
3377
+ * it (first source with any config wins, per npm's resolve-allow-scripts),
3378
+ * so passing this suppresses any allow-scripts entries in .npmrc for this
3379
+ * install. Third-party approvals therefore belong on the command line too,
3380
+ * not in .npmrc. */
3381
+ function allowOwnScriptsArgs(dir) {
3382
+ if (!npmSupportsAllowScripts())
3383
+ return [];
3384
+ const own = ownScopePackages(dir);
3385
+ return own.length ? ['--allow-scripts', own.join(',')] : [];
3386
+ }
3387
+ /** A skipped postinstall can leave a package broken at runtime (puppeteer
3388
+ * without its browser, native modules unbuilt), so surface the warning
3389
+ * instead of burying it in the captured output. Own-scope packages are
3390
+ * reported separately and louder: those were supposed to be allowed, so
3391
+ * one showing up here means the allowlist missed it. */
3392
+ function warnSkippedScripts(combined, allowed = []) {
3280
3393
  const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(/gm)];
3281
3394
  if (!pkgs.length)
3282
3395
  return;
3283
- const names = pkgs.map(m => m[1].replace(/@[^@]+$/, ''));
3284
- console.log(colors.yellow(` ⚠ npm skipped install scripts (allowScripts): ${pkgs.map(m => m[1]).join(', ')}`));
3285
- console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm config set allow-scripts=${names.join(',')} --location=user`));
3396
+ const nameOf = (spec) => spec.replace(/@[^@]+$/, '');
3397
+ const first = allowed[0] || '';
3398
+ const scope = first.startsWith('@') ? first.slice(0, first.indexOf('/') + 1) : '';
3399
+ const ours = scope ? pkgs.filter(m => nameOf(m[1]).startsWith(scope)) : [];
3400
+ const others = pkgs.filter(m => !ours.includes(m));
3401
+ if (ours.length) {
3402
+ console.log(colors.red(` ⚠ Own-scope install scripts still skipped: ${ours.map(m => m[1]).join(', ')}`));
3403
+ console.log(colors.red(` npmglobalize allowlisted ${allowed.length} own package(s) but missed these — likely transitive deps not present in the local node_modules tree.`));
3404
+ }
3405
+ if (others.length) {
3406
+ const names = [...new Set(others.map(m => nameOf(m[1])))];
3407
+ console.log(colors.yellow(` ⚠ npm skipped install scripts for third-party packages: ${others.map(m => m[1]).join(', ')}`));
3408
+ console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm install -g <pkg> --allow-scripts ${names.join(',')}`));
3409
+ }
3286
3410
  }
3287
3411
  /** Run npm install -g with retries for registry propagation delay.
3288
3412
  * Brand-new packages (first-time publish) take much longer to become
@@ -3301,16 +3425,20 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
3301
3425
  let result = { success: false, output: '', stderr: '' };
3302
3426
  let attempt = 0;
3303
3427
  let cacheFixRounds = 0;
3428
+ // Our own packages are trusted — we just built and published them from
3429
+ // this very directory — so their install scripts run. Everything else
3430
+ // stays behind npm's allowScripts gate.
3431
+ const allowArgs = allowOwnScriptsArgs(cwd);
3304
3432
  while (true) {
3305
- console.log(colors.cyan(`> npm install -g ${pkgSpec}`));
3306
- result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: true });
3433
+ console.log(colors.cyan(`> npm install -g ${pkgSpec}${allowArgs.length ? ` ${allowArgs.join(' ')}` : ''}`));
3434
+ result = await runCommandAsync('npm', ['install', '-g', pkgSpec, ...allowArgs], { cwd, silent: true });
3307
3435
  const combined = `${result.output}\n${result.stderr}`;
3308
3436
  fullLog += `===== attempt ${attempt + 1}: npm install -g ${pkgSpec} =====\n${combined}\n`;
3309
3437
  if (result.success) {
3310
3438
  const added = combined.match(/^(?:added|changed|removed) \d+ packages?.*$/m);
3311
3439
  if (added)
3312
3440
  console.log(colors.dim(` ${added[0]}`));
3313
- warnSkippedScripts(combined);
3441
+ warnSkippedScripts(combined, allowArgs.length ? allowArgs[1].split(',') : []);
3314
3442
  // A truncated puppeteer browser extraction exits 0, so the install
3315
3443
  // LOOKS fine while the cache is poisoned for every future install.
3316
3444
  // Verify now, while the leftover archive still allows a repair.
@@ -3548,6 +3676,14 @@ async function waitForNpmVersionInWsl(spec, maxWaitMs = 180000) {
3548
3676
  return false;
3549
3677
  }
3550
3678
  export async function installInWsl(wslArgs, opts = {}) {
3679
+ // Same trust rule as the Windows installs: allow our own packages'
3680
+ // install scripts, leave third-party ones gated. Probed against WSL's
3681
+ // npm, which is a separate install from the Windows one.
3682
+ if (opts.cwd && wslArgs.includes('install') && await wslNpmSupportsAllowScripts()) {
3683
+ const own = ownScopePackages(opts.cwd);
3684
+ if (own.length)
3685
+ wslArgs = [...wslArgs, '--allow-scripts', own.join(',')];
3686
+ }
3551
3687
  const runOnce = async () => {
3552
3688
  console.log(colors.cyan(`> wsl ${wslArgs.join(' ')}`));
3553
3689
  const r = await runCommandAsync('wsl', wslArgs, { cwd: opts.cwd, silent: true });
@@ -4035,29 +4171,39 @@ function getAuthInstructions() {
4035
4171
  return `
4036
4172
  Authentication Options:
4037
4173
 
4038
- 1. ${colors.yellow('Create a Granular Access Token')} (recommended):
4174
+ 1. ${colors.yellow('Interactive login')} (recommended):
4175
+ - Run: ${colors.green('npm login')}
4176
+ - Web-based: completes 2FA in the browser and writes the token for you.
4177
+ - The path that survives npm's token changes (see the note below).
4178
+
4179
+ 2. ${colors.yellow('Create a Granular Access Token')} (unattended use):
4039
4180
  - Go to: https://www.npmjs.com/settings/[username]/tokens
4040
4181
  - Click "Generate New Token" → "Granular Access Token"
4041
4182
  - Set permissions: ${colors.green('Read and write')} for packages
4042
- - Enable: ${colors.green('Bypass 2FA requirement')} (if available)
4043
- - Copy the token and save it securely
4183
+ - Scope it to the packages you actually publish, and set an expiry
4184
+ - ${colors.red('Do NOT enable "Bypass 2FA"')} — npm is retiring it:
4185
+ · since early Aug 2026, bypass tokens no longer skip 2FA for token,
4186
+ package-access, maintainer, org or team changes
4187
+ · around Jan 2027, they lose direct publish entirely (read + stage
4188
+ only; publication needs human 2FA approval)
4044
4189
 
4045
- 2. ${colors.yellow('Set token via environment variable')}:
4190
+ 3. ${colors.yellow('Set token via environment variable')}:
4046
4191
  - Set: ${colors.green('NPM_TOKEN=npm_xxx...')}
4047
4192
  - Or run: ${colors.green('$env:NPM_TOKEN="npm_xxx..."')} (PowerShell)
4048
4193
 
4049
- 3. ${colors.yellow('Set token in .npmrc')}:
4194
+ 4. ${colors.yellow('Set token in .npmrc')}:
4050
4195
  - Edit: ${colors.green(npmrcPath)}
4051
4196
  - Add: ${colors.green('//registry.npmjs.org/:_authToken=npm_xxx...')}
4052
4197
 
4053
- 4. ${colors.yellow('Use classic login')} (may require 2FA):
4054
- - Run: ${colors.green('npm login')}
4055
- - Follow interactive prompts
4198
+ 5. ${colors.yellow('Publishing from CI')}:
4199
+ - Prefer trusted publishing (OIDC) over any long-lived token — nothing to
4200
+ rotate, and it is the supported path once bypass tokens lose publish.
4056
4201
 
4057
4202
  ${colors.italic('Note: y:\\dev\\utils\\npmglobalize has set-npm-token.ps1 that may help fix token')}
4058
4203
  ${colors.italic(' problems, but no promises.')}
4059
4204
 
4060
- Note: npm now requires either 2FA or a granular token with bypass enabled.
4205
+ Note: unattended local publishing with a long-lived token is on a deprecation
4206
+ path. Plan for OIDC publishing from CI, or a publish step you approve with 2FA.
4061
4207
  `;
4062
4208
  }
4063
4209
  function loadIgnorePatterns() {
@@ -4989,7 +5135,7 @@ async function doLocalInstall(cwd, options) {
4989
5135
  console.log(' [dry-run] Would run: wsl npm install -g .');
4990
5136
  return true;
4991
5137
  }
4992
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5138
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
4993
5139
  if (result.success) {
4994
5140
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
4995
5141
  }
@@ -5000,7 +5146,7 @@ async function doLocalInstall(cwd, options) {
5000
5146
  }
5001
5147
  if (wsl) {
5002
5148
  console.log(`Installing ${pkgName} in WSL (local)...`);
5003
- const wslResult = await runCommandAsync('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5149
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
5004
5150
  if (wslResult.success) {
5005
5151
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
5006
5152
  }
@@ -5105,7 +5251,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5105
5251
  console.log(' [dry-run] Would run: wsl npm install -g .');
5106
5252
  return true;
5107
5253
  }
5108
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5254
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
5109
5255
  if (result.success) {
5110
5256
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
5111
5257
  }
@@ -5116,7 +5262,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5116
5262
  }
5117
5263
  if (wsl) {
5118
5264
  console.log(`Installing ${pkgName} in WSL (local)...`);
5119
- const wslResult = runCommand('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5265
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
5120
5266
  if (wslResult.success) {
5121
5267
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
5122
5268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.196",
3
+ "version": "1.0.197",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@bobfrankston/freezepak": "^0.1.9",
35
- "@bobfrankston/importgen": "^0.1.38",
35
+ "@bobfrankston/importgen": "^0.1.39",
36
36
  "@bobfrankston/themecolors": "^0.1.8",
37
37
  "@bobfrankston/userconfig": "^1.0.10",
38
38
  "@npmcli/package-json": "^7.0.4",
@@ -60,7 +60,7 @@
60
60
  ".transformedSnapshot": {
61
61
  "dependencies": {
62
62
  "@bobfrankston/freezepak": "^0.1.9",
63
- "@bobfrankston/importgen": "^0.1.38",
63
+ "@bobfrankston/importgen": "^0.1.39",
64
64
  "@bobfrankston/themecolors": "^0.1.8",
65
65
  "@bobfrankston/userconfig": "^1.0.10",
66
66
  "@npmcli/package-json": "^7.0.4",