@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.
- package/README.md +39 -0
- package/lib.d.ts +34 -0
- package/lib.js +315 -25
- 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
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
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
|
|
3284
|
-
|
|
3285
|
-
|
|
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('
|
|
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
|
-
-
|
|
4043
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4054
|
-
-
|
|
4055
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|