@bobfrankston/npmglobalize 1.0.216 → 1.0.217

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 (5) hide show
  1. package/README.md +22 -13
  2. package/cli.js +14 -2
  3. package/lib.d.ts +50 -11
  4. package/lib.js +233 -142
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -142,19 +142,24 @@ npmglobalize --update-major
142
142
 
143
143
  ### 🔒 Security Auditing
144
144
 
145
- **Check vulnerabilities**:
146
- ```bash
147
- npmglobalize # Shows audit at end
148
- ```
145
+ Every run ends with an `npm audit` report. The **Release Summary** (or Transform Summary
146
+ with `-nopublish`) carries an `Audit:` row — `✓ no vulnerabilities`, the severity counts,
147
+ or `not run` with npm's reason (typically `ENOLOCK` when there is no lockfile). Critical
148
+ and high findings are listed one per package in the **Issues Summary** at the very end,
149
+ each with the advisory title and URL, how the package got in (`through …` / `pulled in
150
+ by …` / `direct dependency`), and what would clear it:
149
151
 
150
- **Auto-fix vulnerabilities**:
151
- ```bash
152
- npmglobalize --fix # Runs npm audit fix
153
- ```
152
+ - `fix: npm audit fix` — a compatible update exists; the next run's fix step takes it
153
+ - `fix: npm audit fix --force → node-gyp@13.0.2 (breaking — not run automatically)` — only a
154
+ semver-major bump clears it; npmglobalize never passes `--force`, that is your call
155
+ - `fix: none published yet` — wait for upstream
156
+
157
+ **Auto-fix** (`-fix`, **on by default**, remembered as `"fix": true` in `.globalize.json5`):
158
+ `npm audit fix` runs before the pre-release commit, so a changed lockfile is committed with
159
+ the release. It only ever installs semver-compatible updates.
154
160
 
155
- **Disable audit**:
156
161
  ```bash
157
- npmglobalize --no-fix
162
+ npmglobalize -no-fix # report only, no fix attempt
158
163
  ```
159
164
 
160
165
  ### 🧩 Install Scripts (npm `allowScripts`)
@@ -409,8 +414,9 @@ not what you meant to publish, so npmglobalize lists the conflicted files and st
409
414
  -no-publish-deps, -npd Skip auto-publishing file: dependencies
410
415
  -no-prescan, -nps Skip upfront dep-graph prescan
411
416
  -force-publish Republish dependencies even if version exists
412
- -fix Run npm audit fix after transformation
413
- -no-fix Don't run npm audit
417
+ -fix, -no-fix Run npm audit fix before the pre-release commit (default on,
418
+ never --force). The audit REPORT always runs; critical/high
419
+ findings are listed in the Issues Summary with a fix hint.
414
420
  -no-use-paths, -nup Declare package standalone; do not resolve file: deps
415
421
  from sibling checkouts (see Configuration File)
416
422
  ```
@@ -703,12 +709,15 @@ npmglobalize --dry-run # See what would happen
703
709
  11. **Pushes** to git (with push-protection detection and auto-bypass)
704
710
  12. **Installs** globally (if `--install`)
705
711
  13. **Restores** file: references (if `--files`, default)
706
- 14. **Runs audit** (shows security status)
712
+ 14. **Runs audit** `Audit:` row in the Release Summary; critical/high findings explained in the Issues Summary
707
713
 
708
714
  ## Operational Details
709
715
 
710
716
  ### Build Cascade
711
717
 
718
+ Before anything is built, every project in the `file:` tree with missing deps gets an `npm install`. If one fails, the run **stops** there — the compiler/linker/npm lines that name the failure are printed (the full output goes to a log under `%TEMP%
719
+ pmglobalize-logs`), the failure is repeated in the Issues Summary, and nothing is built, published, or installed on top of a broken tree. `-force` continues anyway. A stale-lockfile / junction failure offers a clean reinstall (delete `node_modules/` + `package-lock.json`, retry once); a compiler or linker error does not, since a reinstall would reach the same line.
720
+
712
721
  Before transforming or publishing anything, `npmglobalize` builds `file:` dependencies in topological order — deps before consumers — and then builds the target itself. This guarantees the target's `tsc` reads up-to-date `.d.ts` and `.js` from sibling checkouts even when a dep's source has changed since its last build.
713
722
 
714
723
  For each project visited (the target and every transitive `file:` dep):
package/cli.js CHANGED
@@ -60,7 +60,9 @@ Dependency Options:
60
60
  to date (freshness check compares .ts/tsconfig
61
61
  mtimes against emitted .js/.d.ts)
62
62
  -force-publish Republish dependencies even if version exists
63
- -fix Run npm audit fix after transformation
63
+ -fix, -no-fix Run npm audit fix before the pre-release commit (default on,
64
+ never --force). The audit REPORT always runs; critical/high
65
+ findings are listed in the Issues Summary with a fix hint.
64
66
  -no-use-paths, -nup Declare this package standalone — file: deps shouldn't
65
67
  be resolved from sibling checkouts (persisted as
66
68
  usePaths:false in .globalize.json5; FYI for now)
@@ -596,7 +598,17 @@ export async function main() {
596
598
  // Ensures consumers' tsc sees up-to-date `.d.ts` from sibling checkouts
597
599
  // whose source has changed since their last build.
598
600
  if (!cliOptions.cleanup) {
599
- await ensureFileDepModules(cwd, !!cliOptions.verbose);
601
+ // 2026-09-06 — Claude Code (Fable 5.1), at Bob's direction: a failed npm install used
602
+ // to be reported and then walked past — the run went on to "Build up to date" and a
603
+ // green "Installed locally" for a package with no node_modules. Stop here instead.
604
+ const modulesOk = await ensureFileDepModules(cwd, !!cliOptions.verbose);
605
+ if (!modulesOk && !cliOptions.force) {
606
+ console.error(colors.error('npm install failed — stopping before build/publish (-force continues anyway).'));
607
+ printBuildSummary();
608
+ process.exit(1);
609
+ }
610
+ if (!modulesOk)
611
+ console.log(colors.warn('Continuing with -force despite npm install failure...'));
600
612
  const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
601
613
  if (!depsOk && !cliOptions.force) {
602
614
  printBuildSummary();
package/lib.d.ts CHANGED
@@ -101,7 +101,8 @@ export interface GlobalizeOptions {
101
101
  publicDeps?: boolean;
102
102
  /** Force republish dependencies even if version exists on npm */
103
103
  forcePublish?: boolean;
104
- /** Run npm audit and fix vulnerabilities */
104
+ /** Run `npm audit fix` before the pre-release commit (default true; never `--force`). The audit
105
+ * REPORT always runs regardless — this only controls the fix attempt. */
105
106
  fix?: boolean;
106
107
  /** Automatically fix version/tag mismatches */
107
108
  fixTags?: boolean;
@@ -378,7 +379,10 @@ export declare function parseVersionTag(tag: string): number[] | null;
378
379
  export declare function compareVersions(a: number[], b: number[]): number;
379
380
  /** Fix version/tag mismatches */
380
381
  export declare function fixVersionTagMismatch(cwd: string, pkg: any, verbose?: boolean): boolean;
381
- export declare function printPnpmSuggestionSummary(): void;
382
+ /** Lines from a failed install that name the actual failure: linker / compiler / msbuild
383
+ * errors from a native addon build, and node-gyp's own verdict. npm's "npm error " prefix is
384
+ * stripped so a line is recognised whether npm relayed it or node-gyp printed it directly. */
385
+ export declare function distillBuildErrors(combined: string): string[];
382
386
  /** Return declared deps (dependencies + devDependencies) that don't resolve from
383
387
  * `pkgDir`. Skips `workspace:`/`link:` specs (handled by workspace tooling /
384
388
  * rarely used). `file:` deps are checked: npm installs them as junctions
@@ -392,7 +396,7 @@ export declare function missingDeps(pkgDir: string, pkg: any): string[];
392
396
  * "fresh clone, no `node_modules/`" and "dep added but `npm install` not
393
397
  * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
394
398
  * Cycle-safe via the shared `visited` set. */
395
- export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): Promise<void>;
399
+ export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): Promise<boolean>;
396
400
  /** Cheap freshness check so the build cascade can skip packages whose output is
397
401
  * already current. Returns true only when provably up to date:
398
402
  * - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
@@ -499,7 +503,7 @@ export declare function fixTs7Deprecations(cwd: string): {
499
503
  export declare function ensureWorkspaceDepModules(rootDir: string, members: Array<{
500
504
  dir: string;
501
505
  pkg: any;
502
- }>, verbose?: boolean): Promise<void>;
506
+ }>, verbose?: boolean): Promise<boolean>;
503
507
  /** Extract the lines a human needs from a failed npm command's output: the
504
508
  * `npm error` lines minus stack frames and brace-only object-dump fragments,
505
509
  * deduped. The full output belongs in a log file (saveNpmLog), not the terminal. */
@@ -622,13 +626,48 @@ export declare function promptChoice(message: string, choices: string[]): Promis
622
626
  export declare function adoptExistingRemote(cwd: string, repoUrl: string, defaultBranch: string, dryRun: boolean): Promise<boolean>;
623
627
  /** Initialize git repository */
624
628
  export declare function initGit(cwd: string, visibility: 'private' | 'public', dryRun: boolean, allowTs?: boolean): Promise<boolean>;
625
- /** Main globalize function */
626
- /** Run npm audit and optionally fix vulnerabilities */
627
- export declare function runNpmAudit(cwd: string, fix?: boolean, verbose?: boolean): Promise<{
628
- success: boolean;
629
- report: string;
630
- hasVulnerabilities: boolean;
631
- }>;
629
+ /** Vulnerability severity as reported by `npm audit --json` */
630
+ export type AuditSeverity = 'critical' | 'high' | 'moderate' | 'low' | 'info';
631
+ /** One vulnerable package from `npm audit --json` */
632
+ export interface AuditFinding {
633
+ /** Package name */
634
+ name: string;
635
+ severity: AuditSeverity;
636
+ /** Vulnerable version range, e.g. "<7.5.4" */
637
+ range: string;
638
+ /** Declared directly in this package.json (vs. pulled in transitively) */
639
+ isDirect: boolean;
640
+ /** Advisory titles + URLs. Empty when the package is vulnerable only because of a dep it uses. */
641
+ advisories: {
642
+ title: string;
643
+ url: string;
644
+ }[];
645
+ /** Vulnerable deps this package is affected THROUGH (npm's string `via` entries) */
646
+ via: string[];
647
+ /** Packages that are affected BECAUSE of this one (npm's `effects`) */
648
+ effects: string[];
649
+ /** 'plain' = `npm audit fix` clears it; 'major' = needs `--force` (breaking); 'none' = no fix published */
650
+ fix: 'plain' | 'major' | 'none';
651
+ /** The top-level package@version the fix would install (npm names it when the fix is an object) */
652
+ fixTarget: string;
653
+ }
654
+ /** Outcome of an `npm audit --json` run */
655
+ export interface AuditResult {
656
+ /** false when npm audit itself could not run (no lockfile, registry down...) — see error */
657
+ ran: boolean;
658
+ error: string;
659
+ counts: Record<AuditSeverity, number> & {
660
+ total: number;
661
+ };
662
+ findings: AuditFinding[];
663
+ }
664
+ /** Run `npm audit fix` (never `--force`: that installs semver-major bumps and can break the
665
+ * build; the summary tells the user when that is what it would take). Silent on purpose —
666
+ * the report that follows shows what is left. */
667
+ export declare function runNpmAuditFix(cwd: string): Promise<void>;
668
+ /** Run `npm audit --json` (read-only) and parse it into an AuditResult. Never throws; a run
669
+ * that could not happen comes back with ran=false and the reason in error. */
670
+ export declare function runNpmAudit(cwd: string): AuditResult;
632
671
  /** Get the version of npmglobalize itself */
633
672
  export declare function getToolVersion(): string;
634
673
  export declare function globalize(cwd: string, options?: GlobalizeOptions, configOptions?: Partial<GlobalizeOptions>): Promise<boolean>;
package/lib.js CHANGED
@@ -29,7 +29,6 @@ function spawnSafe(cmd, args, options = {}) {
29
29
  return spawnSync(cmd, args, opts);
30
30
  }
31
31
  import readline from 'readline';
32
- import { styleText } from 'util';
33
32
  import libversion from 'libnpmversion';
34
33
  import JSON5 from 'json5';
35
34
  import { fileURLToPath } from 'url';
@@ -332,7 +331,7 @@ const BOOL_COMMENTS = {
332
331
  force: { on: 'Continue despite git errors', off: 'Stop on git errors' },
333
332
  quiet: { on: 'Suppress npm warnings', off: 'Show npm warnings' },
334
333
  verbose: { on: 'Show detailed output', off: 'Normal output detail' },
335
- fix: { on: 'Auto-run npm audit fix', off: 'Do NOT run npm audit fix' },
334
+ fix: { on: 'Auto-run npm audit fix (default)', off: 'Do NOT run npm audit fix (audit report still runs)' },
336
335
  local: { on: 'Local install only (skip transform/publish)', off: 'Normal transform/publish flow' },
337
336
  noPublish: { on: 'Transform but don\'t publish', off: 'Publish normally' },
338
337
  freeze: { on: 'Freeze node_modules (replace symlinks with real copies)', off: 'Leave node_modules symlinks as-is' },
@@ -444,7 +443,7 @@ export function writeConfig(dir, config, explicitKeys) {
444
443
  ['"verbose": false', 'true = show detailed output'],
445
444
  ['"gitVisibility": "private"', 'Git repo: private or public'],
446
445
  ['"npmVisibility": "private"', 'npm package: private or public'],
447
- ['"fix": false', 'true = auto-run npm audit fix'],
446
+ ['"fix": true', 'true = auto-run npm audit fix (never --force); false = report only'],
448
447
  ['"local": false', 'true = local install only (skip transform/publish); clear with -global'],
449
448
  ['"noPublish": false', 'true = transform but don\'t publish'],
450
449
  ['"freeze": false', 'true = freeze node_modules (replace symlinks with real copies)'],
@@ -2069,73 +2068,67 @@ export function fixVersionTagMismatch(cwd, pkg, verbose = false) {
2069
2068
  function sleepSync(ms) {
2070
2069
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2071
2070
  }
2072
- /** User-requested pnpm-eval hook (2026-05-14). The first local `npm install`
2073
- * failure in a process prints a yellow-on-red banner and pauses 60s so the
2074
- * user can read it. Every failure (first and subsequent) is captured in
2075
- * `_pnpmFailureContexts` so `printPnpmSuggestionSummary()` can re-emit the
2076
- * banner listing all failure sites at end-of-run, in case the live
2077
- * banner scrolled past. Remove once pnpm-vs-npm decision is made. */
2078
- let _pnpmSuggestionShown = false;
2079
- const _pnpmFailureContexts = [];
2080
- function _pnpmBanner(contexts) {
2081
- const width = 76;
2082
- const wrap = (s) => styleText(['yellow', 'bgRed', 'bold'], ' ' + s.padEnd(width - 2) + ' ');
2083
- const body = [
2084
- '',
2085
- contexts.length > 1
2086
- ? `npm install FAILED -- ${contexts.length} sites -- consider trying pnpm`
2087
- : 'npm install FAILED -- consider trying pnpm',
2088
- '',
2089
- ];
2090
- for (const c of contexts) {
2091
- const label = ` - ${c}`;
2092
- body.push(label.length > width - 2 ? label.slice(0, width - 5) + '...' : label);
2093
- }
2094
- body.push('', 'pnpm uses a content-addressed store with strict node_modules and', 'much faster installs. Commands mirror npm; great workspace support.', '', 'Try: npm i -g pnpm && pnpm import && pnpm install', '');
2095
- return body.map(wrap);
2096
- }
2097
- async function suggestPnpmOnInstallFailure(context) {
2098
- _pnpmFailureContexts.push(context);
2099
- if (_pnpmSuggestionShown)
2100
- return;
2101
- _pnpmSuggestionShown = true;
2102
- const lines = _pnpmBanner([context]);
2103
- console.error('');
2104
- for (const l of lines)
2105
- console.error(l);
2106
- console.error(styleText(['yellow', 'bgRed', 'bold'], ' ' + 'Pausing 60s so you can read this. Ctrl+C to abort.'.padEnd(74) + ' '));
2107
- console.error('');
2108
- await new Promise(resolve => setTimeout(resolve, 60_000));
2109
- }
2110
- /** Re-emit the pnpm banner at end-of-run if any `npm install` failed.
2111
- * No-op when nothing failed. Safe to call from multiple summary paths —
2112
- * guarded so the summary banner prints at most once per process. */
2113
- let _pnpmSummaryPrinted = false;
2114
- export function printPnpmSuggestionSummary() {
2115
- if (_pnpmSummaryPrinted)
2116
- return;
2117
- if (_pnpmFailureContexts.length === 0)
2118
- return;
2119
- _pnpmSummaryPrinted = true;
2120
- console.error('');
2121
- for (const l of _pnpmBanner(_pnpmFailureContexts))
2122
- console.error(l);
2123
- console.error('');
2124
- }
2125
- /** Run `npm install` in `dir`; on failure, offer to delete `node_modules/`
2126
- * and `package-lock.json` and retry once — the standard fix for arborist's
2127
- * null-`package` crash on stale lockfile / junction state — before falling
2128
- * back to the pnpm suggestion banner. Returns true if install succeeded.
2129
- * `rmSync` unlinks junctions without descending, so file: sibling targets
2130
- * are never touched. */
2071
+ // 2026-09-06 Claude Code (Fable 5.1), at Bob's direction: the pnpm-eval banner that used
2072
+ // to live here ("npm install FAILED -- consider trying pnpm", plus a 60-second pause) is
2073
+ // gone. pnpm was evaluated and judged not feasible, so the suggestion was noise that
2074
+ // delayed the run and hid the real error. A failed install is now reported by
2075
+ // reportInstallFailure (the actual compiler/linker/npm lines, plus a log file) and stops
2076
+ // the run see ensureFileDepModules and its callers.
2077
+ /** Lines from a failed install that name the actual failure: linker / compiler / msbuild
2078
+ * errors from a native addon build, and node-gyp's own verdict. npm's "npm error " prefix is
2079
+ * stripped so a line is recognised whether npm relayed it or node-gyp printed it directly. */
2080
+ export function distillBuildErrors(combined) {
2081
+ const out = [];
2082
+ const seen = new Set();
2083
+ for (const raw of combined.split('\n')) {
2084
+ const line = raw.replace(/^npm (?:error|ERR!)\s?/, '').trimEnd();
2085
+ if (!/fatal error|\berror (?:LNK|C|MSB|TS)\d+|gyp ERR! (?:build|configure|rebuild) error/.test(line))
2086
+ continue;
2087
+ if (seen.has(line))
2088
+ continue;
2089
+ seen.add(line);
2090
+ out.push(line);
2091
+ }
2092
+ return out.slice(0, 12);
2093
+ }
2094
+ /** Print why an `npm install` failed and keep the whole output in a log file. Native-build
2095
+ * errors (LNK1117 and friends) come first — they are the cause; npm's own "command failed"
2096
+ * lines only say that something did. Was: the first 5 lines of stderr, which for a node-gyp
2097
+ * failure are "npm error code 1 / path / command failed / command ..." and never the error. */
2098
+ function reportInstallFailure(dir, r, attempt) {
2099
+ const combined = [r.output, r.stderr].filter(Boolean).join('\n');
2100
+ const logFile = saveNpmLog(`install-${path.basename(dir)}`, combined);
2101
+ const buildErrors = distillBuildErrors(combined);
2102
+ const npmErrors = distillNpmErrors(combined).filter(l => !buildErrors.includes(l));
2103
+ console.error(colors.red(` ✗ npm install failed in ${dir}${attempt}`));
2104
+ for (const l of buildErrors)
2105
+ console.error(colors.red(` ${l}`));
2106
+ for (const l of npmErrors.slice(0, buildErrors.length > 0 ? 4 : 8))
2107
+ console.error(colors.dim(` ${l}`));
2108
+ if (logFile)
2109
+ console.error(colors.dim(` full output: ${logFile}`));
2110
+ const headline = buildErrors.find(l => /fatal error|\berror /.test(l))
2111
+ || buildErrors[0]
2112
+ || npmErrors.find(l => !/^(code \d+|path |command )/.test(l))
2113
+ || 'see log';
2114
+ return { isBuildError: buildErrors.length > 0, headline, logFile };
2115
+ }
2116
+ /** Run `npm install` in `dir`. On failure, offer to delete `node_modules/` and
2117
+ * `package-lock.json` and retry once — the standard fix for arborist's null-`package`
2118
+ * crash on stale lockfile / junction state. Not offered when the output holds a
2119
+ * compiler / linker error: that is a broken native build, not a broken tree, and the
2120
+ * reinstall would spend a minute reaching the same line. Returns true if install
2121
+ * succeeded; a failure is recorded for the Issues Summary. `rmSync` unlinks junctions
2122
+ * without descending, so file: sibling targets are never touched. */
2131
2123
  async function npmInstallWithCleanRetry(dir, verbose, context) {
2132
2124
  let r = await runCommandAsync('npm', ['install'], { cwd: dir, silent: !verbose });
2133
2125
  if (r.success)
2134
2126
  return true;
2135
- console.error(colors.red(` ✗ npm install failed in ${dir}`));
2136
- if (r.stderr)
2137
- console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
2138
- if (await confirm(` Delete node_modules/ and package-lock.json in ${dir} and retry npm install?`, true)) {
2127
+ let failure = reportInstallFailure(dir, r, '');
2128
+ if (failure.isBuildError) {
2129
+ console.error(colors.dim(' (compiler/linker error — a clean reinstall would hit the same line, so not offering one)'));
2130
+ }
2131
+ else if (await confirm(` Delete node_modules/ and package-lock.json in ${dir} and retry npm install?`, true)) {
2139
2132
  try {
2140
2133
  fs.rmSync(path.join(dir, 'node_modules'), { recursive: true, force: true });
2141
2134
  }
@@ -2150,11 +2143,9 @@ async function npmInstallWithCleanRetry(dir, verbose, context) {
2150
2143
  console.log(colors.green(` ✓ clean reinstall succeeded in ${dir}`));
2151
2144
  return true;
2152
2145
  }
2153
- console.error(colors.red(` ✗ npm install still failing in ${dir} after clean reinstall`));
2154
- if (r.stderr)
2155
- console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
2146
+ failure = reportInstallFailure(dir, r, ' after clean reinstall');
2156
2147
  }
2157
- await suggestPnpmOnInstallFailure(context);
2148
+ recordBuildIssue(path.basename(dir), 'error', `npm install failed (${context}): ${failure.headline}${failure.logFile ? ` — log: ${failure.logFile}` : ''}`);
2158
2149
  return false;
2159
2150
  }
2160
2151
  /** Wait for a package version to appear on the npm registry.
@@ -2308,17 +2299,22 @@ function formatMissingReason(missing) {
2308
2299
  * "fresh clone, no `node_modules/`" and "dep added but `npm install` not
2309
2300
  * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
2310
2301
  * Cycle-safe via the shared `visited` set. */
2302
+ // 2026-09-06 — Claude Code (Fable 5.1), at Bob's direction ("if the install failed why
2303
+ // continue"): returns false when any npm install in the tree failed. Every dep is still
2304
+ // attempted so one run reports all failures; the CALLER stops before building/publishing
2305
+ // on false (unless -force).
2311
2306
  export async function ensureFileDepModules(cwd, verbose = false, visited = new Set()) {
2312
2307
  const abs = path.resolve(cwd);
2313
2308
  if (visited.has(abs))
2314
- return;
2309
+ return true;
2315
2310
  visited.add(abs);
2311
+ let ok = true;
2316
2312
  let pkg;
2317
2313
  try {
2318
2314
  pkg = readPackageJson(cwd);
2319
2315
  }
2320
2316
  catch {
2321
- return;
2317
+ return true; // no package.json here: nothing to install, nothing failed
2322
2318
  }
2323
2319
  const cwdMissing = missingDeps(abs, pkg);
2324
2320
  if (cwdMissing.length > 0) {
@@ -2335,7 +2331,8 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
2335
2331
  catch { /* best-effort */ }
2336
2332
  }
2337
2333
  console.log(colors.yellow(`↻ installing node_modules in ${pkg?.name || abs} (${formatMissingReason(cwdMissing)})`));
2338
- await npmInstallWithCleanRetry(abs, verbose, `ensureFileDepModules (cwd): ${abs}`);
2334
+ if (!await npmInstallWithCleanRetry(abs, verbose, `ensureFileDepModules (cwd): ${abs}`))
2335
+ ok = false;
2339
2336
  }
2340
2337
  for (const key of ['dependencies', 'devDependencies']) {
2341
2338
  const deps = pkg?.[key];
@@ -2376,11 +2373,14 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
2376
2373
  catch { /* best-effort */ }
2377
2374
  }
2378
2375
  console.log(colors.yellow(`↻ restoring node_modules in ${name} (${target}) (${formatMissingReason(targetMissing)})`));
2379
- await npmInstallWithCleanRetry(target, verbose, `ensureFileDepModules (file: dep ${name}): ${target}`);
2376
+ if (!await npmInstallWithCleanRetry(target, verbose, `ensureFileDepModules (file: dep ${name}): ${target}`))
2377
+ ok = false;
2380
2378
  }
2381
- await ensureFileDepModules(target, verbose, visited);
2379
+ if (!await ensureFileDepModules(target, verbose, visited))
2380
+ ok = false;
2382
2381
  }
2383
2382
  }
2383
+ return ok;
2384
2384
  }
2385
2385
  /** Cached major version of the global `tsc`. null = not yet probed,
2386
2386
  * 0 = probe failed / tsc not found. TypeScript 6 stopped auto-including
@@ -3770,7 +3770,7 @@ export async function ensureWorkspaceDepModules(rootDir, members, verbose = fals
3770
3770
  rootPkg = readPackageJson(root);
3771
3771
  }
3772
3772
  catch {
3773
- return;
3773
+ return true;
3774
3774
  }
3775
3775
  const allMissing = new Set();
3776
3776
  for (const name of missingDeps(root, rootPkg))
@@ -3780,10 +3780,10 @@ export async function ensureWorkspaceDepModules(rootDir, members, verbose = fals
3780
3780
  allMissing.add(name);
3781
3781
  }
3782
3782
  if (allMissing.size === 0)
3783
- return;
3783
+ return true;
3784
3784
  const list = [...allMissing];
3785
3785
  console.log(colors.yellow(`↻ installing workspace node_modules in ${rootPkg?.name || path.basename(root)} (${formatMissingReason(list)})`));
3786
- await npmInstallWithCleanRetry(root, verbose, `ensureWorkspaceDepModules: ${root}`);
3786
+ return npmInstallWithCleanRetry(root, verbose, `ensureWorkspaceDepModules: ${root}`);
3787
3787
  }
3788
3788
  /** Extract the lines a human needs from a failed npm command's output: the
3789
3789
  * `npm error` lines minus stack frames and brace-only object-dump fragments,
@@ -6019,54 +6019,140 @@ export async function initGit(cwd, visibility, dryRun, allowTs) {
6019
6019
  }
6020
6020
  return true;
6021
6021
  }
6022
- /** Main globalize function */
6023
- /** Run npm audit and optionally fix vulnerabilities */
6024
- export async function runNpmAudit(cwd, fix = false, verbose = false) {
6025
- if (fix) {
6026
- await runCommandAsync('npm', ['audit', 'fix'], { cwd, silent: true });
6027
- }
6028
- // Check remaining vulnerabilities
6029
- const auditResult = spawnSafe('npm', ['audit', '--json'], {
6030
- cwd, encoding: 'utf-8', stdio: 'pipe', shell: true
6031
- });
6032
- let hasVulnerabilities = false;
6033
- let report = '';
6022
+ /** Run `npm audit fix` (never `--force`: that installs semver-major bumps and can break the
6023
+ * build; the summary tells the user when that is what it would take). Silent on purpose —
6024
+ * the report that follows shows what is left. */
6025
+ export async function runNpmAuditFix(cwd) {
6026
+ await runCommandAsync('npm', ['audit', 'fix'], { cwd, silent: true });
6027
+ }
6028
+ /** Classify npm's `fixAvailable` — true | false | { name, version, isSemVerMajor } */
6029
+ function classifyFix(fixAvailable) {
6030
+ if (fixAvailable === true)
6031
+ return { fix: 'plain', fixTarget: '' };
6032
+ if (fixAvailable && typeof fixAvailable === 'object') {
6033
+ const f = fixAvailable;
6034
+ const fixTarget = f.name ? `${f.name}@${f.version ?? '?'}` : '';
6035
+ return { fix: f.isSemVerMajor ? 'major' : 'plain', fixTarget };
6036
+ }
6037
+ return { fix: 'none', fixTarget: '' };
6038
+ }
6039
+ /** Run `npm audit --json` (read-only) and parse it into an AuditResult. Never throws; a run
6040
+ * that could not happen comes back with ran=false and the reason in error. */
6041
+ export function runNpmAudit(cwd) {
6042
+ const counts = { critical: 0, high: 0, moderate: 0, low: 0, info: 0, total: 0 };
6043
+ const result = { ran: false, error: '', counts, findings: [] };
6044
+ const proc = spawnSafe('npm', ['audit', '--json'], { cwd, encoding: 'utf-8', stdio: 'pipe', shell: true });
6045
+ // The JSON shape is npm's, not ours — narrowed field by field below, hence the any.
6046
+ let data;
6034
6047
  try {
6035
- const auditData = JSON.parse(auditResult.stdout || '{}');
6036
- const m = auditData.metadata?.vulnerabilities || {};
6037
- const critical = m.critical || 0;
6038
- const high = m.high || 0;
6039
- const moderate = m.moderate || 0;
6040
- const low = m.low || 0;
6041
- const total = critical + high + moderate + low + (m.info || 0);
6042
- if (total > 0) {
6043
- hasVulnerabilities = true;
6044
- const parts = [];
6045
- if (critical > 0)
6046
- parts.push(colors.red(`${critical} critical`));
6047
- if (high > 0)
6048
- parts.push(colors.red(`${high} high`));
6049
- if (moderate > 0)
6050
- parts.push(colors.yellow(`${moderate} moderate`));
6051
- if (low > 0)
6052
- parts.push(`${low} low`);
6053
- report = `${total} vulnerabilities`;
6054
- // Only show if high/critical remain after fix
6055
- if (critical > 0 || high > 0) {
6056
- console.log(colors.red(` Audit: ${parts.join(', ')}`));
6057
- }
6058
- else if (verbose) {
6059
- console.log(colors.dim(` Audit: ${parts.join(', ')}`));
6060
- }
6061
- }
6062
- else {
6063
- report = 'No vulnerabilities';
6064
- }
6048
+ data = JSON.parse(proc.stdout || '');
6065
6049
  }
6066
- catch {
6067
- report = 'Audit check failed';
6050
+ catch (error) {
6051
+ // Not a swallow: the failure becomes result.error, which the summary prints as "Audit: not run".
6052
+ const stderr = (proc.stderr || '').trim().split('\n')[0];
6053
+ result.error = stderr || `npm audit produced no JSON (${error.message})`;
6054
+ return result;
6068
6055
  }
6069
- return { success: true, report, hasVulnerabilities };
6056
+ if (data?.error) {
6057
+ // npm reports its own failures (ENOLOCK, network...) as JSON on stdout
6058
+ result.error = [data.error.code, data.error.summary].filter(Boolean).join(': ');
6059
+ return result;
6060
+ }
6061
+ result.ran = true;
6062
+ const m = data.metadata?.vulnerabilities || {};
6063
+ for (const sev of ['critical', 'high', 'moderate', 'low', 'info']) {
6064
+ counts[sev] = m[sev] || 0;
6065
+ counts.total += counts[sev];
6066
+ }
6067
+ const vulns = data.vulnerabilities || {};
6068
+ for (const name of Object.keys(vulns)) {
6069
+ const v = vulns[name];
6070
+ const viaList = Array.isArray(v.via) ? v.via : [];
6071
+ const advisories = viaList
6072
+ .filter((x) => !!x && typeof x === 'object')
6073
+ .map(x => ({ title: x.title || '', url: x.url || '' }));
6074
+ const via = viaList.filter((x) => typeof x === 'string');
6075
+ result.findings.push({
6076
+ name,
6077
+ severity: v.severity,
6078
+ range: v.range || '',
6079
+ isDirect: !!v.isDirect,
6080
+ advisories,
6081
+ via,
6082
+ effects: Array.isArray(v.effects) ? v.effects : [],
6083
+ ...classifyFix(v.fixAvailable)
6084
+ });
6085
+ }
6086
+ const order = ['critical', 'high', 'moderate', 'low', 'info'];
6087
+ result.findings.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity) || a.name.localeCompare(b.name));
6088
+ return result;
6089
+ }
6090
+ /** One-line explanation of a finding for the Issues Summary: what, why it is here, how to clear it. */
6091
+ function describeAuditFinding(f) {
6092
+ const parts = [`audit ${f.severity.toUpperCase()}: ${f.name} ${f.range}`.trim()];
6093
+ if (f.advisories.length > 0) {
6094
+ // One advisory named in full; the rest counted. tar alone carries 12 and a line per
6095
+ // title would bury the Issues Summary. The URL leads to the complete list.
6096
+ const a = f.advisories[0];
6097
+ const more = f.advisories.length - 1;
6098
+ parts.push(`${a.title}${a.url ? ` (${a.url})` : ''}${more > 0 ? ` +${more} more` : ''}`);
6099
+ }
6100
+ if (f.via.length > 0)
6101
+ parts.push(`through ${f.via.join(', ')}`);
6102
+ if (f.isDirect)
6103
+ parts.push('direct dependency');
6104
+ else if (f.effects.length > 0)
6105
+ parts.push(`pulled in by ${f.effects.join(', ')}`);
6106
+ switch (f.fix) {
6107
+ case 'plain':
6108
+ parts.push('fix: npm audit fix');
6109
+ break;
6110
+ case 'major':
6111
+ parts.push(`fix: npm audit fix --force → ${f.fixTarget || 'a semver-major bump'} (breaking — not run automatically)`);
6112
+ break;
6113
+ case 'none':
6114
+ parts.push('fix: none published yet');
6115
+ break;
6116
+ }
6117
+ return parts.join(' — ');
6118
+ }
6119
+ /** Run the audit report and put critical/high findings into the Issues Summary. Call once,
6120
+ * right before the end-of-run summary. */
6121
+ function auditForSummary(cwd, moduleName) {
6122
+ const audit = runNpmAudit(cwd);
6123
+ for (const f of audit.findings) {
6124
+ if (f.severity === 'critical' || f.severity === 'high') {
6125
+ recordBuildIssue(moduleName, 'warning', describeAuditFinding(f));
6126
+ }
6127
+ }
6128
+ return audit;
6129
+ }
6130
+ /** The "Audit:" row for the Release / Transform Summary. */
6131
+ function auditSummaryRow(audit, dryRun) {
6132
+ if (dryRun)
6133
+ return ` Audit: ${colors.dim('skipped (dry-run)')}`;
6134
+ if (!audit)
6135
+ return ` Audit: ${colors.dim('not run')}`;
6136
+ if (!audit.ran)
6137
+ return ` Audit: ${colors.yellow('not run')} ${colors.dim(audit.error)}`;
6138
+ if (audit.counts.total === 0)
6139
+ return ` Audit: ${colors.green('✓')} no vulnerabilities`;
6140
+ const c = audit.counts;
6141
+ const parts = [];
6142
+ if (c.critical > 0)
6143
+ parts.push(colors.red(`${c.critical} critical`));
6144
+ if (c.high > 0)
6145
+ parts.push(colors.red(`${c.high} high`));
6146
+ if (c.moderate > 0)
6147
+ parts.push(colors.yellow(`${c.moderate} moderate`));
6148
+ if (c.low > 0)
6149
+ parts.push(`${c.low} low`);
6150
+ if (c.info > 0)
6151
+ parts.push(`${c.info} info`);
6152
+ const serious = c.critical > 0 || c.high > 0;
6153
+ const mark = serious ? colors.red('✗') : colors.yellow('!');
6154
+ const hint = serious ? colors.dim(' (details in Issues Summary)') : '';
6155
+ return ` Audit: ${mark} ${parts.join(', ')}${hint}`;
6070
6156
  }
6071
6157
  /** Get the version of npmglobalize itself */
6072
6158
  export function getToolVersion() {
@@ -6687,8 +6773,15 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6687
6773
  // before doing anything else. Independent of build script presence — a dep
6688
6774
  // with new declared deps still needs its own `npm install` even if it has
6689
6775
  // no build step. CLI entrypoint already ran this, so skip when _fromCli.
6690
- if (!options._fromCli && !dryRun)
6691
- await ensureFileDepModules(cwd, verbose);
6776
+ if (!options._fromCli && !dryRun) {
6777
+ const modulesOk = await ensureFileDepModules(cwd, verbose);
6778
+ if (!modulesOk && !force) {
6779
+ console.error(colors.red(`npm install failed in ${pkg.name || cwd} — stopping before build/publish (-force continues anyway).`));
6780
+ return false;
6781
+ }
6782
+ if (!modulesOk)
6783
+ console.log(colors.yellow('Continuing with -force despite npm install failure...'));
6784
+ }
6692
6785
  // Run build step if package.json has a build script (skip if CLI already built)
6693
6786
  if (pkg.scripts?.build && !options._fromCli) {
6694
6787
  console.log(`${timestamp()} Running build...`);
@@ -7326,19 +7419,17 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7326
7419
  }
7327
7420
  }
7328
7421
  }
7329
- // Run npm audit if requested or if dependencies were transformed
7330
- if ((fix || updateDeps) && (transformResult.transformed || alreadyTransformed || updateDeps)) {
7422
+ // 2026-09-06 Claude Code (Fable 5.1): fix step only here (before the pre-release commit,
7423
+ // so a changed lockfile is committed). The report moved next to the summaries so its
7424
+ // result is shown where the user is looking, not scrolled off under the git output.
7425
+ if (fix) {
7331
7426
  if (!dryRun) {
7332
- await runNpmAudit(cwd, fix, verbose);
7427
+ await runNpmAuditFix(cwd);
7333
7428
  }
7334
7429
  else {
7335
- console.log(' [dry-run] Would run npm audit');
7430
+ console.log(' [dry-run] Would run npm audit fix');
7336
7431
  }
7337
7432
  }
7338
- else if (fix && !dryRun) {
7339
- // Run fix even if no deps changed
7340
- await runNpmAudit(cwd, fix, verbose);
7341
- }
7342
7433
  if (noPublish) {
7343
7434
  console.log('Transform complete (--nopublish mode).');
7344
7435
  if (freeze && !dryRun) {
@@ -7390,11 +7481,11 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7390
7481
  console.log(` Package: ${colors.green(finalPkg2.name)}`);
7391
7482
  console.log(` Version: ${colors.green('v' + finalPkg2.version)}`);
7392
7483
  console.log(` Published: ${colors.red('✗')}`);
7484
+ console.log(auditSummaryRow(dryRun ? null : auditForSummary(cwd, finalPkg2.name), dryRun));
7393
7485
  for (const s of surprises)
7394
7486
  console.log(` ${colors.yellow('!')} ${s}`);
7395
7487
  console.log(colors.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
7396
7488
  console.log('');
7397
- printPnpmSuggestionSummary();
7398
7489
  return true;
7399
7490
  }
7400
7491
  // Skip if private
@@ -8472,11 +8563,6 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8472
8563
  // publish so the version recorded is the one that actually shipped.
8473
8564
  await recordUpstream(cwd, dryRun, verbose);
8474
8565
  console.log(`${timestamp()} Done!`);
8475
- // Run final audit report if not already run
8476
- const auditAlreadyRun = (fix || updateDeps) && (transformResult.transformed || alreadyTransformed || updateDeps);
8477
- if (!auditAlreadyRun && (fix || updateDeps || transformResult.transformed) && !dryRun) {
8478
- await runNpmAudit(cwd, false, verbose); // Just report, don't fix again
8479
- }
8480
8566
  // Print summary
8481
8567
  console.log('');
8482
8568
  console.log(colors.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
@@ -8489,6 +8575,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8489
8575
  console.log(` Version: ${colors.green('v' + finalPkg.version)}`);
8490
8576
  console.log(` Published: ${colors.green('✓')} (${accessLabel})`);
8491
8577
  console.log(` Git pushed: ${colors.green('✓')}`);
8578
+ console.log(auditSummaryRow(dryRun ? null : auditForSummary(cwd, finalPkg.name), dryRun));
8492
8579
  if (explicitKeys?.has('gitVisibility')) {
8493
8580
  console.log(` Git visibility: ${colors.green(gitVisibility.toUpperCase())}`);
8494
8581
  }
@@ -8537,7 +8624,6 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8537
8624
  }
8538
8625
  console.log(colors.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
8539
8626
  console.log('');
8540
- printPnpmSuggestionSummary();
8541
8627
  // Only show "To use run" message if package provides commands (has bin field)
8542
8628
  if (finalPkg.bin) {
8543
8629
  const commandName = finalPkg.name.includes('/') ? finalPkg.name.split('/')[1] : finalPkg.name;
@@ -8750,7 +8836,13 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
8750
8836
  // the case where a dep was added to a member package.json but `npm install`
8751
8837
  // wasn't re-run — the builds would otherwise fail resolving the new dep.
8752
8838
  if (!options.dryRun) {
8753
- await ensureWorkspaceDepModules(rootDir, packages.map(p => ({ dir: p.dir, pkg: p.pkg })), !!options.verbose);
8839
+ const modulesOk = await ensureWorkspaceDepModules(rootDir, packages.map(p => ({ dir: p.dir, pkg: p.pkg })), !!options.verbose);
8840
+ if (!modulesOk && !options.force) {
8841
+ console.error(colors.red('npm install failed — stopping before build/publish (-force continues anyway).'));
8842
+ return { success: false, packages: [], publishOrder: [] };
8843
+ }
8844
+ if (!modulesOk)
8845
+ console.log(colors.yellow('Continuing with -force despite npm install failure...'));
8754
8846
  }
8755
8847
  // Prescan: decide which packages actually need processing so we don't waste
8756
8848
  // time rebuilding+republishing ones with no relevant changes.
@@ -8891,7 +8983,6 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
8891
8983
  }
8892
8984
  console.log(colors.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
8893
8985
  console.log('');
8894
- printPnpmSuggestionSummary();
8895
8986
  // Global install of the workspace root (monorepo CLI)
8896
8987
  const { install = false, link = false, wsl = false, dryRun = false, verbose = false } = options;
8897
8988
  const rootPkgFinal = readPackageJson(rootDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.216",
3
+ "version": "1.0.217",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",