@bobfrankston/npmglobalize 1.0.192 → 1.0.193

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib.d.ts +32 -0
  2. package/lib.js +235 -10
  3. package/package.json +1 -1
package/lib.d.ts CHANGED
@@ -356,6 +356,38 @@ export declare function ensureWorkspaceDepModules(rootDir: string, members: Arra
356
356
  dir: string;
357
357
  pkg: any;
358
358
  }>, verbose?: boolean): Promise<void>;
359
+ /** Extract the lines a human needs from a failed npm command's output: the
360
+ * `npm error` lines minus stack frames and brace-only object-dump fragments,
361
+ * deduped. The full output belongs in a log file (saveNpmLog), not the terminal. */
362
+ export declare function distillNpmErrors(combined: string): string[];
363
+ /** Scan a puppeteer browser cache (layout <root>/<browser>/<platform-version>)
364
+ * and repair every version folder that has no browser executable in it — the
365
+ * residue of a truncated extraction (see extractBrowserArchive for the Node
366
+ * 26 extract-zip bug that produces these). Per corrupt folder:
367
+ * 1. If the downloaded archive still sits next to it (the usual case right
368
+ * after a truncating run — the hung extraction means puppeteer's cleanup
369
+ * never deleted it), finish the extraction with the system unzipper. This
370
+ * is the only path that converges on Node 26, where re-extraction by
371
+ * puppeteer truncates again every time.
372
+ * 2. Otherwise delete the folder so the next attempt re-downloads.
373
+ *
374
+ * Timing matters: a truncating install EXITS 0, so the poisoned cache hides
375
+ * behind a successful install — and the NEXT install deletes the leftover
376
+ * archive before erroring. So this must run right after a SUCCESSFUL install
377
+ * (while the archive is still there), not only on failure. */
378
+ export declare function verifyPuppeteerCache(cacheRoot?: string): {
379
+ repaired: string[];
380
+ removed: string[];
381
+ };
382
+ /** Detect puppeteer's corrupted-browser-cache failure ("browser folder exists
383
+ * but the executable is missing") in failed install output and repair the
384
+ * whole cache it points into (verifyPuppeteerCache — the installer checks
385
+ * browsers in parallel and only reports the first corrupt one, so siblings
386
+ * are usually poisoned too). Returns the folders repaired or removed. */
387
+ export declare function fixPuppeteerCorruptedCache(combined: string): {
388
+ repaired: string[];
389
+ removed: string[];
390
+ };
359
391
  /** Run a command and return success status */
360
392
  export declare function runCommand(cmd: string, args: string[], options?: {
361
393
  silent?: boolean;
package/lib.js CHANGED
@@ -2510,19 +2510,244 @@ export async function ensureWorkspaceDepModules(rootDir, members, verbose = fals
2510
2510
  await suggestPnpmOnInstallFailure(`ensureWorkspaceDepModules: ${root}`);
2511
2511
  }
2512
2512
  }
2513
+ /** Extract the lines a human needs from a failed npm command's output: the
2514
+ * `npm error` lines minus stack frames and brace-only object-dump fragments,
2515
+ * deduped. The full output belongs in a log file (saveNpmLog), not the terminal. */
2516
+ export function distillNpmErrors(combined) {
2517
+ const key = [];
2518
+ const seen = new Set();
2519
+ for (const raw of combined.split('\n')) {
2520
+ const m = raw.trimEnd().match(/^npm (?:error|ERR!)\s?(.*)$/);
2521
+ if (!m || !m[1])
2522
+ continue;
2523
+ const msg = m[1];
2524
+ if (/^\s*at /.test(msg))
2525
+ continue; // stack frames
2526
+ if (/^[\s{}\[\]),]*$/.test(msg))
2527
+ continue; // object-dump punctuation lines
2528
+ if (seen.has(msg))
2529
+ continue;
2530
+ seen.add(msg);
2531
+ key.push(msg);
2532
+ }
2533
+ return key.slice(0, 12);
2534
+ }
2535
+ /** Write the full output of a failed npm command to a log file under the temp
2536
+ * dir and return its path (null if the write itself fails). */
2537
+ function saveNpmLog(label, content) {
2538
+ try {
2539
+ const dir = path.join(os.tmpdir(), 'npmglobalize-logs');
2540
+ fs.mkdirSync(dir, { recursive: true });
2541
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2542
+ const file = path.join(dir, `${label.replace(/[^a-z0-9.@-]+/gi, '_')}-${stamp}.log`);
2543
+ fs.writeFileSync(file, content);
2544
+ return file;
2545
+ }
2546
+ catch {
2547
+ return null;
2548
+ }
2549
+ }
2550
+ /** True if `dir` contains a browser executable somewhere below it. A healthy
2551
+ * puppeteer browser folder always has one; a truncated extraction doesn't
2552
+ * (extraction writes the small metadata files first — manifest, ABOUT,
2553
+ * licenses — so an interrupted one holds only those). */
2554
+ function containsBrowserExecutable(dir) {
2555
+ try {
2556
+ for (const entry of fs.readdirSync(dir, { recursive: true })) {
2557
+ const name = String(entry);
2558
+ if (process.platform === 'win32' ? /\.exe$/i.test(name) : /(^|[\\/])(chrome|chrome-headless-shell|firefox)$/.test(name))
2559
+ return true;
2560
+ }
2561
+ }
2562
+ catch {
2563
+ // unreadable — treat as not-a-healthy-browser; caller only acts on puppeteer cache paths
2564
+ }
2565
+ return false;
2566
+ }
2567
+ /** Extract a puppeteer browser archive with the SYSTEM unzipper into a fresh
2568
+ * version folder. Workaround for a third-party bug: puppeteer's installer
2569
+ * unpacks zips with the `extract-zip` package, which on Node 26 silently
2570
+ * truncates — its promise never resolves, the event loop drains, and the
2571
+ * process exits 0 after writing only the first few small files. The download
2572
+ * itself is fine (and the archive is left behind, since the hung promise
2573
+ * means puppeteer's cleanup never runs), so completing the extraction
2574
+ * ourselves both repairs the folder and converges where delete-and-retry
2575
+ * cannot. TODO: drop this when puppeteer/extract-zip fix Node 26 extraction. */
2576
+ function extractBrowserArchive(archivePath, folder) {
2577
+ // Git-Bash puts GNU tar (no zip support) first on PATH — use bsdtar
2578
+ // explicitly on Windows; macOS tar is bsdtar; Linux needs unzip.
2579
+ const [cmd, args] = process.platform === 'win32'
2580
+ ? [path.join(process.env.SystemRoot || 'C:\\WINDOWS', 'system32', 'tar.exe'), ['-xf', archivePath, '-C', folder]]
2581
+ : process.platform === 'darwin'
2582
+ ? ['tar', ['-xf', archivePath, '-C', folder]]
2583
+ : ['unzip', ['-oq', archivePath, '-d', folder]];
2584
+ try {
2585
+ fs.rmSync(folder, { recursive: true, force: true });
2586
+ fs.mkdirSync(folder, { recursive: true });
2587
+ const r = spawnSafe(cmd, args, { stdio: 'pipe', encoding: 'utf-8' });
2588
+ return r.status === 0 && containsBrowserExecutable(folder);
2589
+ }
2590
+ catch {
2591
+ return false;
2592
+ }
2593
+ }
2594
+ /** Scan a puppeteer browser cache (layout <root>/<browser>/<platform-version>)
2595
+ * and repair every version folder that has no browser executable in it — the
2596
+ * residue of a truncated extraction (see extractBrowserArchive for the Node
2597
+ * 26 extract-zip bug that produces these). Per corrupt folder:
2598
+ * 1. If the downloaded archive still sits next to it (the usual case right
2599
+ * after a truncating run — the hung extraction means puppeteer's cleanup
2600
+ * never deleted it), finish the extraction with the system unzipper. This
2601
+ * is the only path that converges on Node 26, where re-extraction by
2602
+ * puppeteer truncates again every time.
2603
+ * 2. Otherwise delete the folder so the next attempt re-downloads.
2604
+ *
2605
+ * Timing matters: a truncating install EXITS 0, so the poisoned cache hides
2606
+ * behind a successful install — and the NEXT install deletes the leftover
2607
+ * archive before erroring. So this must run right after a SUCCESSFUL install
2608
+ * (while the archive is still there), not only on failure. */
2609
+ export function verifyPuppeteerCache(cacheRoot) {
2610
+ const root = cacheRoot || process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
2611
+ const result = { repaired: [], removed: [] };
2612
+ let browserDirs;
2613
+ try {
2614
+ browserDirs = fs.readdirSync(root, { withFileTypes: true }).filter(d => d.isDirectory());
2615
+ }
2616
+ catch {
2617
+ return result; // no puppeteer cache — nothing to verify
2618
+ }
2619
+ for (const browser of browserDirs) {
2620
+ const browserDir = path.join(root, browser.name);
2621
+ let entries;
2622
+ try {
2623
+ entries = fs.readdirSync(browserDir, { withFileTypes: true });
2624
+ }
2625
+ catch {
2626
+ continue;
2627
+ }
2628
+ for (const entry of entries) {
2629
+ // version folders look like win64-148.0.7778.97 / mac_arm-131.0.6778.204
2630
+ if (!entry.isDirectory() || !/^[a-z0-9_]+-[\d.]+$/i.test(entry.name))
2631
+ continue;
2632
+ const folder = path.join(browserDir, entry.name);
2633
+ if (containsBrowserExecutable(folder))
2634
+ continue;
2635
+ try {
2636
+ // archive sits next to the version folder, named e.g.
2637
+ // chrome/148.0.7778.97-chrome-win64.zip for chrome/win64-148.0.7778.97
2638
+ const version = entry.name.slice(entry.name.indexOf('-') + 1);
2639
+ const archive = entries.find(e => e.isFile() && e.name.startsWith(`${version}-`) && e.name.endsWith('.zip'))?.name;
2640
+ if (archive && extractBrowserArchive(path.join(browserDir, archive), folder)) {
2641
+ fs.rmSync(path.join(browserDir, archive), { force: true }); // puppeteer's own cleanup never ran
2642
+ result.repaired.push(folder);
2643
+ continue;
2644
+ }
2645
+ fs.rmSync(folder, { recursive: true, force: true });
2646
+ result.removed.push(folder);
2647
+ }
2648
+ catch {
2649
+ // couldn't repair or delete (locked?) — leave it; install will report it
2650
+ }
2651
+ }
2652
+ }
2653
+ return result;
2654
+ }
2655
+ /** Detect puppeteer's corrupted-browser-cache failure ("browser folder exists
2656
+ * but the executable is missing") in failed install output and repair the
2657
+ * whole cache it points into (verifyPuppeteerCache — the installer checks
2658
+ * browsers in parallel and only reports the first corrupt one, so siblings
2659
+ * are usually poisoned too). Returns the folders repaired or removed. */
2660
+ export function fixPuppeteerCorruptedCache(combined) {
2661
+ for (const m of combined.matchAll(/The browser folder \((.+?)\) exists but the executable \((.+?)\) is missing/g)) {
2662
+ const folder = m[1];
2663
+ // Only ever touch a puppeteer cache — never an arbitrary path parsed from output
2664
+ if (!/[\\/]puppeteer[\\/]/i.test(folder))
2665
+ continue;
2666
+ // cache layout is <root>/<browser>/<platform-version>
2667
+ return verifyPuppeteerCache(path.dirname(path.dirname(folder)));
2668
+ }
2669
+ return { repaired: [], removed: [] };
2670
+ }
2671
+ /** npm 11.17+ gates lifecycle scripts behind `allowScripts` — non-covered
2672
+ * scripts are skipped and the install still exits 0. A skipped postinstall
2673
+ * can leave the package broken at runtime (puppeteer without its browser,
2674
+ * native modules unbuilt), so surface the warning instead of burying it in
2675
+ * the captured output. */
2676
+ function warnSkippedScripts(combined) {
2677
+ const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(/gm)];
2678
+ if (!pkgs.length)
2679
+ return;
2680
+ const names = pkgs.map(m => m[1].replace(/@[^@]+$/, ''));
2681
+ console.log(colors.yellow(` ⚠ npm skipped install scripts (allowScripts): ${pkgs.map(m => m[1]).join(', ')}`));
2682
+ console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm config set allow-scripts=${names.join(',')} --location=user`));
2683
+ }
2513
2684
  /** Run npm install -g with retries for registry propagation delay.
2514
2685
  * Brand-new packages (first-time publish) take much longer to become
2515
2686
  * installable than version bumps, so we use longer waits and more
2516
- * attempts when `isNewPackage` is true. */
2687
+ * attempts when `isNewPackage` is true.
2688
+ *
2689
+ * Output is captured, not streamed: npm failures produce hundreds of lines
2690
+ * (cleanup warns, stack traces, object dumps). The terminal gets a distilled
2691
+ * error summary; the full text of all attempts goes to a log file.
2692
+ * Known-fixable failures (puppeteer's corrupted browser cache) are repaired
2693
+ * automatically and granted one immediate bonus retry. */
2517
2694
  async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRetries) {
2518
2695
  const retries = maxRetries ?? (isNewPackage ? 6 : 3);
2519
2696
  const delaySec = isNewPackage ? 30 : 10;
2520
- let result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: false, showCommand: true });
2521
- for (let attempt = 1; attempt < retries && !result.success; attempt++) {
2697
+ let fullLog = '';
2698
+ let result = { success: false, output: '', stderr: '' };
2699
+ let attempt = 0;
2700
+ let cacheFixRounds = 0;
2701
+ while (true) {
2702
+ console.log(colors.cyan(`> npm install -g ${pkgSpec}`));
2703
+ result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: true });
2704
+ const combined = `${result.output}\n${result.stderr}`;
2705
+ fullLog += `===== attempt ${attempt + 1}: npm install -g ${pkgSpec} =====\n${combined}\n`;
2706
+ if (result.success) {
2707
+ const added = combined.match(/^added \d+ packages?.*$/m);
2708
+ if (added)
2709
+ console.log(colors.dim(` ${added[0]}`));
2710
+ warnSkippedScripts(combined);
2711
+ // A truncated puppeteer browser extraction exits 0, so the install
2712
+ // LOOKS fine while the cache is poisoned for every future install.
2713
+ // Verify now, while the leftover archive still allows a repair.
2714
+ const verify = verifyPuppeteerCache();
2715
+ for (const folder of verify.repaired)
2716
+ console.log(colors.yellow(` ↻ Repaired truncated puppeteer browser extraction (from cached archive): ${folder}`));
2717
+ for (const folder of verify.removed)
2718
+ console.log(colors.yellow(` ↻ Removed corrupted puppeteer browser cache (no archive to repair from — will re-download when needed): ${folder}`));
2719
+ return result;
2720
+ }
2721
+ const cleanupFails = (combined.match(/npm warn cleanup\s+\[Error: EPERM/g) || []).length;
2722
+ if (cleanupFails)
2723
+ console.log(colors.dim(` (npm couldn't remove ${cleanupFails} leftover dir(s) — EPERM cleanup warnings, details in log)`));
2724
+ for (const line of distillNpmErrors(combined))
2725
+ console.error(colors.red(` ${line}`));
2726
+ // Known-fixable failure: repair and retry immediately — the cause is
2727
+ // local, so waiting for registry propagation is pointless. Puppeteer
2728
+ // reports one corrupted browser per run, so allow a few repair rounds
2729
+ // (each must actually repair or remove a folder to earn another retry).
2730
+ if (cacheFixRounds < 3) {
2731
+ const fix = fixPuppeteerCorruptedCache(combined);
2732
+ if (fix.repaired.length || fix.removed.length) {
2733
+ cacheFixRounds++;
2734
+ for (const folder of fix.repaired)
2735
+ console.log(colors.yellow(` ↻ Repaired corrupted puppeteer browser cache (extracted cached archive): ${folder}`));
2736
+ for (const folder of fix.removed)
2737
+ console.log(colors.yellow(` ↻ Removed corrupted puppeteer browser cache (will re-download): ${folder}`));
2738
+ console.log(colors.yellow(` (puppeteer's extract-zip truncates silently on Node 26, leaving folders without executables) — retrying now`));
2739
+ continue;
2740
+ }
2741
+ }
2742
+ attempt++;
2743
+ if (attempt >= retries)
2744
+ break;
2522
2745
  console.log(colors.yellow(` Retrying install (attempt ${attempt + 1}/${retries}) in ${delaySec} seconds...`));
2523
2746
  sleepSync(delaySec * 1000);
2524
- result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: false, showCommand: true });
2525
2747
  }
2748
+ const logFile = saveNpmLog(`install-${pkgSpec === '.' ? path.basename(cwd) : pkgSpec}`, fullLog);
2749
+ if (logFile)
2750
+ console.error(colors.dim(` Full npm output: ${logFile}`));
2526
2751
  return result;
2527
2752
  }
2528
2753
  /** Run a command and return success status */
@@ -5523,7 +5748,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5523
5748
  if (pkg.bin && (install || link || wsl)) {
5524
5749
  if (link) {
5525
5750
  console.log(`Installing ${pkgName} globally from local directory (link)...`);
5526
- const localInstallResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5751
+ const localInstallResult = await installGlobalWithRetry('.', cwd, false, 1);
5527
5752
  if (localInstallResult.success) {
5528
5753
  console.log(colors.green(`✓ Linked globally: ${pkgName}@${pkgVersion}`));
5529
5754
  }
@@ -5538,7 +5763,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5538
5763
  // sibling imports. Local install handles workspace linking correctly.
5539
5764
  if (pkg.workspaces) {
5540
5765
  console.log(`Installing ${pkgName}@${pkgVersion} globally from local (workspace)...`);
5541
- const localResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5766
+ const localResult = await installGlobalWithRetry('.', cwd, false, 1);
5542
5767
  if (localResult.success) {
5543
5768
  console.log(colors.green(`✓ Installed globally: ${pkgName}@${pkgVersion}`));
5544
5769
  }
@@ -5577,7 +5802,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5577
5802
  else {
5578
5803
  console.log(colors.yellow(`${pkgName}@${pkgVersion} not found on npm — installing from local directory.`));
5579
5804
  console.log(colors.dim(' Use -m "message" to publish this version to npm.'));
5580
- const localResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5805
+ const localResult = await installGlobalWithRetry('.', cwd, false, 1);
5581
5806
  if (localResult.success) {
5582
5807
  console.log(colors.green(`✓ Installed globally from local: ${pkgName}@${pkgVersion}`));
5583
5808
  }
@@ -6361,7 +6586,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6361
6586
  if (link) {
6362
6587
  console.log(`Linking globally: ${pkgName}@${pkgVersion}...`);
6363
6588
  if (!dryRun) {
6364
- const installResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
6589
+ const installResult = await installGlobalWithRetry('.', cwd, false, 1);
6365
6590
  if (installResult.success) {
6366
6591
  globalInstallOk = true;
6367
6592
  console.log(colors.green(`✓ Linked globally: ${pkgName}@${pkgVersion}`));
@@ -6381,7 +6606,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
6381
6606
  // Workspace packages: install from local for proper workspace linking
6382
6607
  console.log(`Installing ${pkgName}@${pkgVersion} globally from local (workspace)...`);
6383
6608
  if (!dryRun) {
6384
- const installResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
6609
+ const installResult = await installGlobalWithRetry('.', cwd, false, 1);
6385
6610
  if (installResult.success) {
6386
6611
  globalInstallOk = true;
6387
6612
  console.log(colors.green(`✓ Installed globally: ${pkgName}@${pkgVersion}`));
@@ -6942,7 +7167,7 @@ export async function globalizeWorkspace(rootDir, options = {}, configOptions =
6942
7167
  // Workspace root is private — always install from local directory
6943
7168
  console.log(`Installing ${pkgName}@${pkgVersion} globally from local directory...`);
6944
7169
  if (!dryRun) {
6945
- const installResult = await runCommandAsync('npm', ['install', '-g', '.'], { cwd: rootDir, silent: false, showCommand: true });
7170
+ const installResult = await installGlobalWithRetry('.', rootDir, false, 1);
6946
7171
  if (installResult.success) {
6947
7172
  console.log(colors.green(`✓ Installed globally: ${pkgName}@${pkgVersion}`));
6948
7173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.192",
3
+ "version": "1.0.193",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",