@bobfrankston/npmglobalize 1.0.231 → 1.0.233
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.
- package/README.md +30 -2
- package/cli.js +8 -5
- package/lib.d.ts +39 -3
- package/lib.js +199 -43
- 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). 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/cli.js
CHANGED
|
@@ -136,11 +136,14 @@ Other Options:
|
|
|
136
136
|
migration to-do list and exits. Writes nothing.
|
|
137
137
|
-tsfix, -ts7-fix
|
|
138
138
|
One-off utility, not the main flow: apply the TS7 tsconfig
|
|
139
|
-
migration to this package and its file: deps,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
migration to this package and its file: deps, type-check
|
|
140
|
+
each with tsc --noEmit, add the extension NodeNext requires
|
|
141
|
+
on relative imports tsc flags ("./a" -> "./a.js", a noEmit
|
|
142
|
+
project gets ".ts"), and exit. tsconfig files and flagged
|
|
143
|
+
imports change — no emit, no commit, no push, no publish.
|
|
144
|
+
Whatever still fails to type-check is listed. The release
|
|
145
|
+
flow does the same on a TS7 build error; use this for an
|
|
146
|
+
in-place fix of a local directory: npmglobalize <path> -tsfix
|
|
144
147
|
-show Show package.json dependency changes
|
|
145
148
|
-package, -pkg Update package.json scripts to use npmglobalize
|
|
146
149
|
-h, -help Show this help
|
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
|
-
|
|
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
|
|
392
|
+
return [];
|
|
371
393
|
visited.add(resolved);
|
|
372
|
-
|
|
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.
|
|
398
|
+
const full = path.resolve(dir, entry.name);
|
|
377
399
|
if (entry.isDirectory() && !entry.isSymbolicLink() && entry.name !== 'node_modules') {
|
|
378
|
-
|
|
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('\\\\?\\',
|
|
383
|
-
|
|
404
|
+
fs.unlinkSync(path.join('\\\\?\\', full));
|
|
405
|
+
removed.push(full);
|
|
384
406
|
}
|
|
385
|
-
catch {
|
|
386
|
-
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
424
|
-
|
|
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
|
-
|
|
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,34 @@ 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
|
+
}
|
|
2127
|
+
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.`));
|
|
2128
|
+
console.log('');
|
|
2129
|
+
}
|
|
2053
2130
|
/** Report unscoped `file:` deps found by the prescan, without asking anything.
|
|
2054
2131
|
*
|
|
2055
2132
|
* An unscoped dep with private intent cannot reach npm, and npmglobalize does not
|
|
@@ -3953,7 +4030,8 @@ export async function buildProject(cwd, opts = {}) {
|
|
|
3953
4030
|
// ago is worse than leaving the deprecation in place. So put the
|
|
3954
4031
|
// tsconfigs back, and if that restores a working build, hand the user
|
|
3955
4032
|
// the errors plus the command that migrates for real.
|
|
3956
|
-
|
|
4033
|
+
// 2026-09-14 — Claude Code (Fable 5.1): paths anchored to `cwd` (see anchorTscPaths).
|
|
4034
|
+
const migratedOutput = anchorTscPaths((buildResult.stderr || '') + (buildResult.output || ''), cwd);
|
|
3957
4035
|
const migratedTexts = readTsconfigTexts(tsconfigSnapshot.keys());
|
|
3958
4036
|
if (restoreTsconfigTexts(tsconfigSnapshot)) {
|
|
3959
4037
|
const retry = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
|
|
@@ -3990,10 +4068,12 @@ export async function buildProject(cwd, opts = {}) {
|
|
|
3990
4068
|
console.log(colors.green(`✓ Build succeeded (${pkg.name || path.basename(cwd)})`));
|
|
3991
4069
|
return true;
|
|
3992
4070
|
}
|
|
3993
|
-
|
|
4071
|
+
// 2026-09-14 — Claude Code (Fable 5.1): tsc lines anchored to this build's
|
|
4072
|
+
// directory, and the directory named on the failure line (see anchorTscPaths).
|
|
4073
|
+
const buildOutput = anchorTscPaths((buildResult.stderr || '') + (buildResult.output || ''), cwd);
|
|
3994
4074
|
if (buildOutput)
|
|
3995
4075
|
console.error(buildOutput);
|
|
3996
|
-
console.error(colors.red(`Build failed in ${pkg.name || cwd}`));
|
|
4076
|
+
console.error(colors.red(`Build failed in ${pkg.name || path.basename(cwd)} (${cwd})`));
|
|
3997
4077
|
const label = pkg.name || path.basename(cwd);
|
|
3998
4078
|
// tsc reports a missing-declarations failure against the file that imports the
|
|
3999
4079
|
// dep, and suggests an @types package that does not exist for private scopes.
|
|
@@ -4254,8 +4334,10 @@ export async function fixTs7Deprecations(cwd) {
|
|
|
4254
4334
|
result = await runCommandAsync('tsc', ['-p', '.', '--noEmit'], { cwd: dir, silent: true });
|
|
4255
4335
|
}
|
|
4256
4336
|
if (!result.success) {
|
|
4257
|
-
|
|
4258
|
-
|
|
4337
|
+
// 2026-09-14 — Claude Code (Fable 5.1): paths anchored to `dir` (see anchorTscPaths).
|
|
4338
|
+
const tscOut = anchorTscPaths((result.stderr || '') + (result.output || ''), dir);
|
|
4339
|
+
const errs = extractTscErrors(tscOut);
|
|
4340
|
+
typeErrors.set(dir, errs.length ? errs : [tscOut.trim().split('\n')[0] || 'tsc failed with no output']);
|
|
4259
4341
|
}
|
|
4260
4342
|
}
|
|
4261
4343
|
// Drop ignoreDeprecations wherever nothing deprecated remains in that package.
|
|
@@ -5591,6 +5673,31 @@ export function runCommandOrThrow(cmd, args, options = {}) {
|
|
|
5591
5673
|
throw new Error(`Command failed after git index repair: ${cmd} ${args.join(' ')}`);
|
|
5592
5674
|
}
|
|
5593
5675
|
}
|
|
5676
|
+
// 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction ("you should automatically fix").
|
|
5677
|
+
// A git that dies mid-write leaves its `<file>.lock` behind, and every later git refuses
|
|
5678
|
+
// with "File exists". Seen in Y:\photos\utils\photodb: a `git init` died at 00:02 leaving a
|
|
5679
|
+
// 0-byte .git/config.lock; the 00:29 cascade then failed `git init` on it. An abandoned
|
|
5680
|
+
// lock is removed (named in the output) and the command retried once.
|
|
5681
|
+
if (cmd === 'git') {
|
|
5682
|
+
const lockPath = gitLockFromError(stderr, options.cwd);
|
|
5683
|
+
if (lockPath && removeStaleGitLock(lockPath)) {
|
|
5684
|
+
const retry = spawnSafe(cmd, args, {
|
|
5685
|
+
encoding: 'utf-8',
|
|
5686
|
+
stdio: 'pipe',
|
|
5687
|
+
cwd: options.cwd,
|
|
5688
|
+
env: process.env,
|
|
5689
|
+
shell: needsShell
|
|
5690
|
+
});
|
|
5691
|
+
if (retry.status === 0) {
|
|
5692
|
+
return (retry.stdout || '') + (retry.stderr || '');
|
|
5693
|
+
}
|
|
5694
|
+
const retryStderr = retry.stderr || '';
|
|
5695
|
+
if (retryStderr.trim()) {
|
|
5696
|
+
console.error(colors.red(retryStderr.trim()));
|
|
5697
|
+
}
|
|
5698
|
+
throw new Error(`Command failed after removing stale git lock ${lockPath}: ${cmd} ${args.join(' ')}`);
|
|
5699
|
+
}
|
|
5700
|
+
}
|
|
5594
5701
|
// Show the error output
|
|
5595
5702
|
if (stderr.trim()) {
|
|
5596
5703
|
console.error(colors.red(stderr.trim()));
|
|
@@ -5602,6 +5709,47 @@ export function runCommandOrThrow(cmd, args, options = {}) {
|
|
|
5602
5709
|
}
|
|
5603
5710
|
return output;
|
|
5604
5711
|
}
|
|
5712
|
+
/** A git `*.lock` older than this is taken to be abandoned. Git holds config.lock / index.lock
|
|
5713
|
+
* for the duration of one write, so a lock this old has no live owner; a younger one may belong
|
|
5714
|
+
* to a git that is writing right now (VS Code's background git, a parallel run) and is left alone. */
|
|
5715
|
+
const STALE_GIT_LOCK_MS = 10_000;
|
|
5716
|
+
/** Pull the lock file path out of git's "File exists" lock errors; null when stderr is not one.
|
|
5717
|
+
* Two wordings: `could not lock config file <file>: File exists` (the lock is `<file>.lock`)
|
|
5718
|
+
* and `Unable to create '<file>.lock': File exists.` (index.lock, HEAD.lock, refs). */
|
|
5719
|
+
function gitLockFromError(stderr, cwd) {
|
|
5720
|
+
const configLock = stderr.match(/could not lock config file (.+?): File exists/);
|
|
5721
|
+
const otherLock = stderr.match(/Unable to create '(.+?\.lock)': File exists/);
|
|
5722
|
+
const reported = configLock ? `${configLock[1]}.lock` : otherLock ? otherLock[1] : null;
|
|
5723
|
+
if (!reported)
|
|
5724
|
+
return null;
|
|
5725
|
+
return path.resolve(cwd || '.', reported); // git may report the path relative to cwd
|
|
5726
|
+
}
|
|
5727
|
+
/** Delete an abandoned git lock file, naming it. Returns true only when the lock is gone. */
|
|
5728
|
+
function removeStaleGitLock(lockPath) {
|
|
5729
|
+
let ageMs;
|
|
5730
|
+
try {
|
|
5731
|
+
ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
5732
|
+
}
|
|
5733
|
+
catch (error) {
|
|
5734
|
+
// Justified swallow: the lock git complained about is already gone (its owner finished
|
|
5735
|
+
// between git's error and this stat). Nothing to remove; returning false sends the caller
|
|
5736
|
+
// to its normal path, which prints git's stderr and throws.
|
|
5737
|
+
return false;
|
|
5738
|
+
}
|
|
5739
|
+
if (ageMs < STALE_GIT_LOCK_MS) {
|
|
5740
|
+
console.log(colors.yellow(`Git lock ${lockPath} is only ${Math.round(ageMs / 1000)}s old — another git may be writing; not removed.`));
|
|
5741
|
+
return false;
|
|
5742
|
+
}
|
|
5743
|
+
try {
|
|
5744
|
+
fs.unlinkSync(lockPath);
|
|
5745
|
+
}
|
|
5746
|
+
catch (error) {
|
|
5747
|
+
console.error(colors.red(`Could not delete stale git lock ${lockPath}: ${error.message}`));
|
|
5748
|
+
return false;
|
|
5749
|
+
}
|
|
5750
|
+
console.log(colors.yellow(`Removed stale git lock ${lockPath} (${Math.round(ageMs / 60000)} min old, left by a git that never finished). Retrying...`));
|
|
5751
|
+
return true;
|
|
5752
|
+
}
|
|
5605
5753
|
export function getGitStatus(cwd) {
|
|
5606
5754
|
const status = {
|
|
5607
5755
|
isRepo: false,
|
|
@@ -6733,7 +6881,7 @@ export async function initGit(cwd, visibility, dryRun, allowTs) {
|
|
|
6733
6881
|
runCommandOrThrow('git', ['config', 'core.autocrlf', 'false'], { cwd });
|
|
6734
6882
|
runCommandOrThrow('git', ['config', 'core.eol', 'lf'], { cwd });
|
|
6735
6883
|
console.log(' ✓ Configured git for LF line endings');
|
|
6736
|
-
let initAddResult =
|
|
6884
|
+
let initAddResult = gitAddAll(cwd);
|
|
6737
6885
|
if (!initAddResult.success) {
|
|
6738
6886
|
const errText = initAddResult.stderr + initAddResult.output;
|
|
6739
6887
|
const deniedFiles = parseDeniedFiles(errText);
|
|
@@ -6753,7 +6901,7 @@ export async function initGit(cwd, visibility, dryRun, allowTs) {
|
|
|
6753
6901
|
}
|
|
6754
6902
|
fs.writeFileSync(gitignorePath, gitignoreContent);
|
|
6755
6903
|
console.log(colors.green(' ✓ Updated .gitignore'));
|
|
6756
|
-
initAddResult =
|
|
6904
|
+
initAddResult = gitAddAll(cwd);
|
|
6757
6905
|
}
|
|
6758
6906
|
if (!initAddResult.success) {
|
|
6759
6907
|
console.error(colors.red(' git add still failing. Fix manually and retry.'));
|
|
@@ -7191,7 +7339,13 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
7191
7339
|
// any transform/publish, so the user can fix them once and run clean.
|
|
7192
7340
|
// Only run at the top level (not during recursive cascade or workspace).
|
|
7193
7341
|
if (!noPrescan && !cleanup && !options._fromDep && !options._fromWorkspace) {
|
|
7194
|
-
const
|
|
7342
|
+
const fileTargets = new Map();
|
|
7343
|
+
const scanned = prescanDepGraph(cwd, new Set(), verbose, fileTargets);
|
|
7344
|
+
// 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction: registry refs to own-scope
|
|
7345
|
+
// packages are reported apart from the prompt below, like unscoped deps — a warning
|
|
7346
|
+
// with no question, since no answer would change what this run does.
|
|
7347
|
+
reportRegistryRefs(scanned.filter(i => i.kind === 'registryRef'), fileTargets);
|
|
7348
|
+
const allIssues = scanned.filter(i => i.kind !== 'registryRef');
|
|
7195
7349
|
// Unscoped deps are handled apart from the error/warning prompt below: nothing the
|
|
7196
7350
|
// user answers could change the outcome, so nothing is asked. Report, remember,
|
|
7197
7351
|
// stop. See reportUnscopedDeps.
|
|
@@ -7658,10 +7812,12 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
7658
7812
|
// Always capture output so we can extract tsc errors for the summary
|
|
7659
7813
|
const buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
|
|
7660
7814
|
if (!buildResult.success) {
|
|
7661
|
-
|
|
7815
|
+
// 2026-09-14 — Claude Code (Fable 5.1): tsc lines anchored to `cwd`
|
|
7816
|
+
// and the directory named on the failure line (see anchorTscPaths).
|
|
7817
|
+
const buildOutput = anchorTscPaths(buildResult.stderr || buildResult.output, cwd);
|
|
7662
7818
|
if (buildOutput)
|
|
7663
7819
|
console.error(buildOutput);
|
|
7664
|
-
console.error(colors.red(
|
|
7820
|
+
console.error(colors.red(`ERROR: Build failed in ${cwd}`));
|
|
7665
7821
|
diagnoseBuildFailure(buildOutput || '', cwd);
|
|
7666
7822
|
const firstErr = extractFirstTscError(buildOutput || '');
|
|
7667
7823
|
recordBuildIssue(pkg.name || path.basename(cwd), 'error', firstErr || 'Build failed');
|
|
@@ -8511,15 +8667,15 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
8511
8667
|
const commitMsg = effectiveMessage || 'Pre-release commit';
|
|
8512
8668
|
console.log(`${timestamp()} Committing changes: ${commitMsg}`);
|
|
8513
8669
|
if (!dryRun) {
|
|
8514
|
-
//
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8670
|
+
// 'nul' files that break git on Windows are deleted inside gitAddAll (2026-09-20,
|
|
8671
|
+
// Claude Code: moved there from this spot so every `git add -A` gets the cleanup).
|
|
8672
|
+
// 2026-09-20 — Claude Code (Fable 5.1): start and finish are separate lines (same
|
|
8673
|
+
// shape as the retry below). They were one line written in two halves, and
|
|
8674
|
+
// gitAddAll's "Deleted 'nul' file …" report landed in the middle of it.
|
|
8519
8675
|
const addStart = Date.now();
|
|
8520
|
-
|
|
8521
|
-
let addResult =
|
|
8522
|
-
|
|
8676
|
+
console.log(` git add -A ...`);
|
|
8677
|
+
let addResult = gitAddAll(cwd);
|
|
8678
|
+
console.log(` git add -A done (${((Date.now() - addStart) / 1000).toFixed(1)}s)`);
|
|
8523
8679
|
if (!addResult.success) {
|
|
8524
8680
|
const errText = addResult.stderr + addResult.output;
|
|
8525
8681
|
const deniedFiles = parseDeniedFiles(errText);
|
|
@@ -8541,7 +8697,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
8541
8697
|
console.log(colors.green('✓ Updated .gitignore'));
|
|
8542
8698
|
const retryStart = Date.now();
|
|
8543
8699
|
console.log(` ${timestamp()} git add -A (retry) ...`);
|
|
8544
|
-
addResult =
|
|
8700
|
+
addResult = gitAddAll(cwd);
|
|
8545
8701
|
console.log(` ${timestamp()} git add -A done (${((Date.now() - retryStart) / 1000).toFixed(1)}s)`);
|
|
8546
8702
|
if (addResult.success) {
|
|
8547
8703
|
console.log(colors.green('✓ git add succeeded after updating .gitignore'));
|
|
@@ -8611,7 +8767,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
8611
8767
|
if (verbose) {
|
|
8612
8768
|
console.log('Committing stray changes before version bump...');
|
|
8613
8769
|
}
|
|
8614
|
-
|
|
8770
|
+
gitAddAll(cwd);
|
|
8615
8771
|
gitCommit('Pre-version cleanup', cwd);
|
|
8616
8772
|
}
|
|
8617
8773
|
}
|
|
@@ -8800,7 +8956,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
8800
8956
|
}
|
|
8801
8957
|
// Commit stray changes and retry
|
|
8802
8958
|
console.log(colors.yellow(' Committing stray changes and retrying...'));
|
|
8803
|
-
const addRes =
|
|
8959
|
+
const addRes = gitAddAll(cwd);
|
|
8804
8960
|
if (addRes.success) {
|
|
8805
8961
|
const commitRes = gitCommit('Pre-version cleanup', cwd);
|
|
8806
8962
|
if (commitRes.success) {
|