@bobfrankston/npmglobalize 1.0.195 → 1.0.197

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 +31 -2
  2. package/cli.js +3 -1
  3. package/lib.d.ts +49 -0
  4. package/lib.js +583 -34
  5. package/package.json +3 -3
package/README.md CHANGED
@@ -590,14 +590,43 @@ Before transforming or publishing anything, `npmglobalize` builds `file:` depend
590
590
 
591
591
  For each project visited (the target and every transitive `file:` dep):
592
592
 
593
- - If `tsconfig.json` is missing or has `"noEmit": true` → **skip** (not a TypeScript build).
594
- - If `tsconfig.json` exists but `package.json` has no `"build"` script → **prompt** to add `"build": "tsc"`. Decline and that project is skipped.
593
+ - If `tsconfig.json` is missing or has `"noEmit": true` → **skip** (not a TypeScript build), unless the `build` script runs `importgen` — a plain-JS browser app still needs its import map regenerated.
594
+ - If `tsconfig.json` exists but `package.json` has no `"build"` script → **prompt** to add `"build": "tsc"` (plus a `tsc -p <dir>` per sub-project — see below). Decline and that project is skipped.
595
595
  - Otherwise → run `npm run build`. A failure halts the cascade unless `-force` is passed.
596
596
 
597
597
  Cycle-safe via a shared visited set; each project is built at most once per run.
598
598
 
599
599
  This complements the existing publish cascade (which ensures version refs are correct) by closing the build-freshness gap that `npm install` alone left open.
600
600
 
601
+ #### Import maps (`importgen`) as a build step
602
+
603
+ Browser projects that use [`importgen`](https://www.npmjs.com/package/@bobfrankston/importgen) have historically regenerated their import map from `.vscode/tasks.json`, which only runs when VS Code opens the folder — so a command-line or CI build could publish a stale map. `npmglobalize` treats the import map as a build product and moves the step into the package's own `build` script, where every build path picks it up.
604
+
605
+ Before building each project, it checks whether the project is an importgen project, in this order:
606
+
607
+ 1. `.vscode/tasks.json` has a task whose `command` is `importgen` (the HTML file, if the task names one, is reused).
608
+ 2. A root-level `.htm`/`.html` file already contains a `<script type="importmap">` block (`index.html`, `default.html`, `default.htm` are checked first, so a stray `temp.htm` doesn't win).
609
+ 3. `importgen` is in `dependencies`/`devDependencies` **and** the project has one of those HTML files — the HTML requirement keeps packages that merely *use* importgen as a library from matching.
610
+
611
+ If a signal matches and the `build` script doesn't already run `importgen`, you're prompted to rewrite it — e.g. `"build": "tsc"` → `"build": "importgen default.htm && tsc"`. The HTML file is named explicitly so importgen doesn't have to guess. Decline and `"importgen": false` is written to `.globalize.json5`, which suppresses the prompt for good; `-noimportgen` does the same from the command line.
612
+
613
+ Once wired, the freshness check gains a second condition: a project whose build runs importgen is only considered up to date if the generated HTML is at least as new as `package.json`. Adding a dependency changes the import map without touching any `.ts` file, which the source-vs-output comparison alone would miss.
614
+
615
+ #### Sub-projects (a second `tsconfig.json` in a sub-directory)
616
+
617
+ A package can hold more than one TypeScript project. The common shape is a service worker in `Sw/` with its own `tsconfig.json` (`lib: ["WebWorker"]`, its own `outDir`) that the root `tsconfig.json` lists under `exclude` — so a bare `"build": "tsc"` compiles everything *except* the service worker, and the stale `sw2.js` ships. As with import maps, these have historically been built only by a second `.vscode/tasks.json` watcher, which runs on folder open and nowhere else.
618
+
619
+ Before building, `npmglobalize` looks for sub-projects, in this order:
620
+
621
+ 1. `.vscode/tasks.json` has a task that runs `tsc` with `"options": { "cwd": "${workspaceFolder}/Sw" }` or with `-p`/`--project` naming a sub-directory (authoritative — it's how the project is actually built today; the task's `label` is quoted back in the prompt).
622
+ 2. An immediate sub-directory containing its own `tsconfig.json`. Build output and vendored trees (`node_modules`, `prev`, `dist`, `built`, `out`, `wwwroot`, `coverage`, `temp`, `preflight`, dot-directories) are never scanned.
623
+
624
+ Nothing is detected when the root `tsconfig.json` uses project `references` — that build graph belongs to `tsc -b` and isn't second-guessed.
625
+
626
+ When the `build` script is `tsc`-driven and doesn't already compile a detected sub-project, you're prompted to append it — `"build": "tsc"` → `"build": "tsc && tsc -p Sw"`, or for an importgen project `"build": "importgen default.htm && tsc && tsc -p Sw"`. Already-wired scripts are recognized in any of their spellings (`tsc -p Sw`, `tsc --project ./Sw/tsconfig.json`, `cd Sw && tsc`), and the directory is emitted with its real on-disk casing so the script still works on WSL and CI. Decline and `"subProjects": false` is written to `.globalize.json5`, suppressing the prompt for good; a `"subProjects": ["Sw"]` array pins the list instead of detecting it.
627
+
628
+ Sub-projects the build actually compiles are also folded into the freshness check — the package rebuilds when a sub-project's sources are newer than its output. A sub-project that emits *outside* its own directory (`"outDir": ".."`, the usual service-worker case) always reports stale: comparing its sources against the whole package would prove nothing.
629
+
601
630
  #### TypeScript 6 `types` auto-fix
602
631
 
603
632
  TypeScript 6 dropped the legacy behavior of auto-including every installed `@types/*` package. A `tsconfig.json` with no explicit `compilerOptions.types` then loses the Node globals (`process`, `Buffer`, …) and the build fails with `TS2591`.
package/cli.js CHANGED
@@ -73,7 +73,9 @@ Install Options:
73
73
  -freeze Freeze node_modules (replace symlinks with real copies for network shares)
74
74
  -nofreeze Disable freeze
75
75
  -importgen Run importgen to update import maps before publishing
76
- -noimportgen Disable importgen
76
+ (auto-detected from .vscode/tasks.json or an import map in the
77
+ project's HTML — npmglobalize offers to add it to the build script)
78
+ -noimportgen Disable importgen (recorded in .globalize.json5, stops the prompt)
77
79
  -once Don't persist flags to .globalize.json5
78
80
 
79
81
  Mode Options:
package/lib.d.ts CHANGED
@@ -101,6 +101,11 @@ export interface GlobalizeOptions {
101
101
  once?: boolean;
102
102
  /** Run importgen to update import maps before publishing */
103
103
  importgen?: boolean;
104
+ /** Sub-directories with their own tsconfig.json that `build` must also compile
105
+ * (e.g. a service worker in `Sw/`). undefined: auto-detect from
106
+ * .vscode/tasks.json + nested tsconfigs. An array pins the list.
107
+ * false: never ask about sub-projects here. */
108
+ subProjects?: string[] | false;
104
109
  /** Use filesystem paths for `file:` deps (default true). Set false to
105
110
  * mark a package as publishable/installable even when sibling checkouts
106
111
  * are absent. Currently declarative (recorded in config and displayed);
@@ -324,6 +329,42 @@ export declare function ensureFileDepModules(cwd: string, verbose?: boolean, vis
324
329
  * ignored — npmglobalize itself rewrites it around every publish, which would
325
330
  * otherwise force a rebuild on every run. */
326
331
  export declare function isBuildUpToDate(cwd: string): boolean;
332
+ /** Detect whether a package is an importgen project — a browser app whose HTML
333
+ * carries a generated `<script type="importmap">`. Signals, in order:
334
+ * 1. `.vscode/tasks.json` runs `importgen` (how these projects are driven
335
+ * today: VS Code starts an importgen watcher on folder open),
336
+ * 2. a root HTML file already contains an import map,
337
+ * 3. importgen is a declared dependency.
338
+ * Returns the HTML file importgen should update plus the signal that matched,
339
+ * or null when this isn't an importgen project. */
340
+ export declare function detectImportgen(cwd: string): {
341
+ htmlFile?: string;
342
+ reason: string;
343
+ } | null;
344
+ /** Wire `importgen` into the package's own `build` script when the project turns
345
+ * out to be an importgen project. The import map is a build product, so it
346
+ * belongs in `npm run build` — that way it regenerates everywhere (VS Code, CI,
347
+ * a bare command line, npmglobalize) instead of only when VS Code happens to
348
+ * start its watcher on folder open. Declining records `importgen: false` in
349
+ * .globalize.json5 so it stops asking. */
350
+ export declare function ensureImportgenInBuild(cwd: string): Promise<void>;
351
+ /** Sub-directories that need their own `tsc -p <dir>` pass. A package can hold
352
+ * more than one tsconfig — a service worker compiled with `lib: WebWorker`, say,
353
+ * which the root tsconfig `exclude`s — and the root `tsc` silently skips them.
354
+ * Signals, in order:
355
+ * 1. `.vscode/tasks.json` runs tsc with `options.cwd` or `-p` pointing at a
356
+ * sub-dir (authoritative: it's how the developer actually builds today),
357
+ * 2. an immediate sub-directory holding its own tsconfig.json.
358
+ * Returns nothing when the root tsconfig uses project `references` — `tsc -b`
359
+ * owns that build graph and shouldn't be second-guessed. */
360
+ export declare function detectSubProjects(cwd: string): Array<{
361
+ dir: string;
362
+ reason: string;
363
+ }>;
364
+ /** Append `tsc -p <dir>` for every sub-project the existing build script misses.
365
+ * Only touches tsc-driven builds — a bundler-driven `build` has its own idea of
366
+ * what to compile. Declining records `subProjects: false` in .globalize.json5. */
367
+ export declare function ensureSubProjectsInBuild(cwd: string): Promise<void>;
327
368
  /** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
328
369
  * TypeScript project lacks a build script, run `npm run build`, record
329
370
  * failures. Returns true if build succeeded (or was skipped because no
@@ -416,6 +457,14 @@ export declare function fixPuppeteerCorruptedCache(combined: string): {
416
457
  repaired: string[];
417
458
  removed: string[];
418
459
  };
460
+ /** Every package in `dir`'s dependency closure that shares its scope — our
461
+ * own packages, published by this same tool. Walks node_modules for
462
+ * transitive own-scope deps, since a cascade dep can carry its own
463
+ * postinstall and only its installed copy lists its dependencies.
464
+ *
465
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
466
+ * package of ours from a third-party one by name alone. */
467
+ export declare function ownScopePackages(dir: string): string[];
419
468
  /** Run a command and return success status */
420
469
  export declare function runCommand(cmd: string, args: string[], options?: {
421
470
  silent?: boolean;
package/lib.js CHANGED
@@ -299,6 +299,10 @@ export function writeConfig(dir, config, explicitKeys) {
299
299
  comment = ' // Resolve file: deps from sibling checkouts (set false for standalone packages)';
300
300
  else if (key === 'allowTs')
301
301
  comment = ' // Keep .ts source (and *.map, tsconfig.json) in the published npm tarball';
302
+ else if (key === 'importgen')
303
+ comment = ' // Regenerate browser import maps (false = never ask about importgen here)';
304
+ else if (key === 'subProjects')
305
+ comment = ' // Sub-dirs with their own tsconfig that build must compile (false = never ask)';
302
306
  lines.push(` "${key}": ${jsonValue}${comma}${comment}`);
303
307
  });
304
308
  lines.push('');
@@ -321,6 +325,8 @@ export function writeConfig(dir, config, explicitKeys) {
321
325
  lines.push(' // "freeze": false // Freeze node_modules (replace symlinks with real copies)');
322
326
  lines.push(' // "usePaths": true // Resolve file: deps from siblings; false = use latest npm version (standalone)');
323
327
  lines.push(' // "allowTs": false // Include .ts source in npm tarball (auto-true for noEmit projects)');
328
+ lines.push(' // "importgen": auto // Detected from .vscode/tasks.json / import map in HTML; false = never ask');
329
+ lines.push(' // "subProjects": auto // Sub-dirs with their own tsconfig (e.g. ["Sw"]); detected from .vscode/tasks.json; false = never ask');
324
330
  lines.push('}');
325
331
  fs.writeFileSync(configPath, lines.join('\n') + '\n');
326
332
  }
@@ -2400,12 +2406,372 @@ export function isBuildUpToDate(cwd) {
2400
2406
  }
2401
2407
  return configTime <= newestOut;
2402
2408
  }
2409
+ /** HTML files importgen looks for when none is named, in its preference order. */
2410
+ const IMPORTGEN_HTML_CANDIDATES = ['index.html', 'default.html', 'default.htm'];
2411
+ /** Resolve the HTML file importgen would update in `cwd`. */
2412
+ function findImportgenHtml(cwd, preferred) {
2413
+ if (preferred && fs.existsSync(path.join(cwd, preferred)))
2414
+ return preferred;
2415
+ for (const name of IMPORTGEN_HTML_CANDIDATES) {
2416
+ if (fs.existsSync(path.join(cwd, name)))
2417
+ return name;
2418
+ }
2419
+ return undefined;
2420
+ }
2421
+ /** True when a script string invokes importgen (`importgen`, `npx importgen`,
2422
+ * `importgen default.htm`, …) — used to tell a wired build from an unwired one. */
2423
+ function scriptRunsImportgen(script) {
2424
+ return /(^|[\s/\\&|])importgen(\s|$)/.test(script);
2425
+ }
2426
+ /** Detect whether a package is an importgen project — a browser app whose HTML
2427
+ * carries a generated `<script type="importmap">`. Signals, in order:
2428
+ * 1. `.vscode/tasks.json` runs `importgen` (how these projects are driven
2429
+ * today: VS Code starts an importgen watcher on folder open),
2430
+ * 2. a root HTML file already contains an import map,
2431
+ * 3. importgen is a declared dependency.
2432
+ * Returns the HTML file importgen should update plus the signal that matched,
2433
+ * or null when this isn't an importgen project. */
2434
+ export function detectImportgen(cwd) {
2435
+ // 1. .vscode/tasks.json — JSON5 because VS Code allows comments/trailing commas
2436
+ try {
2437
+ const tasks = JSON5.parse(fs.readFileSync(path.join(cwd, '.vscode', 'tasks.json'), 'utf-8'));
2438
+ for (const task of Array.isArray(tasks?.tasks) ? tasks.tasks : []) {
2439
+ const command = typeof task?.command === 'string' ? task.command : '';
2440
+ const args = Array.isArray(task?.args) ? task.args.filter((a) => typeof a === 'string') : [];
2441
+ if (!scriptRunsImportgen(command))
2442
+ continue;
2443
+ const named = args.find(a => /\.html?$/i.test(a));
2444
+ return { htmlFile: findImportgenHtml(cwd, named), reason: '.vscode/tasks.json runs importgen' };
2445
+ }
2446
+ }
2447
+ catch {
2448
+ // No tasks.json, or unparsable — fall through to the other signals
2449
+ }
2450
+ // 2. A root HTML file that already holds an import map. Check importgen's own
2451
+ // candidates first so `default.htm` wins over a stray `temp.htm` copy.
2452
+ let htmlFiles = [];
2453
+ try {
2454
+ htmlFiles = fs.readdirSync(cwd).filter(f => /\.html?$/i.test(f));
2455
+ }
2456
+ catch {
2457
+ // Unreadable directory — nothing to detect
2458
+ }
2459
+ htmlFiles.sort((a, b) => {
2460
+ const rank = (f) => {
2461
+ const i = IMPORTGEN_HTML_CANDIDATES.indexOf(f.toLowerCase());
2462
+ return i === -1 ? IMPORTGEN_HTML_CANDIDATES.length : i;
2463
+ };
2464
+ return rank(a) - rank(b) || a.localeCompare(b);
2465
+ });
2466
+ for (const name of htmlFiles) {
2467
+ try {
2468
+ const html = fs.readFileSync(path.join(cwd, name), 'utf-8');
2469
+ if (/<script\s+type=["']importmap["']/i.test(html)) {
2470
+ return { htmlFile: name, reason: `${name} contains an import map` };
2471
+ }
2472
+ }
2473
+ catch {
2474
+ // Unreadable file — try the next one
2475
+ }
2476
+ }
2477
+ // 3. importgen declared as a dependency AND an HTML file to inject into.
2478
+ // The HTML requirement matters: a package can depend on importgen as a
2479
+ // library (npmglobalize itself does) without being a browser app.
2480
+ const htmlFile = findImportgenHtml(cwd);
2481
+ if (htmlFile) {
2482
+ try {
2483
+ const pkg = readPackageJson(cwd);
2484
+ for (const key of ['dependencies', 'devDependencies']) {
2485
+ const deps = pkg?.[key];
2486
+ if (!deps || typeof deps !== 'object')
2487
+ continue;
2488
+ if (Object.keys(deps).some(n => n === 'importgen' || n.endsWith('/importgen'))) {
2489
+ return { htmlFile, reason: `importgen is in ${key}` };
2490
+ }
2491
+ }
2492
+ }
2493
+ catch {
2494
+ // No package.json — caller handles that
2495
+ }
2496
+ }
2497
+ return null;
2498
+ }
2499
+ /** Wire `importgen` into the package's own `build` script when the project turns
2500
+ * out to be an importgen project. The import map is a build product, so it
2501
+ * belongs in `npm run build` — that way it regenerates everywhere (VS Code, CI,
2502
+ * a bare command line, npmglobalize) instead of only when VS Code happens to
2503
+ * start its watcher on folder open. Declining records `importgen: false` in
2504
+ * .globalize.json5 so it stops asking. */
2505
+ export async function ensureImportgenInBuild(cwd) {
2506
+ const config = readConfig(cwd);
2507
+ if (config.importgen === false)
2508
+ return;
2509
+ let pkg;
2510
+ try {
2511
+ pkg = readPackageJson(cwd);
2512
+ }
2513
+ catch {
2514
+ return;
2515
+ }
2516
+ const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
2517
+ if (scriptRunsImportgen(buildScript))
2518
+ return; // already wired
2519
+ const detected = config.importgen === true
2520
+ ? { htmlFile: findImportgenHtml(cwd), reason: 'importgen: true in .globalize.json5' }
2521
+ : detectImportgen(cwd);
2522
+ if (!detected)
2523
+ return;
2524
+ const name = pkg.name || path.basename(cwd);
2525
+ // Name the HTML file explicitly: importgen's own search order may pick a
2526
+ // different file than the one this project actually ships.
2527
+ const command = detected.htmlFile ? `importgen ${detected.htmlFile}` : 'importgen';
2528
+ const newScript = buildScript ? `${command} && ${buildScript}` : command;
2529
+ console.log(colors.yellow(`${name} looks like an importgen project (${detected.reason}) but its build script doesn't run importgen.`));
2530
+ const addIt = await confirm(`Set "build": "${newScript}" in ${name}'s package.json?`, true);
2531
+ if (!addIt) {
2532
+ writeConfig(cwd, { ...config, importgen: false }, new Set(['importgen']));
2533
+ console.log(colors.dim(` Recorded importgen: false in .globalize.json5 — won't ask again.`));
2534
+ return;
2535
+ }
2536
+ if (!pkg.scripts)
2537
+ pkg.scripts = {};
2538
+ pkg.scripts.build = newScript;
2539
+ writePackageJson(cwd, pkg);
2540
+ console.log(colors.green(`✓ Added importgen to ${name}'s build script`));
2541
+ }
2542
+ /** True when the generated import map is at least as new as the package.json it
2543
+ * is generated from. `isBuildUpToDate` only compares .ts sources to emitted
2544
+ * .js, so it can't see a dependency added to package.json — which is exactly
2545
+ * what changes an import map. */
2546
+ function isImportMapUpToDate(cwd) {
2547
+ const htmlFile = findImportgenHtml(cwd, detectImportgen(cwd)?.htmlFile);
2548
+ if (!htmlFile)
2549
+ return false; // can't tell which file — let the build run
2550
+ const mtime = (p) => { try {
2551
+ return fs.statSync(p).mtimeMs;
2552
+ }
2553
+ catch {
2554
+ return 0;
2555
+ } };
2556
+ const htmlTime = mtime(path.join(cwd, htmlFile));
2557
+ return htmlTime > 0 && htmlTime >= mtime(path.join(cwd, 'package.json'));
2558
+ }
2559
+ /** Directories never worth scanning for a nested tsconfig — build output, vendored
2560
+ * code, and the prev/ archive all carry stray tsconfigs that aren't sub-projects. */
2561
+ const SUBPROJECT_SKIP_DIRS = new Set([
2562
+ 'node_modules', 'prev', 'built', 'build', 'dist', 'out', 'output',
2563
+ 'wwwroot', 'coverage', 'temp', 'tmp', 'preflight'
2564
+ ]);
2565
+ /** True when a script string invokes `tsc` (`tsc`, `npx tsc`, `tsc -p Sw`, …). */
2566
+ function scriptRunsTsc(script) {
2567
+ return /(^|[\s&|(])(npx\s+)?tsc(\s|$)/.test(script);
2568
+ }
2569
+ /** Normalize a path as written in a script/task (quotes, backslashes, `./`,
2570
+ * a trailing `tsconfig.json`) down to a comparable relative directory. */
2571
+ function normalizeProjectPath(raw) {
2572
+ return raw
2573
+ .replace(/^["']|["']$/g, '')
2574
+ .replace(/\$\{workspaceFolder\}/gi, '')
2575
+ .replace(/\\/g, '/')
2576
+ .replace(/^\.\//, '')
2577
+ .replace(/\/tsconfig\.json$/i, '')
2578
+ .replace(/^\/+|\/+$/g, '');
2579
+ }
2580
+ /** Match `rel` against what's actually on disk so the emitted script uses the
2581
+ * real casing (`Sw`, not `sw`) — Windows compares case-insensitively, but the
2582
+ * script also has to run on WSL/CI where it doesn't. Returns null if absent. */
2583
+ function resolveOnDiskCase(base, rel) {
2584
+ const parts = rel.split('/').filter(Boolean);
2585
+ let dir = base;
2586
+ const out = [];
2587
+ for (const part of parts) {
2588
+ let entries;
2589
+ try {
2590
+ entries = fs.readdirSync(dir);
2591
+ }
2592
+ catch {
2593
+ return null;
2594
+ }
2595
+ const match = entries.find(e => e.toLowerCase() === part.toLowerCase());
2596
+ if (!match)
2597
+ return null;
2598
+ out.push(match);
2599
+ dir = path.join(dir, match);
2600
+ }
2601
+ return out.length ? out.join('/') : null;
2602
+ }
2603
+ /** True when `script` already compiles the tsconfig in `dir` — `tsc -p Sw`,
2604
+ * `tsc --project ./Sw/tsconfig.json`, or `cd Sw && tsc`. */
2605
+ function scriptBuildsSubProject(script, dir) {
2606
+ const target = normalizeProjectPath(dir).toLowerCase();
2607
+ if (!target)
2608
+ return false;
2609
+ for (const re of [/(?:^|\s)(?:-p|--project)[\s=]+("[^"]+"|'[^']+'|\S+)/g,
2610
+ /(?:^|[\s&|(])cd\s+("[^"]+"|'[^']+'|\S+)/g]) {
2611
+ for (const m of script.matchAll(re)) {
2612
+ if (normalizeProjectPath(m[1]).toLowerCase() === target)
2613
+ return true;
2614
+ }
2615
+ }
2616
+ return false;
2617
+ }
2618
+ /** Sub-directories that need their own `tsc -p <dir>` pass. A package can hold
2619
+ * more than one tsconfig — a service worker compiled with `lib: WebWorker`, say,
2620
+ * which the root tsconfig `exclude`s — and the root `tsc` silently skips them.
2621
+ * Signals, in order:
2622
+ * 1. `.vscode/tasks.json` runs tsc with `options.cwd` or `-p` pointing at a
2623
+ * sub-dir (authoritative: it's how the developer actually builds today),
2624
+ * 2. an immediate sub-directory holding its own tsconfig.json.
2625
+ * Returns nothing when the root tsconfig uses project `references` — `tsc -b`
2626
+ * owns that build graph and shouldn't be second-guessed. */
2627
+ export function detectSubProjects(cwd) {
2628
+ try {
2629
+ const root = JSON5.parse(fs.readFileSync(path.join(cwd, 'tsconfig.json'), 'utf-8'));
2630
+ if (Array.isArray(root?.references) && root.references.length > 0)
2631
+ return [];
2632
+ }
2633
+ catch {
2634
+ // No root tsconfig (or unparsable) — nested ones still count
2635
+ }
2636
+ const found = new Map(); // on-disk relative dir → reason
2637
+ const add = (raw, reason) => {
2638
+ const rel = normalizeProjectPath(raw);
2639
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
2640
+ return;
2641
+ if (rel.split('/').some(p => SUBPROJECT_SKIP_DIRS.has(p.toLowerCase()) || p.startsWith('.')))
2642
+ return;
2643
+ const onDisk = resolveOnDiskCase(cwd, rel);
2644
+ if (!onDisk)
2645
+ return;
2646
+ if (!fs.existsSync(path.join(cwd, onDisk, 'tsconfig.json')))
2647
+ return;
2648
+ if (!found.has(onDisk))
2649
+ found.set(onDisk, reason);
2650
+ };
2651
+ // 1. .vscode/tasks.json — JSON5 because VS Code allows comments/trailing commas
2652
+ try {
2653
+ const tasks = JSON5.parse(fs.readFileSync(path.join(cwd, '.vscode', 'tasks.json'), 'utf-8'));
2654
+ for (const task of Array.isArray(tasks?.tasks) ? tasks.tasks : []) {
2655
+ const command = typeof task?.command === 'string' ? task.command : '';
2656
+ const args = Array.isArray(task?.args) ? task.args.filter((a) => typeof a === 'string') : [];
2657
+ if (!scriptRunsTsc([command, ...args].join(' ')))
2658
+ continue;
2659
+ const label = typeof task?.label === 'string' ? task.label : 'a tsc task';
2660
+ const projIdx = args.findIndex(a => a === '-p' || a === '--project');
2661
+ if (projIdx !== -1 && args[projIdx + 1]) {
2662
+ add(args[projIdx + 1], `.vscode/tasks.json "${label}" builds it`);
2663
+ }
2664
+ else if (typeof task?.options?.cwd === 'string') {
2665
+ add(task.options.cwd, `.vscode/tasks.json "${label}" runs tsc there`);
2666
+ }
2667
+ }
2668
+ }
2669
+ catch {
2670
+ // No tasks.json, or unparsable — fall through to the filesystem scan
2671
+ }
2672
+ // 2. Immediate sub-directories with their own tsconfig.json
2673
+ try {
2674
+ for (const e of fs.readdirSync(cwd, { withFileTypes: true })) {
2675
+ if (!e.isDirectory() || e.isSymbolicLink())
2676
+ continue;
2677
+ if (e.name.startsWith('.') || SUBPROJECT_SKIP_DIRS.has(e.name.toLowerCase()))
2678
+ continue;
2679
+ if (fs.existsSync(path.join(cwd, e.name, 'tsconfig.json'))) {
2680
+ add(e.name, `${e.name}/tsconfig.json`);
2681
+ }
2682
+ }
2683
+ }
2684
+ catch {
2685
+ // Unreadable directory — nothing more to detect
2686
+ }
2687
+ return [...found].map(([dir, reason]) => ({ dir, reason }));
2688
+ }
2689
+ /** The sub-projects to wire, honoring `.globalize.json5`: `false` disables the
2690
+ * whole check, an array pins the list instead of detecting it. */
2691
+ function configuredSubProjects(cwd, config) {
2692
+ if (config.subProjects === false)
2693
+ return [];
2694
+ if (Array.isArray(config.subProjects)) {
2695
+ return config.subProjects
2696
+ .map(d => ({ dir: resolveOnDiskCase(cwd, normalizeProjectPath(d)) || d, reason: 'listed in .globalize.json5' }))
2697
+ .filter(s => fs.existsSync(path.join(cwd, s.dir, 'tsconfig.json')));
2698
+ }
2699
+ return detectSubProjects(cwd);
2700
+ }
2701
+ /** Quote a path for a package.json script only when it needs it. */
2702
+ function quoteScriptPath(p) {
2703
+ return /[\s&|<>]/.test(p) ? `"${p}"` : p;
2704
+ }
2705
+ /** Append `tsc -p <dir>` for every sub-project the existing build script misses.
2706
+ * Only touches tsc-driven builds — a bundler-driven `build` has its own idea of
2707
+ * what to compile. Declining records `subProjects: false` in .globalize.json5. */
2708
+ export async function ensureSubProjectsInBuild(cwd) {
2709
+ const config = readConfig(cwd);
2710
+ if (config.subProjects === false)
2711
+ return;
2712
+ let pkg;
2713
+ try {
2714
+ pkg = readPackageJson(cwd);
2715
+ }
2716
+ catch {
2717
+ return;
2718
+ }
2719
+ const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build.trim() : '';
2720
+ if (!buildScript || !scriptRunsTsc(buildScript))
2721
+ return;
2722
+ const missing = configuredSubProjects(cwd, config)
2723
+ .filter(s => !scriptBuildsSubProject(buildScript, s.dir));
2724
+ if (missing.length === 0)
2725
+ return;
2726
+ const name = pkg.name || path.basename(cwd);
2727
+ const newScript = [buildScript, ...missing.map(s => `tsc -p ${quoteScriptPath(s.dir)}`)].join(' && ');
2728
+ // Default to yes only when tasks.json says the developer already builds these
2729
+ // — a bare nested tsconfig (tests, examples) is a weaker signal, and this
2730
+ // prompt rewrites a build script that currently works.
2731
+ const strong = missing.every(s => s.reason.startsWith('.vscode/tasks.json') || s.reason.startsWith('listed in'));
2732
+ console.log(colors.yellow(`${name}'s build script doesn't compile ${missing.length === 1 ? 'a sub-project' : 'its sub-projects'}:`));
2733
+ for (const s of missing)
2734
+ console.log(colors.yellow(` ${s.dir}/ — ${s.reason}`));
2735
+ const addIt = await confirm(`Set "build": "${newScript}" in ${name}'s package.json?`, strong);
2736
+ if (!addIt) {
2737
+ writeConfig(cwd, { ...config, subProjects: false }, new Set(['subProjects']));
2738
+ console.log(colors.dim(` Recorded subProjects: false in .globalize.json5 — won't ask again.`));
2739
+ return;
2740
+ }
2741
+ pkg.scripts.build = newScript;
2742
+ writePackageJson(cwd, pkg);
2743
+ console.log(colors.green(`✓ Added ${missing.map(s => `tsc -p ${s.dir}`).join(' && ')} to ${name}'s build script`));
2744
+ }
2745
+ /** Freshness for the sub-projects the build script actually compiles. A
2746
+ * sub-project that emits outside its own directory (a service worker with
2747
+ * `outDir: ".."`, say) reports stale: `isBuildUpToDate` would then be comparing
2748
+ * its sources against the whole package, which proves nothing. */
2749
+ function areSubProjectsUpToDate(cwd, subs) {
2750
+ for (const s of subs) {
2751
+ const dir = path.join(cwd, s.dir);
2752
+ let co;
2753
+ try {
2754
+ co = JSON5.parse(fs.readFileSync(path.join(dir, 'tsconfig.json'), 'utf-8')).compilerOptions || {};
2755
+ }
2756
+ catch {
2757
+ return false;
2758
+ }
2759
+ if (typeof co.outDir === 'string' && path.relative(dir, path.resolve(dir, co.outDir)).startsWith('..'))
2760
+ return false;
2761
+ if (!isBuildUpToDate(dir))
2762
+ return false;
2763
+ }
2764
+ return true;
2765
+ }
2403
2766
  /** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
2404
2767
  * TypeScript project lacks a build script, run `npm run build`, record
2405
2768
  * failures. Returns true if build succeeded (or was skipped because no
2406
2769
  * tsconfig / noEmit / no build script after declining the prompt / output
2407
2770
  * already up to date). Pass `forceBuild` to skip the freshness check. */
2408
2771
  export async function buildProject(cwd, opts = {}) {
2772
+ // Do this before the tsconfig check: an importgen project may be plain JS
2773
+ // with no tsconfig at all, and still need its import map regenerated.
2774
+ await ensureImportgenInBuild(cwd);
2409
2775
  let shouldBuild = false;
2410
2776
  try {
2411
2777
  const tsconfigPath = path.join(cwd, 'tsconfig.json');
@@ -2416,25 +2782,60 @@ export async function buildProject(cwd, opts = {}) {
2416
2782
  catch {
2417
2783
  // No tsconfig — skip
2418
2784
  }
2419
- if (!shouldBuild)
2785
+ let earlyPkg;
2786
+ try {
2787
+ earlyPkg = readPackageJson(cwd);
2788
+ }
2789
+ catch {
2420
2790
  return true;
2421
- const pkg = readPackageJson(cwd);
2422
- if (!pkg.scripts?.build) {
2791
+ }
2792
+ const runsImportgen = scriptRunsImportgen(typeof earlyPkg.scripts?.build === 'string' ? earlyPkg.scripts.build : '');
2793
+ // A build script that regenerates the import map is worth running even when
2794
+ // there's nothing for tsc to emit.
2795
+ if (!shouldBuild && !runsImportgen)
2796
+ return true;
2797
+ const pkg = earlyPkg;
2798
+ const existingBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build.trim() : '';
2799
+ // A build script ensureImportgenInBuild just created for a package that had
2800
+ // none still needs its tsc pass — treat importgen-only as "no build yet".
2801
+ const importgenOnly = !!existingBuild && existingBuild.split(/\s*&&\s*/).every(scriptRunsImportgen);
2802
+ if (shouldBuild && (!existingBuild || importgenOnly)) {
2803
+ // Compile the sub-projects (service worker, …) in the same breath: the
2804
+ // root tsc excludes them, so a bare "tsc" would leave them unbuilt.
2805
+ const subs = configuredSubProjects(cwd, readConfig(cwd));
2806
+ const tscParts = ['tsc', ...subs.map(s => `tsc -p ${quoteScriptPath(s.dir)}`)];
2807
+ const newScript = [...(existingBuild ? [existingBuild] : []), ...tscParts].join(' && ');
2423
2808
  console.log(colors.yellow(`TypeScript project has no "build" script in ${pkg.name || cwd}`));
2424
- const addIt = await confirm(`Add "build": "tsc" to ${pkg.name || path.basename(cwd)}'s package.json?`, true);
2809
+ for (const s of subs)
2810
+ console.log(colors.yellow(` sub-project ${s.dir}/ — ${s.reason}`));
2811
+ const addIt = await confirm(`Add "build": "${newScript}" to ${pkg.name || path.basename(cwd)}'s package.json?`, true);
2425
2812
  if (addIt) {
2426
2813
  if (!pkg.scripts)
2427
2814
  pkg.scripts = {};
2428
- pkg.scripts.build = 'tsc';
2815
+ pkg.scripts.build = newScript;
2429
2816
  writePackageJson(cwd, pkg);
2430
- console.log(colors.green(`✓ Added "build": "tsc" to ${pkg.name || path.basename(cwd)}`));
2817
+ console.log(colors.green(`✓ Added "build": "${newScript}" to ${pkg.name || path.basename(cwd)}`));
2431
2818
  }
2432
2819
  else {
2433
2820
  return true;
2434
2821
  }
2435
2822
  }
2823
+ else if (existingBuild) {
2824
+ await ensureSubProjectsInBuild(cwd);
2825
+ try {
2826
+ const refreshed = readPackageJson(cwd);
2827
+ if (typeof refreshed.scripts?.build === 'string')
2828
+ pkg.scripts.build = refreshed.scripts.build;
2829
+ }
2830
+ catch { /* keep the script we already have */ }
2831
+ }
2436
2832
  ensureTsconfigNodeTypes(cwd);
2437
- if (!opts.forceBuild && isBuildUpToDate(cwd)) {
2833
+ // Only the sub-projects the final build script really compiles count toward
2834
+ // freshness — one the user declined to wire in isn't this build's business.
2835
+ const finalBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
2836
+ const wiredSubs = detectSubProjects(cwd).filter(s => scriptBuildsSubProject(finalBuild, s.dir));
2837
+ if (!opts.forceBuild && isBuildUpToDate(cwd) && areSubProjectsUpToDate(cwd, wiredSubs)
2838
+ && (!runsImportgen || isImportMapUpToDate(cwd))) {
2438
2839
  console.log(colors.dim(`– Build up to date (${pkg.name || path.basename(cwd)})`));
2439
2840
  return true;
2440
2841
  }
@@ -2870,18 +3271,142 @@ export function fixPuppeteerCorruptedCache(combined) {
2870
3271
  }
2871
3272
  return { repaired: [], removed: [] };
2872
3273
  }
2873
- /** npm 11.17+ gates lifecycle scripts behind `allowScripts` non-covered
2874
- * scripts are skipped and the install still exits 0. A skipped postinstall
2875
- * can leave the package broken at runtime (puppeteer without its browser,
2876
- * native modules unbuilt), so surface the warning instead of burying it in
2877
- * the captured output. */
2878
- function warnSkippedScripts(combined) {
3274
+ // ─── allowScripts policy: trust our own packages, gate everyone else ──
3275
+ //
3276
+ // npm 11.17+ skips install-time lifecycle scripts (preinstall/install/
3277
+ // postinstall) for packages that aren't on an allowlist, and a global
3278
+ // install has no project package.json to record approvals in — so a
3279
+ // postinstall we wrote ourselves silently never runs while the install
3280
+ // still exits 0. (@bobfrankston/msger's postinstall copies its native
3281
+ // binary into the per-user bin dir its launcher reads from; skipping it
3282
+ // leaves a stale exe behind a green checkmark.)
3283
+ //
3284
+ // npmglobalize only ever installs a package it just built and published
3285
+ // from the user's own source, so own-scope packages are trusted. Third-
3286
+ // party deps are not: they stay gated and keep surfacing through
3287
+ // warnSkippedScripts. That's deliberately narrower than a blanket
3288
+ // --dangerously-allow-all-scripts — it keeps the warning meaningful
3289
+ // instead of turning it into dead code.
3290
+ //
3291
+ // The policy matcher has no scope wildcard (`@scope/*` matches nothing —
3292
+ // see arborist's script-allowed.js), so the allowlist must name every
3293
+ // own-scope package in the tree explicitly.
3294
+ let _allowScriptsSupported = null;
3295
+ let _wslAllowScriptsSupported = null;
3296
+ /** Probe output for a config key npm doesn't define. */
3297
+ const isUnknownConfig = (out) => {
3298
+ const v = out.trim();
3299
+ return v === '' || v === 'undefined';
3300
+ };
3301
+ /** Does this npm know the allowScripts policy (11.17+)? Older npm runs all
3302
+ * install scripts unconditionally, and passing the flag there just prints
3303
+ * "Unknown cli config" on every install. Probed once per process.
3304
+ *
3305
+ * Probes the boolean sibling flag rather than `allow-scripts` itself: an
3306
+ * unset list config also prints empty, which is indistinguishable from
3307
+ * "not supported". Both flags landed in the same npm release. */
3308
+ function npmSupportsAllowScripts() {
3309
+ if (_allowScriptsSupported === null) {
3310
+ const r = runCommand('npm', ['config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3311
+ _allowScriptsSupported = r.success && !isUnknownConfig(r.output);
3312
+ }
3313
+ return _allowScriptsSupported;
3314
+ }
3315
+ /** Same probe against WSL's npm, which is a separate install and can lag
3316
+ * behind the Windows one. */
3317
+ async function wslNpmSupportsAllowScripts() {
3318
+ if (_wslAllowScriptsSupported === null) {
3319
+ const r = await runCommandAsync('wsl', ['npm', 'config', 'get', 'dangerously-allow-all-scripts'], { silent: true });
3320
+ _wslAllowScriptsSupported = r.success && !isUnknownConfig(r.output);
3321
+ }
3322
+ return _wslAllowScriptsSupported;
3323
+ }
3324
+ /** Every package in `dir`'s dependency closure that shares its scope — our
3325
+ * own packages, published by this same tool. Walks node_modules for
3326
+ * transitive own-scope deps, since a cascade dep can carry its own
3327
+ * postinstall and only its installed copy lists its dependencies.
3328
+ *
3329
+ * Returns [] for an unscoped package: there's no way to tell an unscoped
3330
+ * package of ours from a third-party one by name alone. */
3331
+ export function ownScopePackages(dir) {
3332
+ let pkg;
3333
+ try {
3334
+ pkg = readPackageJson(dir);
3335
+ }
3336
+ catch {
3337
+ return [];
3338
+ }
3339
+ const name = pkg.name || '';
3340
+ const slash = name.indexOf('/');
3341
+ if (!name.startsWith('@') || slash < 2)
3342
+ return [];
3343
+ const scope = name.slice(0, slash + 1); // '@bobfrankston/'
3344
+ // npm hoists, so a dep's own deps usually sit in the root node_modules
3345
+ // rather than nested under it — check both.
3346
+ const resolveDep = (dep, from) => {
3347
+ for (const cand of [path.join(from, 'node_modules', dep), path.join(dir, 'node_modules', dep)]) {
3348
+ if (fs.existsSync(path.join(cand, 'package.json')))
3349
+ return cand;
3350
+ }
3351
+ return null;
3352
+ };
3353
+ const found = new Set([name]);
3354
+ const walk = (manifest, from) => {
3355
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.optionalDependencies || {}) };
3356
+ for (const dep of Object.keys(deps)) {
3357
+ if (!dep.startsWith(scope) || found.has(dep))
3358
+ continue;
3359
+ found.add(dep);
3360
+ const depDir = resolveDep(dep, from);
3361
+ if (!depDir)
3362
+ continue; // not installed here — the name is still allowed
3363
+ try {
3364
+ walk(JSON.parse(fs.readFileSync(path.join(depDir, 'package.json'), 'utf-8')), depDir);
3365
+ }
3366
+ catch { /* unreadable dep manifest — the name is still allowed */ }
3367
+ }
3368
+ };
3369
+ walk(pkg, dir);
3370
+ return [...found];
3371
+ }
3372
+ /** npm args allowing install scripts for our own packages only. Empty when
3373
+ * npm predates the policy (it runs the scripts anyway) or the package is
3374
+ * unscoped.
3375
+ *
3376
+ * Note: the CLI layer *replaces* the .npmrc layer rather than merging with
3377
+ * it (first source with any config wins, per npm's resolve-allow-scripts),
3378
+ * so passing this suppresses any allow-scripts entries in .npmrc for this
3379
+ * install. Third-party approvals therefore belong on the command line too,
3380
+ * not in .npmrc. */
3381
+ function allowOwnScriptsArgs(dir) {
3382
+ if (!npmSupportsAllowScripts())
3383
+ return [];
3384
+ const own = ownScopePackages(dir);
3385
+ return own.length ? ['--allow-scripts', own.join(',')] : [];
3386
+ }
3387
+ /** A skipped postinstall can leave a package broken at runtime (puppeteer
3388
+ * without its browser, native modules unbuilt), so surface the warning
3389
+ * instead of burying it in the captured output. Own-scope packages are
3390
+ * reported separately and louder: those were supposed to be allowed, so
3391
+ * one showing up here means the allowlist missed it. */
3392
+ function warnSkippedScripts(combined, allowed = []) {
2879
3393
  const pkgs = [...combined.matchAll(/^npm warn allow-scripts\s+(\S+@[^\s(]+) \(/gm)];
2880
3394
  if (!pkgs.length)
2881
3395
  return;
2882
- const names = pkgs.map(m => m[1].replace(/@[^@]+$/, ''));
2883
- console.log(colors.yellow(` ⚠ npm skipped install scripts (allowScripts): ${pkgs.map(m => m[1]).join(', ')}`));
2884
- console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm config set allow-scripts=${names.join(',')} --location=user`));
3396
+ const nameOf = (spec) => spec.replace(/@[^@]+$/, '');
3397
+ const first = allowed[0] || '';
3398
+ const scope = first.startsWith('@') ? first.slice(0, first.indexOf('/') + 1) : '';
3399
+ const ours = scope ? pkgs.filter(m => nameOf(m[1]).startsWith(scope)) : [];
3400
+ const others = pkgs.filter(m => !ours.includes(m));
3401
+ if (ours.length) {
3402
+ console.log(colors.red(` ⚠ Own-scope install scripts still skipped: ${ours.map(m => m[1]).join(', ')}`));
3403
+ console.log(colors.red(` npmglobalize allowlisted ${allowed.length} own package(s) but missed these — likely transitive deps not present in the local node_modules tree.`));
3404
+ }
3405
+ if (others.length) {
3406
+ const names = [...new Set(others.map(m => nameOf(m[1])))];
3407
+ console.log(colors.yellow(` ⚠ npm skipped install scripts for third-party packages: ${others.map(m => m[1]).join(', ')}`));
3408
+ console.log(colors.yellow(` Package may be broken at runtime. Allow with: npm install -g <pkg> --allow-scripts ${names.join(',')}`));
3409
+ }
2885
3410
  }
2886
3411
  /** Run npm install -g with retries for registry propagation delay.
2887
3412
  * Brand-new packages (first-time publish) take much longer to become
@@ -2900,16 +3425,20 @@ async function installGlobalWithRetry(pkgSpec, cwd, isNewPackage = false, maxRet
2900
3425
  let result = { success: false, output: '', stderr: '' };
2901
3426
  let attempt = 0;
2902
3427
  let cacheFixRounds = 0;
3428
+ // Our own packages are trusted — we just built and published them from
3429
+ // this very directory — so their install scripts run. Everything else
3430
+ // stays behind npm's allowScripts gate.
3431
+ const allowArgs = allowOwnScriptsArgs(cwd);
2903
3432
  while (true) {
2904
- console.log(colors.cyan(`> npm install -g ${pkgSpec}`));
2905
- result = await runCommandAsync('npm', ['install', '-g', pkgSpec], { cwd, silent: true });
3433
+ console.log(colors.cyan(`> npm install -g ${pkgSpec}${allowArgs.length ? ` ${allowArgs.join(' ')}` : ''}`));
3434
+ result = await runCommandAsync('npm', ['install', '-g', pkgSpec, ...allowArgs], { cwd, silent: true });
2906
3435
  const combined = `${result.output}\n${result.stderr}`;
2907
3436
  fullLog += `===== attempt ${attempt + 1}: npm install -g ${pkgSpec} =====\n${combined}\n`;
2908
3437
  if (result.success) {
2909
3438
  const added = combined.match(/^(?:added|changed|removed) \d+ packages?.*$/m);
2910
3439
  if (added)
2911
3440
  console.log(colors.dim(` ${added[0]}`));
2912
- warnSkippedScripts(combined);
3441
+ warnSkippedScripts(combined, allowArgs.length ? allowArgs[1].split(',') : []);
2913
3442
  // A truncated puppeteer browser extraction exits 0, so the install
2914
3443
  // LOOKS fine while the cache is poisoned for every future install.
2915
3444
  // Verify now, while the leftover archive still allows a repair.
@@ -3147,6 +3676,14 @@ async function waitForNpmVersionInWsl(spec, maxWaitMs = 180000) {
3147
3676
  return false;
3148
3677
  }
3149
3678
  export async function installInWsl(wslArgs, opts = {}) {
3679
+ // Same trust rule as the Windows installs: allow our own packages'
3680
+ // install scripts, leave third-party ones gated. Probed against WSL's
3681
+ // npm, which is a separate install from the Windows one.
3682
+ if (opts.cwd && wslArgs.includes('install') && await wslNpmSupportsAllowScripts()) {
3683
+ const own = ownScopePackages(opts.cwd);
3684
+ if (own.length)
3685
+ wslArgs = [...wslArgs, '--allow-scripts', own.join(',')];
3686
+ }
3150
3687
  const runOnce = async () => {
3151
3688
  console.log(colors.cyan(`> wsl ${wslArgs.join(' ')}`));
3152
3689
  const r = await runCommandAsync('wsl', wslArgs, { cwd: opts.cwd, silent: true });
@@ -3634,29 +4171,39 @@ function getAuthInstructions() {
3634
4171
  return `
3635
4172
  Authentication Options:
3636
4173
 
3637
- 1. ${colors.yellow('Create a Granular Access Token')} (recommended):
4174
+ 1. ${colors.yellow('Interactive login')} (recommended):
4175
+ - Run: ${colors.green('npm login')}
4176
+ - Web-based: completes 2FA in the browser and writes the token for you.
4177
+ - The path that survives npm's token changes (see the note below).
4178
+
4179
+ 2. ${colors.yellow('Create a Granular Access Token')} (unattended use):
3638
4180
  - Go to: https://www.npmjs.com/settings/[username]/tokens
3639
4181
  - Click "Generate New Token" → "Granular Access Token"
3640
4182
  - Set permissions: ${colors.green('Read and write')} for packages
3641
- - Enable: ${colors.green('Bypass 2FA requirement')} (if available)
3642
- - Copy the token and save it securely
4183
+ - Scope it to the packages you actually publish, and set an expiry
4184
+ - ${colors.red('Do NOT enable "Bypass 2FA"')} — npm is retiring it:
4185
+ · since early Aug 2026, bypass tokens no longer skip 2FA for token,
4186
+ package-access, maintainer, org or team changes
4187
+ · around Jan 2027, they lose direct publish entirely (read + stage
4188
+ only; publication needs human 2FA approval)
3643
4189
 
3644
- 2. ${colors.yellow('Set token via environment variable')}:
4190
+ 3. ${colors.yellow('Set token via environment variable')}:
3645
4191
  - Set: ${colors.green('NPM_TOKEN=npm_xxx...')}
3646
4192
  - Or run: ${colors.green('$env:NPM_TOKEN="npm_xxx..."')} (PowerShell)
3647
4193
 
3648
- 3. ${colors.yellow('Set token in .npmrc')}:
4194
+ 4. ${colors.yellow('Set token in .npmrc')}:
3649
4195
  - Edit: ${colors.green(npmrcPath)}
3650
4196
  - Add: ${colors.green('//registry.npmjs.org/:_authToken=npm_xxx...')}
3651
4197
 
3652
- 4. ${colors.yellow('Use classic login')} (may require 2FA):
3653
- - Run: ${colors.green('npm login')}
3654
- - Follow interactive prompts
4198
+ 5. ${colors.yellow('Publishing from CI')}:
4199
+ - Prefer trusted publishing (OIDC) over any long-lived token — nothing to
4200
+ rotate, and it is the supported path once bypass tokens lose publish.
3655
4201
 
3656
4202
  ${colors.italic('Note: y:\\dev\\utils\\npmglobalize has set-npm-token.ps1 that may help fix token')}
3657
4203
  ${colors.italic(' problems, but no promises.')}
3658
4204
 
3659
- Note: npm now requires either 2FA or a granular token with bypass enabled.
4205
+ Note: unattended local publishing with a long-lived token is on a deprecation
4206
+ path. Plan for OIDC publishing from CI, or a publish step you approve with 2FA.
3660
4207
  `;
3661
4208
  }
3662
4209
  function loadIgnorePatterns() {
@@ -4588,7 +5135,7 @@ async function doLocalInstall(cwd, options) {
4588
5135
  console.log(' [dry-run] Would run: wsl npm install -g .');
4589
5136
  return true;
4590
5137
  }
4591
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5138
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
4592
5139
  if (result.success) {
4593
5140
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
4594
5141
  }
@@ -4599,7 +5146,7 @@ async function doLocalInstall(cwd, options) {
4599
5146
  }
4600
5147
  if (wsl) {
4601
5148
  console.log(`Installing ${pkgName} in WSL (local)...`);
4602
- const wslResult = await runCommandAsync('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5149
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
4603
5150
  if (wslResult.success) {
4604
5151
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
4605
5152
  }
@@ -4704,7 +5251,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
4704
5251
  console.log(' [dry-run] Would run: wsl npm install -g .');
4705
5252
  return true;
4706
5253
  }
4707
- const result = await runCommandAsync('npm', ['install', '-g', '.'], { cwd, silent: false, showCommand: true });
5254
+ const result = await runCommandAsync('npm', ['install', '-g', '.', ...allowOwnScriptsArgs(cwd)], { cwd, silent: false, showCommand: true });
4708
5255
  if (result.success) {
4709
5256
  console.log(colors.green(`✓ Installed locally: ${pkgName}@${pkgVersion}`));
4710
5257
  }
@@ -4715,7 +5262,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
4715
5262
  }
4716
5263
  if (wsl) {
4717
5264
  console.log(`Installing ${pkgName} in WSL (local)...`);
4718
- const wslResult = runCommand('wsl', ['npm', 'install', '-g', '.'], { cwd, silent: false, showCommand: true });
5265
+ const wslResult = await installInWsl(['npm', 'install', '-g', '.'], { cwd });
4719
5266
  if (wslResult.success) {
4720
5267
  console.log(colors.green(`✓ Installed in WSL: ${pkgName}@${pkgVersion}`));
4721
5268
  }
@@ -5200,8 +5747,10 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
5200
5747
  console.log(' [dry-run] Would run: npm run build');
5201
5748
  }
5202
5749
  }
5203
- // Run importgen if enabled (update import maps in HTML files)
5204
- if (options.importgen) {
5750
+ // Run importgen if explicitly enabled and the build script isn't already
5751
+ // doing it (the CLI wires it into `build` for detected importgen projects).
5752
+ const buildRunsImportgen = scriptRunsImportgen(typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '');
5753
+ if (options.importgen && !buildRunsImportgen) {
5205
5754
  try {
5206
5755
  if (!dryRun) {
5207
5756
  const igResult = runImportgen(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.195",
3
+ "version": "1.0.197",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@bobfrankston/freezepak": "^0.1.9",
35
- "@bobfrankston/importgen": "^0.1.38",
35
+ "@bobfrankston/importgen": "^0.1.39",
36
36
  "@bobfrankston/themecolors": "^0.1.8",
37
37
  "@bobfrankston/userconfig": "^1.0.10",
38
38
  "@npmcli/package-json": "^7.0.4",
@@ -60,7 +60,7 @@
60
60
  ".transformedSnapshot": {
61
61
  "dependencies": {
62
62
  "@bobfrankston/freezepak": "^0.1.9",
63
- "@bobfrankston/importgen": "^0.1.38",
63
+ "@bobfrankston/importgen": "^0.1.39",
64
64
  "@bobfrankston/themecolors": "^0.1.8",
65
65
  "@bobfrankston/userconfig": "^1.0.10",
66
66
  "@npmcli/package-json": "^7.0.4",