@bobfrankston/npmglobalize 1.0.196 → 1.0.198

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 (4) hide show
  1. package/README.md +39 -0
  2. package/lib.d.ts +34 -0
  3. package/lib.js +315 -25
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -532,6 +532,45 @@ not yet change its behavior based on it. Behavior wiring (e.g. resolving
532
532
  `file:` deps to the latest published npm version instead of walking siblings)
533
533
  is planned.
534
534
 
535
+ ### `upstream` — who consumes this package
536
+
537
+ Bookkeeping, not a setting. When a package with `file:` deps publishes,
538
+ npmglobalize appends an entry to each **dependency's** `.globalize.json5`:
539
+
540
+ ```json5
541
+ {
542
+ "install": true,
543
+
544
+ // FYI: packages that depend on this one (immediate consumers,
545
+ // recorded when each of them publishes). Nothing is updated
546
+ // automatically; follow each path's own .globalize.json5 to
547
+ // walk further out.
548
+ "upstream": [
549
+ {"path":"Y:\\dev\\utils\\winpos","version":"2.0.51","updated":"2026-08-09"},
550
+ ],
551
+ }
552
+ ```
553
+
554
+ So publishing `winpos` (which has `"@bobfrankston/msger": "file:../msgx/msger"`)
555
+ records winpos in **msger's** config. Each entry carries the consumer's
556
+ checkout path, its version at the time, and the date.
557
+
558
+ Only **immediate** consumers are recorded. A full consumer tree is a walk, not
559
+ a copy: follow each entry's path and read that package's own `upstream` list.
560
+
561
+ The list is **FYI for now** — it is recorded, preserved across publishes, and
562
+ printed in the Release Summary of the package that owns it. Nothing is
563
+ rebuilt, republished, or reinstalled on its behalf. An entry is added the
564
+ first time a consumer publishes after this feature landed, and refreshed in
565
+ place on every publish after that.
566
+
567
+ The entry is written into the dependency's own checkout, then committed there
568
+ (`Record upstream <consumer>@<version>`) and pushed if that repo has a remote.
569
+ Only `.globalize.json5` is staged and committed, by pathspec — anything else
570
+ the dependency had staged or modified is left exactly as it was. If the
571
+ dependency isn't a git repo, or ignores `.globalize.json5`, the file is still
572
+ written and the commit is skipped.
573
+
535
574
  ## Common Workflows
536
575
 
537
576
  ### Standard Release
package/lib.d.ts CHANGED
@@ -25,6 +25,16 @@ export declare function clearBuildIssues(): void;
25
25
  /** Extract the first TypeScript error line from build output for the summary.
26
26
  * Returns a short string like "file.ts(42,5): error TS2339: Property 'foo' ..." */
27
27
  export declare function extractFirstTscError(output: string): string | null;
28
+ /** One package that depends on this one, recorded in this package's
29
+ * .globalize.json5 when that package publishes. */
30
+ export interface UpstreamEntry {
31
+ /** Absolute path of the consumer's checkout */
32
+ path: string;
33
+ /** Consumer's version at the time it was recorded */
34
+ version: string;
35
+ /** Date the entry was last written (YYYY-MM-DD) */
36
+ updated: string;
37
+ }
28
38
  /** Options for the globalize operation */
29
39
  export interface GlobalizeOptions {
30
40
  /** Bump type: patch (default), minor, major */
@@ -132,6 +142,12 @@ export interface GlobalizeOptions {
132
142
  * that rely on workspace cross-refs / -public-deps cascade. Opt in when
133
143
  * the assumption holds. */
134
144
  importCheck?: boolean;
145
+ /** Bookkeeping, not a CLI option: the packages that depend on this one,
146
+ * written into this package's .globalize.json5 when each of them
147
+ * publishes. Immediate consumers only - a full consumer tree is walked by
148
+ * following each entry's own .globalize.json5. FYI for now: recorded and
149
+ * displayed, nothing is updated automatically. */
150
+ upstream?: UpstreamEntry[];
135
151
  /** Internal: signals this call is from workspace orchestrator */
136
152
  /** Skip the upfront dep-graph prescan */
137
153
  noPrescan?: boolean;
@@ -289,6 +305,16 @@ export declare function prescanDepGraph(baseDir: string, visited?: Set<string>,
289
305
  export declare function restoreDeps(pkg: any, verbose?: boolean): boolean;
290
306
  /** Check if .dependencies exist (already transformed) */
291
307
  export declare function hasBackup(pkg: any): boolean;
308
+ /** Record this package in the `upstream` list of each package it depends on
309
+ * via file:, so a library knows who consumes it.
310
+ *
311
+ * Immediate consumers only — each entry's own .globalize.json5 carries its
312
+ * consumers, so the full tree is a walk rather than a copy. Nothing acts on
313
+ * the list yet; it's FYI for a future "update my consumers" pass.
314
+ *
315
+ * Best-effort: a dep whose config can't be written is reported and skipped,
316
+ * never fatal - this runs after a successful publish. */
317
+ export declare function recordUpstream(cwd: string, dryRun?: boolean, verbose?: boolean): Promise<void>;
292
318
  /** Get the latest git tag (if any) */
293
319
  export declare function getLatestGitTag(cwd: string): string | null;
294
320
  /** Check if a git tag exists */
@@ -457,6 +483,14 @@ export declare function fixPuppeteerCorruptedCache(combined: string): {
457
483
  repaired: string[];
458
484
  removed: string[];
459
485
  };
486
+ /** Every package in `dir`'s dependency closure that shares its scope — our
487
+ * own packages, published by this same tool. Walks node_modules for
488
+ * transitive own-scope deps, since a cascade dep can carry its own
489
+ * postinstall and only its installed copy lists its dependencies.
490
+ *
491
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
492
+ * package of ours from a third-party one by name alone. */
493
+ export declare function ownScopePackages(dir: string): string[];
460
494
  /** Run a command and return success status */
461
495
  export declare function runCommand(cmd: string, args: string[], options?: {
462
496
  silent?: boolean;
package/lib.js CHANGED
@@ -240,6 +240,12 @@ export function writeConfig(dir, config, explicitKeys) {
240
240
  const existing = readConfig(dir);
241
241
  // Filter out temporary flags and default values (unless explicitly set)
242
242
  const filtered = {};
243
+ // upstream is bookkeeping written by recordUpstream, never a CLI option, so
244
+ // it isn't in the options object a normal publish passes here. Carry it
245
+ // across or this package's own next publish would erase its consumer list.
246
+ if (config.upstream === undefined && existing.upstream !== undefined) {
247
+ config = { ...config, upstream: existing.upstream };
248
+ }
243
249
  const omitKeys = new Set(['cleanup', 'init', 'dryRun', 'message', 'conform', 'asis', 'help', 'error', 'updateDeps', 'updateMajor', 'publishDeps', 'publicDeps', 'forcePublish', 'once', 'cleanNestedModules']);
244
250
  for (const [key, value] of Object.entries(config)) {
245
251
  if (omitKeys.has(key))
@@ -263,6 +269,19 @@ export function writeConfig(dir, config, explicitKeys) {
263
269
  const entries = Object.entries(filtered);
264
270
  if (entries.length > 0) {
265
271
  entries.forEach(([key, value]) => {
272
+ // upstream is a list of records - one per line beats one long line
273
+ if (key === 'upstream' && Array.isArray(value)) {
274
+ lines.push(' // FYI: packages that depend on this one (immediate consumers,');
275
+ lines.push(' // recorded when each of them publishes). Nothing is updated');
276
+ lines.push(' // automatically; follow each path\'s own .globalize.json5 to');
277
+ lines.push(' // walk further out.');
278
+ lines.push(' "upstream": [');
279
+ for (const entry of value) {
280
+ lines.push(` ${JSON.stringify(entry)},`);
281
+ }
282
+ lines.push(' ],');
283
+ return;
284
+ }
266
285
  const jsonValue = typeof value === 'string' ? `"${value}"` : JSON.stringify(value);
267
286
  const comma = ','; // JSON5 allows trailing commas
268
287
  // Add inline comment for clarity
@@ -1541,6 +1560,117 @@ export function restoreDeps(pkg, verbose = false) {
1541
1560
  export function hasBackup(pkg) {
1542
1561
  return DEP_KEYS.some(key => pkg['.' + key]);
1543
1562
  }
1563
+ /** Every file: dep target of a package, by absolute path.
1564
+ * Looks in the .dependencies backup too, so it finds them whether or not the
1565
+ * file: paths have been restored yet (--nofiles leaves ^versions in place). */
1566
+ function fileDepTargets(pkg, baseDir) {
1567
+ const targets = new Set();
1568
+ for (const key of DEP_KEYS) {
1569
+ for (const source of [pkg[key], pkg['.' + key]]) {
1570
+ if (!source)
1571
+ continue;
1572
+ for (const value of Object.values(source)) {
1573
+ if (isFileRef(value)) {
1574
+ targets.add(resolveFilePath(value, baseDir));
1575
+ }
1576
+ }
1577
+ }
1578
+ }
1579
+ return [...targets];
1580
+ }
1581
+ /** Commit (and push) the .globalize.json5 we just wrote in a dependency's repo,
1582
+ * so the consumer list doesn't sit as an uncommitted change in someone else's
1583
+ * checkout until their next publish.
1584
+ *
1585
+ * Stages and commits that one pathspec, never `git add -A`: whatever else the
1586
+ * dependency's owner had staged or modified stays untouched, so a publish here
1587
+ * can't sweep up someone else's work in progress. */
1588
+ async function commitUpstreamEntry(target, consumer, verbose) {
1589
+ if (!runCommand('git', ['rev-parse', '--git-dir'], { cwd: target, silent: true }).success) {
1590
+ return; // Not a git repo - the file is written, there's nothing to commit
1591
+ }
1592
+ // Stage it first: a config file the dependency has never had before is
1593
+ // untracked, and `git commit -- <path>` rejects a pathspec git doesn't know.
1594
+ const add = runCommand('git', ['add', '--', '.globalize.json5'], { cwd: target, silent: true });
1595
+ if (!add.success) {
1596
+ // Deliberately ignored in that repo - respect it, the file is still written
1597
+ if (/ignored by/i.test(add.stderr + add.output)) {
1598
+ if (verbose)
1599
+ console.log(colors.dim(` .globalize.json5 is git-ignored in ${target} - not committing`));
1600
+ return;
1601
+ }
1602
+ console.error(colors.yellow(` Warning: could not stage upstream entry in ${target}: ${add.stderr.trim() || add.output.trim()}`));
1603
+ return;
1604
+ }
1605
+ const commit = runCommand('git', ['commit', '-m', `Record upstream ${consumer}`, '--', '.globalize.json5'], { cwd: target, silent: true });
1606
+ if (!commit.success) {
1607
+ // Re-recording an unchanged entry leaves nothing to commit - not a problem
1608
+ const combined = (commit.output + ' ' + commit.stderr).toLowerCase();
1609
+ if (combined.includes('nothing to commit') || combined.includes('no changes added'))
1610
+ return;
1611
+ console.error(colors.yellow(` Warning: could not commit upstream entry in ${target}: ${commit.stderr.trim() || commit.output.trim()}`));
1612
+ return;
1613
+ }
1614
+ if (verbose)
1615
+ console.log(colors.dim(` Committed upstream entry in ${target}`));
1616
+ const hasRemote = runCommand('git', ['remote'], { cwd: target, silent: true }).output.trim().length > 0;
1617
+ if (hasRemote)
1618
+ await pushWithProtection(target, verbose);
1619
+ }
1620
+ /** Record this package in the `upstream` list of each package it depends on
1621
+ * via file:, so a library knows who consumes it.
1622
+ *
1623
+ * Immediate consumers only — each entry's own .globalize.json5 carries its
1624
+ * consumers, so the full tree is a walk rather than a copy. Nothing acts on
1625
+ * the list yet; it's FYI for a future "update my consumers" pass.
1626
+ *
1627
+ * Best-effort: a dep whose config can't be written is reported and skipped,
1628
+ * never fatal - this runs after a successful publish. */
1629
+ export async function recordUpstream(cwd, dryRun = false, verbose = false) {
1630
+ let pkg;
1631
+ try {
1632
+ pkg = readPackageJson(cwd);
1633
+ }
1634
+ catch {
1635
+ return;
1636
+ }
1637
+ const targets = fileDepTargets(pkg, cwd);
1638
+ if (targets.length === 0)
1639
+ return;
1640
+ const entry = {
1641
+ path: path.resolve(cwd),
1642
+ version: pkg.version,
1643
+ updated: new Date().toISOString().slice(0, 10)
1644
+ };
1645
+ for (const target of targets) {
1646
+ try {
1647
+ if (!fs.existsSync(path.join(target, 'package.json')))
1648
+ continue;
1649
+ const targetConfig = readConfig(target);
1650
+ const upstream = [...(targetConfig.upstream ?? [])];
1651
+ // Path identity, case-insensitive: the same checkout reached twice
1652
+ // updates its entry rather than adding a second one.
1653
+ const same = (p) => path.resolve(p).toLowerCase() === entry.path.toLowerCase();
1654
+ const idx = upstream.findIndex(u => same(u.path));
1655
+ if (idx >= 0)
1656
+ upstream[idx] = entry;
1657
+ else
1658
+ upstream.push(entry);
1659
+ if (dryRun) {
1660
+ console.log(` [dry-run] Would record ${pkg.name}@${entry.version} upstream of ${path.basename(target)}`);
1661
+ continue;
1662
+ }
1663
+ writeConfig(target, { ...targetConfig, upstream });
1664
+ if (verbose) {
1665
+ console.log(colors.dim(` Recorded upstream ${pkg.name}@${entry.version} in ${target}`));
1666
+ }
1667
+ await commitUpstreamEntry(target, `${pkg.name}@${entry.version}`, verbose);
1668
+ }
1669
+ catch (error) {
1670
+ console.error(colors.yellow(` Warning: could not record upstream in ${target}: ${error.message}`));
1671
+ }
1672
+ }
1673
+ }
1544
1674
  /** Get the latest git tag (if any) */
1545
1675
  export function getLatestGitTag(cwd) {
1546
1676
  try {
@@ -3271,18 +3401,142 @@ export function fixPuppeteerCorruptedCache(combined) {
3271
3401
  }
3272
3402
  return { repaired: [], removed: [] };
3273
3403
  }
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) {
3404
+ // ─── allowScripts policy: trust our own packages, gate everyone else ──
3405
+ //
3406
+ // npm 11.17+ skips install-time lifecycle scripts (preinstall/install/
3407
+ // postinstall) for packages that aren't on an allowlist, and a global
3408
+ // install has no project package.json to record approvals in — so a
3409
+ // postinstall we wrote ourselves silently never runs while the install
3410
+ // still exits 0. (@bobfrankston/msger's postinstall copies its native
3411
+ // binary into the per-user bin dir its launcher reads from; skipping it
3412
+ // leaves a stale exe behind a green checkmark.)
3413
+ //
3414
+ // npmglobalize only ever installs a package it just built and published
3415
+ // from the user's own source, so own-scope packages are trusted. Third-
3416
+ // party deps are not: they stay gated and keep surfacing through
3417
+ // warnSkippedScripts. That's deliberately narrower than a blanket
3418
+ // --dangerously-allow-all-scripts — it keeps the warning meaningful
3419
+ // instead of turning it into dead code.
3420
+ //
3421
+ // The policy matcher has no scope wildcard (`@scope/*` matches nothing —
3422
+ // see arborist's script-allowed.js), so the allowlist must name every
3423
+ // own-scope package in the tree explicitly.
3424
+ let _allowScriptsSupported = null;
3425
+ let _wslAllowScriptsSupported = null;
3426
+ /** Probe output for a config key npm doesn't define. */
3427
+ const isUnknownConfig = (out) => {
3428
+ const v = out.trim();
3429
+ return v === '' || v === 'undefined';
3430
+ };
3431
+ /** Does this npm know the allowScripts policy (11.17+)? Older npm runs all
3432
+ * install scripts unconditionally, and passing the flag there just prints
3433
+ * "Unknown cli config" on every install. Probed once per process.
3434
+ *
3435
+ * Probes the boolean sibling flag rather than `allow-scripts` itself: an
3436
+ * unset list config also prints empty, which is indistinguishable from
3437
+ * "not supported". Both flags landed in the same npm release. */
3438
+ function npmSupportsAllowScripts() {
3439
+ if (_allowScriptsSupported === null) {
3440
+ const r = runCommand('npm', ['config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3441
+ _allowScriptsSupported = r.success && !isUnknownConfig(r.output);
3442
+ }
3443
+ return _allowScriptsSupported;
3444
+ }
3445
+ /** Same probe against WSL's npm, which is a separate install and can lag
3446
+ * behind the Windows one. */
3447
+ async function wslNpmSupportsAllowScripts() {
3448
+ if (_wslAllowScriptsSupported === null) {
3449
+ const r = await runCommandAsync('wsl', ['npm', 'config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3450
+ _wslAllowScriptsSupported = r.success && !isUnknownConfig(r.output);
3451
+ }
3452
+ return _wslAllowScriptsSupported;
3453
+ }
3454
+ /** Every package in `dir`'s dependency closure that shares its scope — our
3455
+ * own packages, published by this same tool. Walks node_modules for
3456
+ * transitive own-scope deps, since a cascade dep can carry its own
3457
+ * postinstall and only its installed copy lists its dependencies.
3458
+ *
3459
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
3460
+ * package of ours from a third-party one by name alone. */
3461
+ export function ownScopePackages(dir) {
3462
+ let pkg;
3463
+ try {
3464
+ pkg = readPackageJson(dir);
3465
+ }
3466
+ catch {
3467
+ return [];
3468
+ }
3469
+ const name = pkg.name || '';
3470
+ const slash = name.indexOf('/');
3471
+ if (!name.startsWith('@') || slash < 2)
3472
+ return [];
3473
+ const scope = name.slice(0, slash + 1); // '@bobfrankston/'
3474
+ // npm hoists, so a dep's own deps usually sit in the root node_modules
3475
+ // rather than nested under it — check both.
3476
+ const resolveDep = (dep, from) => {
3477
+ for (const cand of [path.join(from, 'node_modules', dep), path.join(dir, 'node_modules', dep)]) {
3478
+ if (fs.existsSync(path.join(cand, 'package.json')))
3479
+ return cand;
3480
+ }
3481
+ return null;
3482
+ };
3483
+ const found = new Set([name]);
3484
+ const walk = (manifest, from) => {
3485
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.optionalDependencies || {}) };
3486
+ for (const dep of Object.keys(deps)) {
3487
+ if (!dep.startsWith(scope) || found.has(dep))
3488
+ continue;
3489
+ found.add(dep);
3490
+ const depDir = resolveDep(dep, from);
3491
+ if (!depDir)
3492
+ continue; // not installed here — the name is still allowed
3493
+ try {
3494
+ walk(JSON.parse(fs.readFileSync(path.join(depDir, 'package.json'), 'utf-8')), depDir);
3495
+ }
3496
+ catch { /* unreadable dep manifest — the name is still allowed */ }
3497
+ }
3498
+ };
3499
+ walk(pkg, dir);
3500
+ return [...found];
3501
+ }
3502
+ /** npm args allowing install scripts for our own packages only. Empty when
3503
+ * npm predates the policy (it runs the scripts anyway) or the package is
3504
+ * unscoped.
3505
+ *
3506
+ * Note: the CLI layer *replaces* the .npmrc layer rather than merging with
3507
+ * it (first source with any config wins, per npm's resolve-allow-scripts),
3508
+ * so passing this suppresses any allow-scripts entries in .npmrc for this
3509
+ * install. Third-party approvals therefore belong on the command line too,
3510
+ * not in .npmrc. */
3511
+ function allowOwnScriptsArgs(dir) {
3512
+ if (!npmSupportsAllowScripts())
3513
+ return [];
3514
+ const own = ownScopePackages(dir);
3515
+ return own.length ? ['--allow-scripts', own.join(',')] : [];
3516
+ }
3517
+ /** A skipped postinstall can leave a package broken at runtime (puppeteer
3518
+ * without its browser, native modules unbuilt), so surface the warning
3519
+ * instead of burying it in the captured output. Own-scope packages are
3520
+ * reported separately and louder: those were supposed to be allowed, so
3521
+ * one showing up here means the allowlist missed it. */
3522
+ function warnSkippedScripts(combined, allowed = []) {
3280
3523
  const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(/gm)];
3281
3524
  if (!pkgs.length)
3282
3525
  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`));
3526
+ const nameOf = (spec) => spec.replace(/@[^@]+$/, '');
3527
+ const first = allowed[0] || '';
3528
+ const scope = first.startsWith('@') ? first.slice(0, first.indexOf('/') + 1) : '';
3529
+ const ours = scope ? pkgs.filter(m => nameOf(m[1]).startsWith(scope)) : [];
3530
+ const others = pkgs.filter(m => !ours.includes(m));
3531
+ if (ours.length) {
3532
+ console.log(colors.red(` ⚠ Own-scope install scripts still skipped: ${ours.map(m => m[1]).join(', ')}`));
3533
+ 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.`));
3534
+ }
3535
+ if (others.length) {
3536
+ const names = [...new Set(others.map(m => nameOf(m[1])))];
3537
+ console.log(colors.yellow(` ⚠ npm skipped install scripts for third-party packages: ${others.map(m => m[1]).join(', ')}`));
3538
+ console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm install -g <pkg> --allow-scripts ${names.join(',')}`));
3539
+ }
3286
3540
  }
3287
3541
  /** Run npm install -g with retries for registry propagation delay.
3288
3542
  * Brand-new packages (first-time publish) take much longer to become
@@ -3301,16 +3555,20 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
3301
3555
  let result = { success: false, output: '', stderr: '' };
3302
3556
  let attempt = 0;
3303
3557
  let cacheFixRounds = 0;
3558
+ // Our own packages are trusted — we just built and published them from
3559
+ // this very directory — so their install scripts run. Everything else
3560
+ // stays behind npm's allowScripts gate.
3561
+ const allowArgs = allowOwnScriptsArgs(cwd);
3304
3562
  while (true) {
3305
- console.log(colors.cyan(`> npm install -g ${pkgSpec}`));
3306
- result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: true });
3563
+ console.log(colors.cyan(`> npm install -g ${pkgSpec}${allowArgs.length ? ` ${allowArgs.join(' ')}` : ''}`));
3564
+ result = await runCommandAsync('npm', ['install', '-g', pkgSpec, ...allowArgs], { cwd, silent: true });
3307
3565
  const combined = `${result.output}\n${result.stderr}`;
3308
3566
  fullLog += `===== attempt ${attempt + 1}: npm install -g ${pkgSpec} =====\n${combined}\n`;
3309
3567
  if (result.success) {
3310
3568
  const added = combined.match(/^(?:added|changed|removed) \d+ packages?.*$/m);
3311
3569
  if (added)
3312
3570
  console.log(colors.dim(` ${added[0]}`));
3313
- warnSkippedScripts(combined);
3571
+ warnSkippedScripts(combined, allowArgs.length ? allowArgs[1].split(',') : []);
3314
3572
  // A truncated puppeteer browser extraction exits 0, so the install
3315
3573
  // LOOKS fine while the cache is poisoned for every future install.
3316
3574
  // Verify now, while the leftover archive still allows a repair.
@@ -3548,6 +3806,14 @@ async function waitForNpmVersionInWsl(spec, maxWaitMs = 180000) {
3548
3806
  return false;
3549
3807
  }
3550
3808
  export async function installInWsl(wslArgs, opts = {}) {
3809
+ // Same trust rule as the Windows installs: allow our own packages'
3810
+ // install scripts, leave third-party ones gated. Probed against WSL's
3811
+ // npm, which is a separate install from the Windows one.
3812
+ if (opts.cwd && wslArgs.includes('install') && await wslNpmSupportsAllowScripts()) {
3813
+ const own = ownScopePackages(opts.cwd);
3814
+ if (own.length)
3815
+ wslArgs = [...wslArgs, '--allow-scripts', own.join(',')];
3816
+ }
3551
3817
  const runOnce = async () => {
3552
3818
  console.log(colors.cyan(`> wsl ${wslArgs.join(' ')}`));
3553
3819
  const r = await runCommandAsync('wsl', wslArgs, { cwd: opts.cwd, silent: true });
@@ -4035,29 +4301,39 @@ function getAuthInstructions() {
4035
4301
  return `
4036
4302
  Authentication Options:
4037
4303
 
4038
- 1. ${colors.yellow('Create a Granular Access Token')} (recommended):
4304
+ 1. ${colors.yellow('Interactive login')} (recommended):
4305
+ - Run: ${colors.green('npm login')}
4306
+ - Web-based: completes 2FA in the browser and writes the token for you.
4307
+ - The path that survives npm's token changes (see the note below).
4308
+
4309
+ 2. ${colors.yellow('Create a Granular Access Token')} (unattended use):
4039
4310
  - Go to: https://www.npmjs.com/settings/[username]/tokens
4040
4311
  - Click "Generate New Token" → "Granular Access Token"
4041
4312
  - 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
4313
+ - Scope it to the packages you actually publish, and set an expiry
4314
+ - ${colors.red('Do NOT enable "Bypass 2FA"')} — npm is retiring it:
4315
+ · since early Aug 2026, bypass tokens no longer skip 2FA for token,
4316
+ package-access, maintainer, org or team changes
4317
+ · around Jan 2027, they lose direct publish entirely (read + stage
4318
+ only; publication needs human 2FA approval)
4044
4319
 
4045
- 2. ${colors.yellow('Set token via environment variable')}:
4320
+ 3. ${colors.yellow('Set token via environment variable')}:
4046
4321
  - Set: ${colors.green('NPM_TOKEN=npm_xxx...')}
4047
4322
  - Or run: ${colors.green('$env:NPM_TOKEN="npm_xxx..."')} (PowerShell)
4048
4323
 
4049
- 3. ${colors.yellow('Set token in .npmrc')}:
4324
+ 4. ${colors.yellow('Set token in .npmrc')}:
4050
4325
  - Edit: ${colors.green(npmrcPath)}
4051
4326
  - Add: ${colors.green('//registry.npmjs.org/:_authToken=npm_xxx...')}
4052
4327
 
4053
- 4. ${colors.yellow('Use classic login')} (may require 2FA):
4054
- - Run: ${colors.green('npm login')}
4055
- - Follow interactive prompts
4328
+ 5. ${colors.yellow('Publishing from CI')}:
4329
+ - Prefer trusted publishing (OIDC) over any long-lived token — nothing to
4330
+ rotate, and it is the supported path once bypass tokens lose publish.
4056
4331
 
4057
4332
  ${colors.italic('Note: y:\\dev\\utils\\npmglobalize has set-npm-token.ps1 that may help fix token')}
4058
4333
  ${colors.italic(' problems, but no promises.')}
4059
4334
 
4060
- Note: npm now requires either 2FA or a granular token with bypass enabled.
4335
+ Note: unattended local publishing with a long-lived token is on a deprecation
4336
+ path. Plan for OIDC publishing from CI, or a publish step you approve with 2FA.
4061
4337
  `;
4062
4338
  }
4063
4339
  function loadIgnorePatterns() {
@@ -4989,7 +5265,7 @@ async function doLocalInstall(cwd, options) {
4989
5265
  console.log(' [dry-run] Would run: wsl npm install -g .');
4990
5266
  return true;
4991
5267
  }
4992
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5268
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
4993
5269
  if (result.success) {
4994
5270
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
4995
5271
  }
@@ -5000,7 +5276,7 @@ async function doLocalInstall(cwd, options) {
5000
5276
  }
5001
5277
  if (wsl) {
5002
5278
  console.log(`Installing ${pkgName} in WSL (local)...`);
5003
- const wslResult = await runCommandAsync('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5279
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
5004
5280
  if (wslResult.success) {
5005
5281
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
5006
5282
  }
@@ -5105,7 +5381,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5105
5381
  console.log(' [dry-run] Would run: wsl npm install -g .');
5106
5382
  return true;
5107
5383
  }
5108
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5384
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
5109
5385
  if (result.success) {
5110
5386
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
5111
5387
  }
@@ -5116,7 +5392,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5116
5392
  }
5117
5393
  if (wsl) {
5118
5394
  console.log(`Installing ${pkgName} in WSL (local)...`);
5119
- const wslResult = runCommand('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5395
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
5120
5396
  if (wslResult.success) {
5121
5397
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
5122
5398
  }
@@ -7309,6 +7585,9 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7309
7585
  else if (!files) {
7310
7586
  console.log('Keeping npm versions (--nofiles mode).');
7311
7587
  }
7588
+ // Tell each file: dependency that this package consumes it. Runs after the
7589
+ // publish so the version recorded is the one that actually shipped.
7590
+ await recordUpstream(cwd, dryRun, verbose);
7312
7591
  console.log(`${timestamp()} Done!`);
7313
7592
  // Run final audit report if not already run
7314
7593
  const auditAlreadyRun = (fix || updateDeps) && (transformResult.transformed || alreadyTransformed || updateDeps);
@@ -7345,6 +7624,17 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7345
7624
  if (files && transformResult.transformed) {
7346
7625
  console.log(` Restored file: deps: ${colors.green('✓')}`);
7347
7626
  }
7627
+ // Who consumes what was just published. FYI only - nothing was updated for
7628
+ // them; this is the list you'd walk if you wanted to.
7629
+ {
7630
+ const consumers = readConfig(cwd).upstream ?? [];
7631
+ if (consumers.length > 0) {
7632
+ console.log(` Upstream consumers (not updated): ${consumers.length}`);
7633
+ for (const c of consumers) {
7634
+ console.log(` ${colors.dim('·')} ${path.basename(c.path)} v${c.version} ${colors.dim(`(${c.updated}, ${c.path})`)}`);
7635
+ }
7636
+ }
7637
+ }
7348
7638
  // Surface non-default settings the user may have forgotten about
7349
7639
  {
7350
7640
  const surprises = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.196",
3
+ "version": "1.0.198",
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",