@bobfrankston/npmglobalize 1.0.232 → 1.0.234

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 (4) hide show
  1. package/README.md +30 -2
  2. package/lib.d.ts +39 -3
  3. package/lib.js +205 -43
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -94,6 +94,8 @@ Errors abort (unless `--force`); warnings prompt to continue.
94
94
 
95
95
  An unscoped dependency with private intent is reported differently: it is not a question. npmglobalize never renames a dependency to add a scope (the name is what every consumer imports by, and changing it is the dep owner's own project), so the dep cannot reach npm and neither can the package that depends on it. The first run says so in full, names the dep and its path, records it under `unscopedDepsNoted` in the consumer's `.globalize.json5`, and stops. Later runs print one line per dep and stop. Nothing is asked, because no answer could change the outcome. Fix it in the dep: scope the name in its own `package.json`, or set `npmVisibility: "public"` or `noprefix: true` in its `.globalize.json5`; the note clears itself on the next run. If the cascade reaches such a dep anyway (`-no-prescan`), it stops there with the same message. The same holds when you run npmglobalize *in* an unscoped package: it used to ask "Add scope to package name?", and now it warns and assumes no, since a rename changes what every consumer imports. Publishing an unscoped package PUBLIC on purpose takes an explicit `-npm public` or `noprefix: true`. (2026-09-14)
96
96
 
97
+ **Own-scope packages installed from npm instead of linked.** When a package in the `file:` graph depends on one of its own-scope packages by version range (`"@bobfrankston/miscassists": "^1.0.61"`) rather than `file:`, npm puts a registry copy in that package's `node_modules` instead of a link to the local source. The consumer is usually nested under other `file:` deps, where neither the `+` list nor the dependency tree shows it, so the prescan lists every such edge on every run: consumer → dep and range, the `package.json` that holds the range, and — when the same dep is linked as `file:` elsewhere in the graph — the directory of its local source (that package then exists twice). Each finding is repeated as one line in the end-of-run **Issues Summary**, because the prescan block has scrolled away by the time a cascade finishes. Nothing is asked and the run continues; a range can be deliberate. A package with `"files": false` in its `.globalize.json5` keeps npm refs on purpose and is not listed. npmglobalize does not rewrite the range: change it to a `file:` path in the named `package.json`, then run `npmglobalize` in that package. (2026-09-21)
98
+
97
99
  Skip the prescan with `-no-prescan` / `-nps`.
98
100
 
99
101
  #### Workspace skip prescan
@@ -347,6 +349,8 @@ For single-developer projects, this safely pulls remote changes before publishin
347
349
 
348
350
  **GitHub Push Protection (GH013)**: When GitHub's secret scanning blocks a push, `npmglobalize` detects the error and checks whether the flagged secrets are actually safe. For Google OAuth desktop/installed app credentials (where the "client_secret" is just a public app identifier, not a real secret), it automatically bypasses push protection via the GitHub API (`gh` CLI required) and retries the push. For other secret types, it displays the unblock URLs with guidance.
349
351
 
352
+ **Stale git lock files**: A git that dies mid-write leaves its lock behind (`.git/config.lock`, `.git/index.lock`), and every later git refuses with `could not lock config file …: File exists` or `Unable to create '….lock': File exists`. When a git command fails that way and the lock is more than 10 seconds old, `npmglobalize` deletes the lock, names the file it deleted, and retries the command once. A younger lock may belong to a git that is writing right now, so it is left alone and the failure is reported with the lock's path and age. (2026-09-21)
353
+
350
354
  **Note:** This tool is designed for single-developer, single-branch workflows where automatic rebase and tag cleanup are safe operations.
351
355
 
352
356
  ### 📂 Git Repository Setup
@@ -804,8 +808,7 @@ npmglobalize --dry-run # See what would happen
804
808
 
805
809
  ### Build Cascade
806
810
 
807
- 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%
808
- 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.
811
+ 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%\npmglobalize-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.
809
812
 
810
813
  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.
811
814
 
@@ -815,6 +818,13 @@ For each project visited (the target and every transitive `file:` dep):
815
818
  - If `tsconfig.json` exists but `package.json` has no `"build"` script → **add** `"build": "tsc"` without asking (2026-09-14; it used to prompt) (plus a `tsc -p <dir>` per sub-project — see below). Decline and that project is skipped.
816
819
  - Otherwise → run `npm run build`. A failure halts the cascade unless `-force` is passed.
817
820
 
821
+ When a build fails, every tsc diagnostic is printed with the file's **absolute path**
822
+ (`Y:\dev\utils\pings\index.ts(23,27): error TS2307: …`) and the failure line names the
823
+ directory the build ran in (`Build failed in @bobfrankston/pings (Y:\dev\utils\pings)`).
824
+ tsc itself prints paths relative to its working directory, which in a cascade is one of
825
+ many dep directories, so a bare `index.ts` would not say which package to open. The
826
+ Issues Summary carries the same absolute path. (2026-09-14)
827
+
818
828
  Cycle-safe via a shared visited set; each project is built at most once per run.
819
829
 
820
830
  This complements the existing publish cascade (which ensures version refs are correct) by closing the build-freshness gap that `npm install` alone left open.
@@ -869,6 +879,24 @@ Before each build, when the global `tsc` is version 6 or newer, `npmglobalize` p
869
879
 
870
880
  If the patch can't be applied for some reason and the build still fails with `TS2591`, the failure summary prints a hint to add `"types": ["node"]` manually.
871
881
 
882
+ ### `nul` Files Are Deleted Before Staging
883
+
884
+ A file literally named `nul` is a dropping from `>nul` run under a shell that creates the
885
+ file instead of discarding the output. Git on Windows cannot index one — `git add` stops with
886
+ `error: invalid path 'nul'` / `unable to add 'nul' to index` (or `short read while indexing nul`).
887
+
888
+ Every `git add -A` that `npmglobalize` runs — the pre-release commit, `-init`, the index
889
+ rebuild, and the pre-version cleanups — first deletes any `nul` file in the tree (any case;
890
+ `node_modules/` is not searched) and names each one it deleted:
891
+
892
+ ```
893
+ Deleted 'nul' file Y:\dev\utils\pings\nul (Windows reserved name — git cannot add it)
894
+ ```
895
+
896
+ A `nul` file that cannot be deleted is reported with the reason, since the `git add` that
897
+ follows will fail on it. (2026-09-20; before that only the pre-release commit did the
898
+ cleanup, silently.)
899
+
872
900
  ### The `.dependencies` Backup (Internal/Transient)
873
901
 
874
902
  **You should never see `.dependencies` in your `package.json` under normal operation.** It is a temporary internal backup that exists only during the brief publish cycle and is removed automatically when the cycle completes.
package/lib.d.ts CHANGED
@@ -29,6 +29,15 @@ export declare function extractFirstTscError(output: string): string | null;
29
29
  * — in order, deduplicated, untruncated. For reports where the reader will act on each
30
30
  * line (the imports a nodenext migration surfaces), as opposed to the one-line summary
31
31
  * extractFirstTscError gives. (2026-09-14) */
32
+ /** Rewrite tsc's cwd-relative file paths to absolute ones, in both of tsc's
33
+ * diagnostic shapes: `index.ts(23,27): error TS2307: …` and the pretty form
34
+ * `index.ts:23:27 - error TS2307: …`. tsc prints paths relative to the directory
35
+ * it ran in, and in a cascade that is one of many dep directories, so a bare
36
+ * `index.ts` says nothing about which package's file is broken.
37
+ * 2026-09-14 15:40 EDT — Claude Code (Fable 5.1), at Bob's direction: "the error
38
+ * messages should be preceded by the path to look at" — a relative `index.ts:23`
39
+ * from a dep build cost a long hunt across pings and its interfaces dep. */
40
+ export declare function anchorTscPaths(output: string, cwd: string): string;
32
41
  export declare function extractTscErrors(output: string): string[];
33
42
  /** One relative import rewritten by fixImportExtensions. */
34
43
  export interface ImportExtensionFix {
@@ -86,6 +95,17 @@ export declare function fixImportExtensions(cwd: string, output: string, opts?:
86
95
  * Recognize the shape and say what actually fixes it.
87
96
  * Returns one diagnosis line per untyped module; empty when TS7016 has another cause. */
88
97
  export declare function diagnoseUntypedDeps(cwd: string, buildOutput: string): string[];
98
+ /** Stage everything (`git add -A`), first deleting any 'nul' files in the tree.
99
+ * A 'nul' file is a dropping from `>nul` run under a shell that creates it instead of discarding
100
+ * it; git on Windows cannot index one ("invalid path 'nul'" / "short read while indexing nul").
101
+ * Every `git add -A` goes through this one function so no staging path can miss the cleanup
102
+ * (2026-09-20, Claude Code: only the pre-release commit did it; -init, the index rebuild and the
103
+ * two pre-version cleanups did not). */
104
+ export declare function gitAddAll(cwd: string): {
105
+ success: boolean;
106
+ output: string;
107
+ stderr: string;
108
+ };
89
109
  /** One package that depends on this one, recorded in this package's
90
110
  * .globalize.json5 when that package publishes. */
91
111
  export interface UpstreamEntry {
@@ -403,10 +423,17 @@ export declare function transformDeps(pkg: any, baseDir: string, verbose?: boole
403
423
  export interface PrescanIssue {
404
424
  severity: 'error' | 'warning';
405
425
  /** 'unscoped': an unscoped file: dep with private intent. Not an error and not a
406
- * question — globalize reports it once, remembers it, and stops. */
407
- kind?: 'unscoped';
426
+ * question — globalize reports it once, remembers it, and stops.
427
+ * 'registryRef': a package in the file: graph depends on one of its own-scope packages
428
+ * by version range instead of file: — reported by reportRegistryRefs, nothing asked. */
429
+ kind?: 'unscoped' | 'registryRef';
408
430
  package: string;
431
+ /** For 'registryRef': the directory of the CONSUMER whose package.json holds the range. */
409
432
  path: string;
433
+ /** 'registryRef' only: name of the package whose package.json declares the dep. */
434
+ consumer?: string;
435
+ /** 'registryRef' only: the spec as written (`^1.0.61`). */
436
+ range?: string;
410
437
  message: string;
411
438
  suggestion?: string;
412
439
  }
@@ -420,7 +447,16 @@ export declare function findUndeclaredImports(cwd: string, pkg: any): Undeclared
420
447
  * unresolvable paths, missing package.json, unpublished transitives. Lets the
421
448
  * user resolve all problems before starting the publish cascade instead of
422
449
  * being interrupted mid-run. */
423
- export declare function prescanDepGraph(baseDir: string, visited?: Set<string>, verbose?: boolean): PrescanIssue[];
450
+ export declare function prescanDepGraph(baseDir: string, visited?: Set<string>, verbose?: boolean, fileTargets?: Map<string, string>): PrescanIssue[];
451
+ /** Report own-scope packages that some package in the file: graph takes from the registry
452
+ * (a version range) instead of from local source. Nothing is asked and the run continues: a
453
+ * range can be deliberate, and the fix is an edit to the CONSUMER's package.json, which is not
454
+ * npmglobalize's to make. It is printed on every run because the consumer is usually nested
455
+ * under other file: deps, where neither the `+` list nor the dependency tree shows it.
456
+ * `fileTargets` is the map prescanDepGraph filled: when the same package is linked as file:
457
+ * elsewhere in the graph, its directory is named — that package then exists twice, the linked
458
+ * source and the registry copy in the consumer's node_modules. */
459
+ export declare function reportRegistryRefs(registryRefs: PrescanIssue[], fileTargets: Map<string, string>): void;
424
460
  /** Report unscoped `file:` deps found by the prescan, without asking anything.
425
461
  *
426
462
  * An unscoped dep with private intent cannot reach npm, and npmglobalize does not
package/lib.js CHANGED
@@ -82,6 +82,23 @@ export function extractFirstTscError(output) {
82
82
  * — in order, deduplicated, untruncated. For reports where the reader will act on each
83
83
  * line (the imports a nodenext migration surfaces), as opposed to the one-line summary
84
84
  * extractFirstTscError gives. (2026-09-14) */
85
+ /** Rewrite tsc's cwd-relative file paths to absolute ones, in both of tsc's
86
+ * diagnostic shapes: `index.ts(23,27): error TS2307: …` and the pretty form
87
+ * `index.ts:23:27 - error TS2307: …`. tsc prints paths relative to the directory
88
+ * it ran in, and in a cascade that is one of many dep directories, so a bare
89
+ * `index.ts` says nothing about which package's file is broken.
90
+ * 2026-09-14 15:40 EDT — Claude Code (Fable 5.1), at Bob's direction: "the error
91
+ * messages should be preceded by the path to look at" — a relative `index.ts:23`
92
+ * from a dep build cost a long hunt across pings and its interfaces dep. */
93
+ export function anchorTscPaths(output, cwd) {
94
+ if (!output)
95
+ return output;
96
+ return output.replace(/^(.+?)((?:\(\d+,\d+\):|:\d+:\d+ -) error TS\d+: )/gm, (whole, file, rest) => {
97
+ if (path.isAbsolute(file))
98
+ return whole;
99
+ return path.resolve(cwd, file) + rest;
100
+ });
101
+ }
85
102
  export function extractTscErrors(output) {
86
103
  if (!output)
87
104
  return [];
@@ -364,34 +381,53 @@ export function diagnoseUntypedDeps(cwd, buildOutput) {
364
381
  * Remove 'nul' files from a directory tree (Windows reserved name issue).
365
382
  * These files break git and npm on Windows. Uses \\?\ prefix to bypass name validation.
366
383
  */
384
+ // 2026-09-20 — Claude Code (Fable 5.1), at Bob's direction ("you should handle 'nul' droppings
385
+ // gracefully by deleting them", after `error: invalid path 'nul'` / `unable to add 'nul' to index`
386
+ // stopped a run). Returns the paths it deleted instead of a count so the caller can NAME them,
387
+ // matches the name case-insensitively (NUL is reserved in any case), and a failed delete is now
388
+ // reported instead of swallowed -- it is the reason the following `git add -A` will fail.
367
389
  function removeNulFiles(dir, visited = new Set()) {
368
390
  const resolved = path.resolve(dir);
369
391
  if (visited.has(resolved))
370
- return 0;
392
+ return [];
371
393
  visited.add(resolved);
372
- let count = 0;
394
+ const removed = [];
373
395
  try {
374
396
  const entries = fs.readdirSync(dir, { withFileTypes: true });
375
397
  for (const entry of entries) {
376
- const full = path.join(dir, entry.name);
398
+ const full = path.resolve(dir, entry.name);
377
399
  if (entry.isDirectory() && !entry.isSymbolicLink() && entry.name !== 'node_modules') {
378
- count += removeNulFiles(full, visited);
400
+ removed.push(...removeNulFiles(full, visited));
379
401
  }
380
- else if (entry.name === 'nul') {
402
+ else if (entry.name.toLowerCase() === 'nul') {
381
403
  try {
382
- fs.unlinkSync(path.join('\\\\?\\', path.resolve(full)));
383
- count++;
404
+ fs.unlinkSync(path.join('\\\\?\\', full));
405
+ removed.push(full);
384
406
  }
385
- catch {
386
- // Ignore deletion errors
407
+ catch (error) {
408
+ console.error(colors.red(`Could not delete 'nul' file ${full}: ${error.message} — git add will fail on it`));
387
409
  }
388
410
  }
389
411
  }
390
412
  }
391
- catch {
392
- // Ignore read errors (permissions, etc.)
413
+ catch (error) {
414
+ // Justified swallow: an unreadable directory cannot be searched for 'nul' files, and that
415
+ // loses nothing -- git walks the same tree next and reports the unreadable directory itself
416
+ // (the "Permission denied" handling at the git add call sites picks it up).
417
+ }
418
+ return removed;
419
+ }
420
+ /** Stage everything (`git add -A`), first deleting any 'nul' files in the tree.
421
+ * A 'nul' file is a dropping from `>nul` run under a shell that creates it instead of discarding
422
+ * it; git on Windows cannot index one ("invalid path 'nul'" / "short read while indexing nul").
423
+ * Every `git add -A` goes through this one function so no staging path can miss the cleanup
424
+ * (2026-09-20, Claude Code: only the pre-release commit did it; -init, the index rebuild and the
425
+ * two pre-version cleanups did not). */
426
+ export function gitAddAll(cwd) {
427
+ for (const nulFile of removeNulFiles(cwd)) {
428
+ console.log(colors.yellow(` Deleted 'nul' file ${nulFile} (Windows reserved name — git cannot add it)`));
393
429
  }
394
- return count;
430
+ return runCommand('git', ['add', '-A'], { cwd, silent: true });
395
431
  }
396
432
  /** Repair a corrupted git index by rebuilding it.
397
433
  * Handles "invalid object" / "Error building trees" errors caused by
@@ -420,13 +456,8 @@ function repairGitIndex(cwd) {
420
456
  }
421
457
  }
422
458
  // Re-stage all files
423
- const addResult = spawnSafe('git', ['add', '-A'], {
424
- encoding: 'utf-8',
425
- stdio: 'pipe',
426
- cwd,
427
- env: process.env
428
- });
429
- if (addResult.status !== 0) {
459
+ const addResult = gitAddAll(cwd);
460
+ if (!addResult.success) {
430
461
  console.error(colors.red('Failed to re-add files after index rebuild:'), (addResult.stderr || '').trim());
431
462
  return false;
432
463
  }
@@ -1968,7 +1999,13 @@ export function findUndeclaredImports(cwd, pkg) {
1968
1999
  * unresolvable paths, missing package.json, unpublished transitives. Lets the
1969
2000
  * user resolve all problems before starting the publish cascade instead of
1970
2001
  * being interrupted mid-run. */
1971
- export function prescanDepGraph(baseDir, visited = new Set(), verbose = false) {
2002
+ // 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction ("you need to warn about @installs
2003
+ // because they may be nested so I won't see them"). `fileTargets` added: every file: target the
2004
+ // walk resolves is recorded (package name → directory) so reportRegistryRefs can say where the
2005
+ // local source of a registry-installed package is. Own-scope deps given as a version range are
2006
+ // collected as kind 'registryRef'. Seen in certsupport: `@bobfrankston/miscassists: ^1.0.61`
2007
+ // committed in its package.json, two levels under pserve, where no tree listing showed it.
2008
+ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false, fileTargets = new Map()) {
1972
2009
  const issues = [];
1973
2010
  let realDir;
1974
2011
  try {
@@ -1987,13 +2024,29 @@ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false) {
1987
2024
  catch {
1988
2025
  return issues;
1989
2026
  }
2027
+ // Own scope = the scope of the package declaring the dep ('@bobfrankston/'), the same
2028
+ // definition ownScopePackages uses. An unscoped consumer has none, so nothing is flagged.
2029
+ const consumerName = pkg.name || '';
2030
+ const ownScope = consumerName.startsWith('@') && consumerName.includes('/')
2031
+ ? consumerName.slice(0, consumerName.indexOf('/') + 1) : '';
2032
+ // `"files": false` in .globalize.json5 means this package keeps npm refs in package.json on
2033
+ // purpose (server deploys copy package.json as is), so its ranges are not a finding.
2034
+ const keepsNpmRefs = readConfig(baseDir).files === false;
1990
2035
  for (const key of DEP_KEYS) {
1991
2036
  if (!pkg[key])
1992
2037
  continue;
1993
2038
  const deps = pkg['.' + key] || pkg[key];
1994
2039
  for (const [name, value] of Object.entries(deps)) {
1995
- if (!isFileRef(value))
2040
+ if (!isFileRef(value)) {
2041
+ if (ownScope && name.startsWith(ownScope) && !keepsNpmRefs) {
2042
+ issues.push({
2043
+ severity: 'warning', kind: 'registryRef',
2044
+ package: name, path: baseDir, consumer: consumerName, range: value,
2045
+ message: `${consumerName} depends on ${name} as ${value}, not file:`
2046
+ });
2047
+ }
1996
2048
  continue;
2049
+ }
1997
2050
  let targetPath;
1998
2051
  try {
1999
2052
  targetPath = resolveFilePath(value, baseDir);
@@ -2019,6 +2072,7 @@ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false) {
2019
2072
  continue;
2020
2073
  }
2021
2074
  const depName = targetPkg.name || name;
2075
+ fileTargets.set(depName, targetPath);
2022
2076
  // Unscoped + private-intent. This used to be a WARNING whose "continue" led
2023
2077
  // into the cascade prompting to rename the dep by adding a scope. The cascade
2024
2078
  // never renames a dependency now (see the _fromDep check in globalize), so it
@@ -2045,11 +2099,40 @@ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false) {
2045
2099
  }
2046
2100
  }
2047
2101
  // Recurse into the dep
2048
- issues.push(...prescanDepGraph(targetPath, visited, verbose));
2102
+ issues.push(...prescanDepGraph(targetPath, visited, verbose, fileTargets));
2049
2103
  }
2050
2104
  }
2051
2105
  return issues;
2052
2106
  }
2107
+ /** Report own-scope packages that some package in the file: graph takes from the registry
2108
+ * (a version range) instead of from local source. Nothing is asked and the run continues: a
2109
+ * range can be deliberate, and the fix is an edit to the CONSUMER's package.json, which is not
2110
+ * npmglobalize's to make. It is printed on every run because the consumer is usually nested
2111
+ * under other file: deps, where neither the `+` list nor the dependency tree shows it.
2112
+ * `fileTargets` is the map prescanDepGraph filled: when the same package is linked as file:
2113
+ * elsewhere in the graph, its directory is named — that package then exists twice, the linked
2114
+ * source and the registry copy in the consumer's node_modules. */
2115
+ export function reportRegistryRefs(registryRefs, fileTargets) {
2116
+ if (!registryRefs.length)
2117
+ return;
2118
+ console.log(colors.yellow(`${registryRefs.length} own-scope dependenc${registryRefs.length === 1 ? 'y is' : 'ies are'} installed from npm, not linked with file: (a registry copy in node_modules, not the local source):`));
2119
+ for (const ref of registryRefs) {
2120
+ console.log(` ${colors.yellow('@')} ${ref.consumer} → ${ref.package} ${ref.range}`);
2121
+ console.log(colors.dim(` in ${path.join(ref.path, 'package.json')}`));
2122
+ const localSource = fileTargets.get(ref.package);
2123
+ if (localSource) {
2124
+ console.log(colors.dim(` local source, linked as file: elsewhere in this graph: ${localSource}`));
2125
+ }
2126
+ // 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction ("it should summarize the
2127
+ // findings at the end"). The block above prints before the cascade and scrolls away, so
2128
+ // each finding also goes into the Issues Summary cli.ts prints on every exit path. One
2129
+ // self-contained line: it has to make sense with the prescan output long gone.
2130
+ recordBuildIssue(ref.consumer, 'warning', `${ref.package} ${ref.range} is installed from npm, not file: — ${path.join(ref.path, 'package.json')}`
2131
+ + (localSource ? ` (local source: ${localSource})` : ''));
2132
+ }
2133
+ console.log(colors.dim(` To link one, set it to a file: path in the package.json named above, then run npmglobalize in that package.`));
2134
+ console.log('');
2135
+ }
2053
2136
  /** Report unscoped `file:` deps found by the prescan, without asking anything.
2054
2137
  *
2055
2138
  * An unscoped dep with private intent cannot reach npm, and npmglobalize does not
@@ -3953,7 +4036,8 @@ export async function buildProject(cwd, opts = {}) {
3953
4036
  // ago is worse than leaving the deprecation in place. So put the
3954
4037
  // tsconfigs back, and if that restores a working build, hand the user
3955
4038
  // the errors plus the command that migrates for real.
3956
- const migratedOutput = (buildResult.stderr || '') + (buildResult.output || '');
4039
+ // 2026-09-14 — Claude Code (Fable 5.1): paths anchored to `cwd` (see anchorTscPaths).
4040
+ const migratedOutput = anchorTscPaths((buildResult.stderr || '') + (buildResult.output || ''), cwd);
3957
4041
  const migratedTexts = readTsconfigTexts(tsconfigSnapshot.keys());
3958
4042
  if (restoreTsconfigTexts(tsconfigSnapshot)) {
3959
4043
  const retry = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
@@ -3990,10 +4074,12 @@ export async function buildProject(cwd, opts = {}) {
3990
4074
  console.log(colors.green(`✓ Build succeeded (${pkg.name || path.basename(cwd)})`));
3991
4075
  return true;
3992
4076
  }
3993
- const buildOutput = (buildResult.stderr || '') + (buildResult.output || '');
4077
+ // 2026-09-14 — Claude Code (Fable 5.1): tsc lines anchored to this build's
4078
+ // directory, and the directory named on the failure line (see anchorTscPaths).
4079
+ const buildOutput = anchorTscPaths((buildResult.stderr || '') + (buildResult.output || ''), cwd);
3994
4080
  if (buildOutput)
3995
4081
  console.error(buildOutput);
3996
- console.error(colors.red(`Build failed in ${pkg.name || cwd}`));
4082
+ console.error(colors.red(`Build failed in ${pkg.name || path.basename(cwd)} (${cwd})`));
3997
4083
  const label = pkg.name || path.basename(cwd);
3998
4084
  // tsc reports a missing-declarations failure against the file that imports the
3999
4085
  // dep, and suggests an @types package that does not exist for private scopes.
@@ -4254,8 +4340,10 @@ export async function fixTs7Deprecations(cwd) {
4254
4340
  result = await runCommandAsync('tsc', ['-p', '.', '--noEmit'], { cwd: dir, silent: true });
4255
4341
  }
4256
4342
  if (!result.success) {
4257
- const errs = extractTscErrors((result.stderr || '') + (result.output || ''));
4258
- typeErrors.set(dir, errs.length ? errs : [(result.stderr || result.output || '').trim().split('\n')[0] || 'tsc failed with no output']);
4343
+ // 2026-09-14 — Claude Code (Fable 5.1): paths anchored to `dir` (see anchorTscPaths).
4344
+ const tscOut = anchorTscPaths((result.stderr || '') + (result.output || ''), dir);
4345
+ const errs = extractTscErrors(tscOut);
4346
+ typeErrors.set(dir, errs.length ? errs : [tscOut.trim().split('\n')[0] || 'tsc failed with no output']);
4259
4347
  }
4260
4348
  }
4261
4349
  // Drop ignoreDeprecations wherever nothing deprecated remains in that package.
@@ -5591,6 +5679,31 @@ export function runCommandOrThrow(cmd, args, options = {}) {
5591
5679
  throw new Error(`Command failed after git index repair: ${cmd} ${args.join(' ')}`);
5592
5680
  }
5593
5681
  }
5682
+ // 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction ("you should automatically fix").
5683
+ // A git that dies mid-write leaves its `<file>.lock` behind, and every later git refuses
5684
+ // with "File exists". Seen in Y:\photos\utils\photodb: a `git init` died at 00:02 leaving a
5685
+ // 0-byte .git/config.lock; the 00:29 cascade then failed `git init` on it. An abandoned
5686
+ // lock is removed (named in the output) and the command retried once.
5687
+ if (cmd === 'git') {
5688
+ const lockPath = gitLockFromError(stderr, options.cwd);
5689
+ if (lockPath && removeStaleGitLock(lockPath)) {
5690
+ const retry = spawnSafe(cmd, args, {
5691
+ encoding: 'utf-8',
5692
+ stdio: 'pipe',
5693
+ cwd: options.cwd,
5694
+ env: process.env,
5695
+ shell: needsShell
5696
+ });
5697
+ if (retry.status === 0) {
5698
+ return (retry.stdout || '') + (retry.stderr || '');
5699
+ }
5700
+ const retryStderr = retry.stderr || '';
5701
+ if (retryStderr.trim()) {
5702
+ console.error(colors.red(retryStderr.trim()));
5703
+ }
5704
+ throw new Error(`Command failed after removing stale git lock ${lockPath}: ${cmd} ${args.join(' ')}`);
5705
+ }
5706
+ }
5594
5707
  // Show the error output
5595
5708
  if (stderr.trim()) {
5596
5709
  console.error(colors.red(stderr.trim()));
@@ -5602,6 +5715,47 @@ export function runCommandOrThrow(cmd, args, options = {}) {
5602
5715
  }
5603
5716
  return output;
5604
5717
  }
5718
+ /** A git `*.lock` older than this is taken to be abandoned. Git holds config.lock / index.lock
5719
+ * for the duration of one write, so a lock this old has no live owner; a younger one may belong
5720
+ * to a git that is writing right now (VS Code's background git, a parallel run) and is left alone. */
5721
+ const STALE_GIT_LOCK_MS = 10_000;
5722
+ /** Pull the lock file path out of git's "File exists" lock errors; null when stderr is not one.
5723
+ * Two wordings: `could not lock config file <file>: File exists` (the lock is `<file>.lock`)
5724
+ * and `Unable to create '<file>.lock': File exists.` (index.lock, HEAD.lock, refs). */
5725
+ function gitLockFromError(stderr, cwd) {
5726
+ const configLock = stderr.match(/could not lock config file (.+?): File exists/);
5727
+ const otherLock = stderr.match(/Unable to create '(.+?\.lock)': File exists/);
5728
+ const reported = configLock ? `${configLock[1]}.lock` : otherLock ? otherLock[1] : null;
5729
+ if (!reported)
5730
+ return null;
5731
+ return path.resolve(cwd || '.', reported); // git may report the path relative to cwd
5732
+ }
5733
+ /** Delete an abandoned git lock file, naming it. Returns true only when the lock is gone. */
5734
+ function removeStaleGitLock(lockPath) {
5735
+ let ageMs;
5736
+ try {
5737
+ ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
5738
+ }
5739
+ catch (error) {
5740
+ // Justified swallow: the lock git complained about is already gone (its owner finished
5741
+ // between git's error and this stat). Nothing to remove; returning false sends the caller
5742
+ // to its normal path, which prints git's stderr and throws.
5743
+ return false;
5744
+ }
5745
+ if (ageMs < STALE_GIT_LOCK_MS) {
5746
+ console.log(colors.yellow(`Git lock ${lockPath} is only ${Math.round(ageMs / 1000)}s old — another git may be writing; not removed.`));
5747
+ return false;
5748
+ }
5749
+ try {
5750
+ fs.unlinkSync(lockPath);
5751
+ }
5752
+ catch (error) {
5753
+ console.error(colors.red(`Could not delete stale git lock ${lockPath}: ${error.message}`));
5754
+ return false;
5755
+ }
5756
+ console.log(colors.yellow(`Removed stale git lock ${lockPath} (${Math.round(ageMs / 60000)} min old, left by a git that never finished). Retrying...`));
5757
+ return true;
5758
+ }
5605
5759
  export function getGitStatus(cwd) {
5606
5760
  const status = {
5607
5761
  isRepo: false,
@@ -6733,7 +6887,7 @@ export async function initGit(cwd, visibility, dryRun, allowTs) {
6733
6887
  runCommandOrThrow('git', ['config', 'core.autocrlf', 'false'], { cwd });
6734
6888
  runCommandOrThrow('git', ['config', 'core.eol', 'lf'], { cwd });
6735
6889
  console.log(' ✓ Configured git for LF line endings');
6736
- let initAddResult = runCommand('git', ['add', '-A'], { cwd, silent: true });
6890
+ let initAddResult = gitAddAll(cwd);
6737
6891
  if (!initAddResult.success) {
6738
6892
  const errText = initAddResult.stderr + initAddResult.output;
6739
6893
  const deniedFiles = parseDeniedFiles(errText);
@@ -6753,7 +6907,7 @@ export async function initGit(cwd, visibility, dryRun, allowTs) {
6753
6907
  }
6754
6908
  fs.writeFileSync(gitignorePath, gitignoreContent);
6755
6909
  console.log(colors.green(' ✓ Updated .gitignore'));
6756
- initAddResult = runCommand('git', ['add', '-A'], { cwd, silent: true });
6910
+ initAddResult = gitAddAll(cwd);
6757
6911
  }
6758
6912
  if (!initAddResult.success) {
6759
6913
  console.error(colors.red(' git add still failing. Fix manually and retry.'));
@@ -7191,7 +7345,13 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7191
7345
  // any transform/publish, so the user can fix them once and run clean.
7192
7346
  // Only run at the top level (not during recursive cascade or workspace).
7193
7347
  if (!noPrescan && !cleanup && !options._fromDep && !options._fromWorkspace) {
7194
- const allIssues = prescanDepGraph(cwd, new Set(), verbose);
7348
+ const fileTargets = new Map();
7349
+ const scanned = prescanDepGraph(cwd, new Set(), verbose, fileTargets);
7350
+ // 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction: registry refs to own-scope
7351
+ // packages are reported apart from the prompt below, like unscoped deps — a warning
7352
+ // with no question, since no answer would change what this run does.
7353
+ reportRegistryRefs(scanned.filter(i => i.kind === 'registryRef'), fileTargets);
7354
+ const allIssues = scanned.filter(i => i.kind !== 'registryRef');
7195
7355
  // Unscoped deps are handled apart from the error/warning prompt below: nothing the
7196
7356
  // user answers could change the outcome, so nothing is asked. Report, remember,
7197
7357
  // stop. See reportUnscopedDeps.
@@ -7658,10 +7818,12 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
7658
7818
  // Always capture output so we can extract tsc errors for the summary
7659
7819
  const buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
7660
7820
  if (!buildResult.success) {
7661
- const buildOutput = buildResult.stderr || buildResult.output;
7821
+ // 2026-09-14 — Claude Code (Fable 5.1): tsc lines anchored to `cwd`
7822
+ // and the directory named on the failure line (see anchorTscPaths).
7823
+ const buildOutput = anchorTscPaths(buildResult.stderr || buildResult.output, cwd);
7662
7824
  if (buildOutput)
7663
7825
  console.error(buildOutput);
7664
- console.error(colors.red('ERROR: Build failed'));
7826
+ console.error(colors.red(`ERROR: Build failed in ${cwd}`));
7665
7827
  diagnoseBuildFailure(buildOutput || '', cwd);
7666
7828
  const firstErr = extractFirstTscError(buildOutput || '');
7667
7829
  recordBuildIssue(pkg.name || path.basename(cwd), 'error', firstErr || 'Build failed');
@@ -8511,15 +8673,15 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8511
8673
  const commitMsg = effectiveMessage || 'Pre-release commit';
8512
8674
  console.log(`${timestamp()} Committing changes: ${commitMsg}`);
8513
8675
  if (!dryRun) {
8514
- // Remove 'nul' files that break git on Windows
8515
- const nulCount = removeNulFiles(cwd);
8516
- if (nulCount > 0 && verbose) {
8517
- console.log(` Removed ${nulCount} 'nul' file(s) (Windows reserved name)`);
8518
- }
8676
+ // 'nul' files that break git on Windows are deleted inside gitAddAll (2026-09-20,
8677
+ // Claude Code: moved there from this spot so every `git add -A` gets the cleanup).
8678
+ // 2026-09-20 — Claude Code (Fable 5.1): start and finish are separate lines (same
8679
+ // shape as the retry below). They were one line written in two halves, and
8680
+ // gitAddAll's "Deleted 'nul' file …" report landed in the middle of it.
8519
8681
  const addStart = Date.now();
8520
- process.stdout.write(` git add -A ... `);
8521
- let addResult = runCommand('git', ['add', '-A'], { cwd, silent: true });
8522
- process.stdout.write(`done (${((Date.now() - addStart) / 1000).toFixed(1)}s)\n`);
8682
+ console.log(` git add -A ...`);
8683
+ let addResult = gitAddAll(cwd);
8684
+ console.log(` git add -A done (${((Date.now() - addStart) / 1000).toFixed(1)}s)`);
8523
8685
  if (!addResult.success) {
8524
8686
  const errText = addResult.stderr + addResult.output;
8525
8687
  const deniedFiles = parseDeniedFiles(errText);
@@ -8541,7 +8703,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8541
8703
  console.log(colors.green('✓ Updated .gitignore'));
8542
8704
  const retryStart = Date.now();
8543
8705
  console.log(` ${timestamp()} git add -A (retry) ...`);
8544
- addResult = runCommand('git', ['add', '-A'], { cwd, silent: true });
8706
+ addResult = gitAddAll(cwd);
8545
8707
  console.log(` ${timestamp()} git add -A done (${((Date.now() - retryStart) / 1000).toFixed(1)}s)`);
8546
8708
  if (addResult.success) {
8547
8709
  console.log(colors.green('✓ git add succeeded after updating .gitignore'));
@@ -8611,7 +8773,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8611
8773
  if (verbose) {
8612
8774
  console.log('Committing stray changes before version bump...');
8613
8775
  }
8614
- runCommand('git', ['add', '-A'], { cwd, silent: true });
8776
+ gitAddAll(cwd);
8615
8777
  gitCommit('Pre-version cleanup', cwd);
8616
8778
  }
8617
8779
  }
@@ -8800,7 +8962,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
8800
8962
  }
8801
8963
  // Commit stray changes and retry
8802
8964
  console.log(colors.yellow(' Committing stray changes and retrying...'));
8803
- const addRes = runCommand('git', ['add', '-A'], { cwd, silent: true });
8965
+ const addRes = gitAddAll(cwd);
8804
8966
  if (addRes.success) {
8805
8967
  const commitRes = gitCommit('Pre-version cleanup', cwd);
8806
8968
  if (commitRes.success) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.232",
3
+ "version": "1.0.234",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",