@bobfrankston/npmglobalize 1.0.194 → 1.0.195

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 +13 -0
  2. package/cli.js +15 -6
  3. package/lib.d.ts +17 -1
  4. package/lib.js +177 -22
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -454,6 +454,19 @@ Workspace mode is auto-detected when run from a root with `"private": true` and
454
454
  target. Fixes arborist "Cannot read properties of null"
455
455
  crashes caused by sibling file: deps with nested
456
456
  node_modules. Suggested automatically when the error hits.
457
+ -ts7-report, -deprecation-report
458
+ Report-only: scan this package and its file: deps for
459
+ compilerOptions removed in TypeScript 7 and list a migration
460
+ to-do. Writes nothing.
461
+ -tsfix, -ts7-fix
462
+ One-off utility, NOT part of the main flow: apply the TS7
463
+ tsconfig migration to a package (and its file: deps) and exit —
464
+ no build, no commit, no push, no publish. Intended as a
465
+ temporary tool for fixing a local/subdirectory tsconfig in
466
+ place. The normal release flow already applies the same
467
+ migration automatically when a build hits a TS7 deprecation
468
+ error, so day-to-day you never need this flag.
469
+ npmglobalize <path> -tsfix
457
470
  -show Show package.json dependency changes
458
471
  -package, -pkg Update package.json scripts to use npmglobalize (see below)
459
472
  -h, -help Show help
package/cli.js CHANGED
@@ -56,6 +56,9 @@ Dependency Options:
56
56
  public visibility to already-published deps in the cascade.
57
57
  Use when a public consumer's deps weren't all marked public.
58
58
  -no-prescan, -nps Skip upfront dep-graph prescan
59
+ -force-build, -fb Rebuild file: deps even when their output looks up
60
+ to date (freshness check compares .ts/tsconfig
61
+ mtimes against emitted .js/.d.ts)
59
62
  -force-publish Republish dependencies even if version exists
60
63
  -fix Run npm audit fix after transformation
61
64
  -no-use-paths, -nup Declare this package standalone — file: deps shouldn't
@@ -122,10 +125,12 @@ Other Options:
122
125
  moduleResolution, target es3, deprecated flags). Lists a
123
126
  migration to-do list and exits. Writes nothing.
124
127
  -tsfix, -ts7-fix
125
- Fix-only: apply the TS7 tsconfig migration to this package
126
- and its file: deps, then exit. Only tsconfig files change —
127
- no build, no commit, no push, no publish. Use on any local
128
- directory: npmglobalize <path> -tsfix
128
+ One-off utility, not the main flow: apply the TS7 tsconfig
129
+ migration to this package and its file: deps, then exit.
130
+ Only tsconfig files change — no build, no commit, no push,
131
+ no publish. The release flow auto-applies the same migration
132
+ on a TS7 build error; use this only for a temporary in-place
133
+ fix of a local directory: npmglobalize <path> -tsfix
129
134
  -show Show package.json dependency changes
130
135
  -package, -pkg Update package.json scripts to use npmglobalize
131
136
  -h, -help Show this help
@@ -243,6 +248,10 @@ function parseArgs(args) {
243
248
  case '-force':
244
249
  options.force = true;
245
250
  break;
251
+ case '-force-build':
252
+ case '-fb':
253
+ options.forceBuild = true;
254
+ break;
246
255
  case '-files':
247
256
  options.files = true;
248
257
  options.explicitKeys.add('files');
@@ -555,14 +564,14 @@ export async function main() {
555
564
  // whose source has changed since their last build.
556
565
  if (!cliOptions.cleanup) {
557
566
  await ensureFileDepModules(cwd, !!cliOptions.verbose);
558
- const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force });
567
+ const depsOk = await buildFileDepsTopologically(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
559
568
  if (!depsOk && !cliOptions.force) {
560
569
  printBuildSummary();
561
570
  process.exit(1);
562
571
  }
563
572
  if (!depsOk)
564
573
  console.log(colors.warn('Continuing with --force despite dep build failure...'));
565
- const targetOk = await buildProject(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force });
574
+ const targetOk = await buildProject(cwd, { verbose: !!cliOptions.verbose, force: !!cliOptions.force, forceBuild: !!cliOptions.forceBuild });
566
575
  if (!targetOk) {
567
576
  if (!cliOptions.force) {
568
577
  printBuildSummary();
package/lib.d.ts CHANGED
@@ -108,6 +108,8 @@ export interface GlobalizeOptions {
108
108
  usePaths?: boolean;
109
109
  /** Local install only — skip transform/publish, just npm install -g . */
110
110
  local?: boolean;
111
+ /** Build every file: dep even when outputs look up to date (skips the freshness check) */
112
+ forceBuild?: boolean;
111
113
  /** Freeze node_modules: replace symlinks/junctions with real copies for network share use */
112
114
  freeze?: boolean;
113
115
  /** Before `npm pack`, delete `node_modules/` inside each `file:` dep target.
@@ -311,13 +313,26 @@ export declare function missingDeps(pkgDir: string, pkg: any): string[];
311
313
  * re-run" (partial-sync) cases. Also covers `cwd` itself on the first call.
312
314
  * Cycle-safe via the shared `visited` set. */
313
315
  export declare function ensureFileDepModules(cwd: string, verbose?: boolean, visited?: Set<string>): Promise<void>;
316
+ /** Cheap freshness check so the build cascade can skip packages whose output is
317
+ * already current. Returns true only when provably up to date:
318
+ * - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
319
+ * not be newer than the newest file under outDir.
320
+ * - side-by-side projects: every source must have an emitted sibling
321
+ * (.js/.jsx/.mjs/.cjs or declaration) at least as new.
322
+ * Conservative: project references, allowJs, missing outputs, or unreadable
323
+ * tsconfig all report stale (→ build). package.json mtime is deliberately
324
+ * ignored — npmglobalize itself rewrites it around every publish, which would
325
+ * otherwise force a rebuild on every run. */
326
+ export declare function isBuildUpToDate(cwd: string): boolean;
314
327
  /** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
315
328
  * TypeScript project lacks a build script, run `npm run build`, record
316
329
  * failures. Returns true if build succeeded (or was skipped because no
317
- * tsconfig / noEmit / no build script after declining the prompt). */
330
+ * tsconfig / noEmit / no build script after declining the prompt / output
331
+ * already up to date). Pass `forceBuild` to skip the freshness check. */
318
332
  export declare function buildProject(cwd: string, opts?: {
319
333
  verbose?: boolean;
320
334
  force?: boolean;
335
+ forceBuild?: boolean;
321
336
  }): Promise<boolean>;
322
337
  /** Walk `file:` deps depth-first (deps before consumers) and build each one
323
338
  * that has a tsconfig. Mirrors `ensureFileDepModules`'s traversal but invokes
@@ -327,6 +342,7 @@ export declare function buildProject(cwd: string, opts?: {
327
342
  export declare function buildFileDepsTopologically(cwd: string, opts?: {
328
343
  verbose?: boolean;
329
344
  force?: boolean;
345
+ forceBuild?: boolean;
330
346
  }, visited?: Set<string>): Promise<boolean>;
331
347
  /** A single TS7-readiness problem found in a package's effective tsconfig. */
332
348
  export interface Ts7Finding {
package/lib.js CHANGED
@@ -1720,6 +1720,41 @@ export function printPnpmSuggestionSummary() {
1720
1720
  console.error(l);
1721
1721
  console.error('');
1722
1722
  }
1723
+ /** Run `npm install` in `dir`; on failure, offer to delete `node_modules/`
1724
+ * and `package-lock.json` and retry once — the standard fix for arborist's
1725
+ * null-`package` crash on stale lockfile / junction state — before falling
1726
+ * back to the pnpm suggestion banner. Returns true if install succeeded.
1727
+ * `rmSync` unlinks junctions without descending, so file: sibling targets
1728
+ * are never touched. */
1729
+ async function npmInstallWithCleanRetry(dir, verbose, context) {
1730
+ let r = await runCommandAsync('npm', ['install'], { cwd: dir, silent: !verbose });
1731
+ if (r.success)
1732
+ return true;
1733
+ console.error(colors.red(` ✗ npm install failed in ${dir}`));
1734
+ if (r.stderr)
1735
+ console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
1736
+ if (await confirm(` Delete node_modules/ and package-lock.json in ${dir} and retry npm install?`, true)) {
1737
+ try {
1738
+ fs.rmSync(path.join(dir, 'node_modules'), { recursive: true, force: true });
1739
+ }
1740
+ catch { /* best-effort */ }
1741
+ try {
1742
+ fs.rmSync(path.join(dir, 'package-lock.json'), { force: true });
1743
+ }
1744
+ catch { /* best-effort */ }
1745
+ console.log(colors.yellow(`↻ clean reinstall in ${dir}`));
1746
+ r = await runCommandAsync('npm', ['install'], { cwd: dir, silent: !verbose });
1747
+ if (r.success) {
1748
+ console.log(colors.green(` ✓ clean reinstall succeeded in ${dir}`));
1749
+ return true;
1750
+ }
1751
+ console.error(colors.red(` ✗ npm install still failing in ${dir} after clean reinstall`));
1752
+ if (r.stderr)
1753
+ console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
1754
+ }
1755
+ await suggestPnpmOnInstallFailure(context);
1756
+ return false;
1757
+ }
1723
1758
  /** Wait for a package version to appear on the npm registry.
1724
1759
  * First-time publishes (brand-new package name) take much longer to
1725
1760
  * propagate than version bumps — npm has no cached metadata to update,
@@ -1877,13 +1912,7 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
1877
1912
  catch { /* best-effort */ }
1878
1913
  }
1879
1914
  console.log(colors.yellow(`↻ installing node_modules in ${pkg?.name || abs} (${formatMissingReason(cwdMissing)})`));
1880
- const r = await runCommandAsync('npm', ['install'], { cwd: abs, silent: !verbose });
1881
- if (!r.success) {
1882
- console.error(colors.red(` ✗ npm install failed in ${abs}`));
1883
- if (r.stderr)
1884
- console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
1885
- await suggestPnpmOnInstallFailure(`ensureFileDepModules (cwd): ${abs}`);
1886
- }
1915
+ await npmInstallWithCleanRetry(abs, verbose, `ensureFileDepModules (cwd): ${abs}`);
1887
1916
  }
1888
1917
  for (const key of ['dependencies', 'devDependencies']) {
1889
1918
  const deps = pkg?.[key];
@@ -1915,13 +1944,7 @@ export async function ensureFileDepModules(cwd, verbose = false, visited = new S
1915
1944
  catch { /* best-effort */ }
1916
1945
  }
1917
1946
  console.log(colors.yellow(`↻ restoring node_modules in ${name} (${target}) (${formatMissingReason(targetMissing)})`));
1918
- const r = await runCommandAsync('npm', ['install'], { cwd: target, silent: !verbose });
1919
- if (!r.success) {
1920
- console.error(colors.red(` ✗ npm install failed in ${target}`));
1921
- if (r.stderr)
1922
- console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
1923
- await suggestPnpmOnInstallFailure(`ensureFileDepModules (file: dep ${name}): ${target}`);
1924
- }
1947
+ await npmInstallWithCleanRetry(target, verbose, `ensureFileDepModules (file: dep ${name}): ${target}`);
1925
1948
  }
1926
1949
  await ensureFileDepModules(target, verbose, visited);
1927
1950
  }
@@ -2244,10 +2267,144 @@ function migrateTsconfigDeprecations(cwd) {
2244
2267
  }
2245
2268
  return changed;
2246
2269
  }
2270
+ /** Newest mtime of any regular file under `dir` (recursive). Symlinks/junctions
2271
+ * are not followed — the file: dep webs here link back into sibling checkouts. */
2272
+ function newestMtimeUnder(dir) {
2273
+ let newest = 0;
2274
+ let entries;
2275
+ try {
2276
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2277
+ }
2278
+ catch {
2279
+ return 0;
2280
+ }
2281
+ for (const e of entries) {
2282
+ if (e.isSymbolicLink())
2283
+ continue;
2284
+ const full = path.join(dir, e.name);
2285
+ if (e.isDirectory()) {
2286
+ newest = Math.max(newest, newestMtimeUnder(full));
2287
+ }
2288
+ else if (e.isFile()) {
2289
+ try {
2290
+ newest = Math.max(newest, fs.statSync(full).mtimeMs);
2291
+ }
2292
+ catch { /* ignore */ }
2293
+ }
2294
+ }
2295
+ return newest;
2296
+ }
2297
+ /** Cheap freshness check so the build cascade can skip packages whose output is
2298
+ * already current. Returns true only when provably up to date:
2299
+ * - outDir projects: newest source (.ts/.tsx/.mts/.cts + tsconfig*.json) must
2300
+ * not be newer than the newest file under outDir.
2301
+ * - side-by-side projects: every source must have an emitted sibling
2302
+ * (.js/.jsx/.mjs/.cjs or declaration) at least as new.
2303
+ * Conservative: project references, allowJs, missing outputs, or unreadable
2304
+ * tsconfig all report stale (→ build). package.json mtime is deliberately
2305
+ * ignored — npmglobalize itself rewrites it around every publish, which would
2306
+ * otherwise force a rebuild on every run. */
2307
+ export function isBuildUpToDate(cwd) {
2308
+ let tsconfig;
2309
+ try {
2310
+ tsconfig = JSON5.parse(fs.readFileSync(path.join(cwd, 'tsconfig.json'), 'utf-8'));
2311
+ }
2312
+ catch {
2313
+ return false;
2314
+ }
2315
+ const co = tsconfig.compilerOptions || {};
2316
+ if (tsconfig.references || co.allowJs || co.checkJs)
2317
+ return false;
2318
+ const outDir = typeof co.outDir === 'string' ? path.resolve(cwd, co.outDir) : null;
2319
+ // tsconfig "exclude" dirs aren't compiled, so their .ts files never get
2320
+ // outputs — don't count them as sources. Match on the literal path prefix
2321
+ // (glob tails like "tests/**" reduce to "tests").
2322
+ const excludes = (Array.isArray(tsconfig.exclude) ? tsconfig.exclude : [])
2323
+ .filter((x) => typeof x === 'string')
2324
+ .map((x) => x.replace(/^\.\//, '').replace(/[\\/]?\*.*$/, '').replace(/[\\/]+$/, '').toLowerCase())
2325
+ .filter((x) => x.length > 0 && !x.includes('*'));
2326
+ const sources = [];
2327
+ const collect = (dir, rel) => {
2328
+ let entries;
2329
+ try {
2330
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2331
+ }
2332
+ catch {
2333
+ return;
2334
+ }
2335
+ for (const e of entries) {
2336
+ if (e.isSymbolicLink())
2337
+ continue;
2338
+ const full = path.join(dir, e.name);
2339
+ const relPath = rel ? `${rel}/${e.name.toLowerCase()}` : e.name.toLowerCase();
2340
+ if (e.isDirectory()) {
2341
+ const lower = e.name.toLowerCase();
2342
+ if (lower === 'node_modules' || lower === 'prev' || e.name.startsWith('.'))
2343
+ continue;
2344
+ if (outDir && path.resolve(full) === outDir)
2345
+ continue;
2346
+ if (excludes.includes(relPath))
2347
+ continue;
2348
+ collect(full, relPath);
2349
+ }
2350
+ else if (/\.(ts|tsx|mts|cts)$/.test(e.name) && !/\.d\.(ts|mts|cts)$/.test(e.name)) {
2351
+ if (excludes.includes(relPath))
2352
+ continue;
2353
+ sources.push(full);
2354
+ }
2355
+ }
2356
+ };
2357
+ collect(cwd, '');
2358
+ if (sources.length === 0)
2359
+ return true;
2360
+ const mtime = (p) => {
2361
+ try {
2362
+ return fs.statSync(p).mtimeMs;
2363
+ }
2364
+ catch {
2365
+ return -1;
2366
+ }
2367
+ };
2368
+ // Any tsconfig*.json edit (own or extended base in the package) invalidates the build.
2369
+ let configTime = 0;
2370
+ try {
2371
+ for (const name of fs.readdirSync(cwd)) {
2372
+ if (/^tsconfig.*\.json$/.test(name))
2373
+ configTime = Math.max(configTime, mtime(path.join(cwd, name)));
2374
+ }
2375
+ }
2376
+ catch { /* ignore */ }
2377
+ if (outDir) {
2378
+ const newestOut = newestMtimeUnder(outDir);
2379
+ if (newestOut === 0)
2380
+ return false;
2381
+ const newestSrc = Math.max(configTime, ...sources.map(mtime));
2382
+ return newestSrc <= newestOut;
2383
+ }
2384
+ // Side-by-side emit: each source needs an output sibling at least as new.
2385
+ let newestOut = 0;
2386
+ for (const src of sources) {
2387
+ const m = src.match(/\.(ts|tsx|mts|cts)$/);
2388
+ const base = src.slice(0, -m[0].length);
2389
+ const candidates = m[1] === 'mts' ? ['.mjs', '.d.mts']
2390
+ : m[1] === 'cts' ? ['.cjs', '.d.cts']
2391
+ : ['.js', '.jsx', '.d.ts'];
2392
+ let outTime = -1;
2393
+ for (const ext of candidates)
2394
+ outTime = Math.max(outTime, mtime(base + ext));
2395
+ if (outTime < 0)
2396
+ return false;
2397
+ if (mtime(src) > outTime)
2398
+ return false;
2399
+ newestOut = Math.max(newestOut, outTime);
2400
+ }
2401
+ return configTime <= newestOut;
2402
+ }
2247
2403
  /** Build a single project: detect tsconfig, prompt to add `build: tsc` if a
2248
2404
  * TypeScript project lacks a build script, run `npm run build`, record
2249
2405
  * failures. Returns true if build succeeded (or was skipped because no
2250
- * tsconfig / noEmit / no build script after declining the prompt). */
2406
+ * tsconfig / noEmit / no build script after declining the prompt / output
2407
+ * already up to date). Pass `forceBuild` to skip the freshness check. */
2251
2408
  export async function buildProject(cwd, opts = {}) {
2252
2409
  let shouldBuild = false;
2253
2410
  try {
@@ -2277,6 +2434,10 @@ export async function buildProject(cwd, opts = {}) {
2277
2434
  }
2278
2435
  }
2279
2436
  ensureTsconfigNodeTypes(cwd);
2437
+ if (!opts.forceBuild && isBuildUpToDate(cwd)) {
2438
+ console.log(colors.dim(`– Build up to date (${pkg.name || path.basename(cwd)})`));
2439
+ return true;
2440
+ }
2280
2441
  console.log(`Building ${pkg.name || cwd}...`);
2281
2442
  let buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
2282
2443
  if (!buildResult.success) {
@@ -2549,13 +2710,7 @@ export async function ensureWorkspaceDepModules(rootDir, members, verbose = fals
2549
2710
  return;
2550
2711
  const list = [...allMissing];
2551
2712
  console.log(colors.yellow(`↻ installing workspace node_modules in ${rootPkg?.name || path.basename(root)} (${formatMissingReason(list)})`));
2552
- const r = await runCommandAsync('npm', ['install'], { cwd: root, silent: !verbose });
2553
- if (!r.success) {
2554
- console.error(colors.red(` ✗ npm install failed in ${root}`));
2555
- if (r.stderr)
2556
- console.error(colors.dim(r.stderr.split('\n').slice(0, 5).join('\n')));
2557
- await suggestPnpmOnInstallFailure(`ensureWorkspaceDepModules: ${root}`);
2558
- }
2713
+ await npmInstallWithCleanRetry(root, verbose, `ensureWorkspaceDepModules: ${root}`);
2559
2714
  }
2560
2715
  /** Extract the lines a human needs from a failed npm command's output: the
2561
2716
  * `npm error` lines minus stack frames and brace-only object-dump fragments,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.194",
3
+ "version": "1.0.195",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",