@bobfrankston/npmglobalize 1.0.197 → 1.0.199

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 +26 -0
  3. package/lib.js +184 -10
  4. package/package.json +1 -1
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 */
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 {
@@ -3384,28 +3514,58 @@ function allowOwnScriptsArgs(dir) {
3384
3514
  const own = ownScopePackages(dir);
3385
3515
  return own.length ? ['--allow-scripts', own.join(',')] : [];
3386
3516
  }
3517
+ /** Install-script runners that fetch or select a prebuilt binary and only
3518
+ * compile when no prebuild matches the platform. A package using one of these
3519
+ * ships working binaries in its tarball, so a skipped script is very rarely
3520
+ * the thing that breaks it — koffi (cnoke) is the standing example: it carries
3521
+ * build/koffi/<platform>/koffi.node for every platform it supports. */
3522
+ const PREBUILT_SCRIPT_RUNNERS = /\b(prebuild-install|node-gyp-build|prebuildify|cnoke|napi-postinstall)\b/;
3387
3523
  /** A skipped postinstall can leave a package broken at runtime (puppeteer
3388
3524
  * without its browser, native modules unbuilt), so surface the warning
3389
3525
  * instead of burying it in the captured output. Own-scope packages are
3390
3526
  * 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 = []) {
3393
- const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(/gm)];
3527
+ * one showing up here means the allowlist missed it.
3528
+ *
3529
+ * npm names the script it skipped in the warn line — "(install: node
3530
+ * src/cnoke/cnoke.js ... --prebuild)" — so the report can say WHICH script
3531
+ * and whether it's the kind that has a prebuilt fallback, rather than
3532
+ * flatly claiming every skip may have broken something. */
3533
+ function warnSkippedScripts(combined, allowed = [], pkgSpec) {
3534
+ const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(([^)]*)\)/gm)]
3535
+ .map(m => ({ spec: m[1], detail: m[2] }));
3394
3536
  if (!pkgs.length)
3395
3537
  return;
3396
3538
  const nameOf = (spec) => spec.replace(/@[^@]+$/, '');
3397
3539
  const first = allowed[0] || '';
3398
3540
  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));
3541
+ const ours = scope ? pkgs.filter(p => nameOf(p.spec).startsWith(scope)) : [];
3542
+ const others = pkgs.filter(p => !ours.includes(p));
3401
3543
  if (ours.length) {
3402
- console.log(colors.red(` ⚠ Own-scope install scripts still skipped: ${ours.map(m => m[1]).join(', ')}`));
3544
+ console.log(colors.red(` ⚠ Own-scope install scripts still skipped: ${ours.map(p => p.spec).join(', ')}`));
3403
3545
  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
3546
  }
3405
3547
  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(',')}`));
3548
+ const prebuilt = others.filter(p => PREBUILT_SCRIPT_RUNNERS.test(p.detail));
3549
+ const unknown = others.filter(p => !prebuilt.includes(p));
3550
+ for (const p of prebuilt) {
3551
+ const script = p.detail.split(':')[0];
3552
+ console.log(colors.dim(` · Skipped ${p.spec} ${script} script — prebuilt-binary fetcher, the package ships binaries; normally harmless.`));
3553
+ }
3554
+ if (unknown.length) {
3555
+ console.log(colors.yellow(` ⚠ npm skipped install scripts for third-party packages: ${unknown.map(p => p.spec).join(', ')}`));
3556
+ for (const p of unknown)
3557
+ console.log(colors.yellow(` ${p.spec} (${p.detail})`));
3558
+ console.log(colors.yellow(` These may be broken at runtime — no prebuilt fallback recognized.`));
3559
+ }
3560
+ // The re-run command must carry the OWN packages too: npm takes the
3561
+ // allowlist from the first source that has one, so a command listing
3562
+ // only the third-party name silently re-gates ours (msger's postinstall
3563
+ // copies its native binary — losing it is the failure this all exists
3564
+ // to prevent). Print the whole list, and the real spec rather than a
3565
+ // <pkg> placeholder nobody can paste.
3566
+ const rerun = [...allowed, ...new Set(others.map(p => nameOf(p.spec)))].join(',');
3567
+ const target = pkgSpec ?? '<pkg>';
3568
+ console.log(colors.dim(` To run them anyway: npm install -g ${target} --allow-scripts ${rerun}`));
3409
3569
  }
3410
3570
  }
3411
3571
  /** Run npm install -g with retries for registry propagation delay.
@@ -3438,7 +3598,7 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
3438
3598
  const added = combined.match(/^(?:added|changed|removed) \d+ packages?.*$/m);
3439
3599
  if (added)
3440
3600
  console.log(colors.dim(` ${added[0]}`));
3441
- warnSkippedScripts(combined, allowArgs.length ? allowArgs[1].split(',') : []);
3601
+ warnSkippedScripts(combined, allowArgs.length ? allowArgs[1].split(',') : [], pkgSpec);
3442
3602
  // A truncated puppeteer browser extraction exits 0, so the install
3443
3603
  // LOOKS fine while the cache is poisoned for every future install.
3444
3604
  // Verify now, while the leftover archive still allows a repair.
@@ -7455,6 +7615,9 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7455
7615
  else if (!files) {
7456
7616
  console.log('Keeping npm versions (--nofiles mode).');
7457
7617
  }
7618
+ // Tell each file: dependency that this package consumes it. Runs after the
7619
+ // publish so the version recorded is the one that actually shipped.
7620
+ await recordUpstream(cwd, dryRun, verbose);
7458
7621
  console.log(`${timestamp()} Done!`);
7459
7622
  // Run final audit report if not already run
7460
7623
  const auditAlreadyRun = (fix || updateDeps) && (transformResult.transformed || alreadyTransformed || updateDeps);
@@ -7491,6 +7654,17 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7491
7654
  if (files && transformResult.transformed) {
7492
7655
  console.log(` Restored file: deps: ${colors.green('✓')}`);
7493
7656
  }
7657
+ // Who consumes what was just published. FYI only - nothing was updated for
7658
+ // them; this is the list you'd walk if you wanted to.
7659
+ {
7660
+ const consumers = readConfig(cwd).upstream ?? [];
7661
+ if (consumers.length > 0) {
7662
+ console.log(` Upstream consumers (not updated): ${consumers.length}`);
7663
+ for (const c of consumers) {
7664
+ console.log(` ${colors.dim('·')} ${path.basename(c.path)} v${c.version} ${colors.dim(`(${c.updated}, ${c.path})`)}`);
7665
+ }
7666
+ }
7667
+ }
7494
7668
  // Surface non-default settings the user may have forgotten about
7495
7669
  {
7496
7670
  const surprises = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.197",
3
+ "version": "1.0.199",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",