@bobfrankston/npmglobalize 1.0.238 → 1.0.240
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 +4 -0
- package/lib.d.ts +40 -2
- package/lib.js +277 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,6 +96,8 @@ An unscoped dependency with private intent is reported differently: it is not a
|
|
|
96
96
|
|
|
97
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 finds every such edge. Since 2026-09-23 it **fixes** them: the range is rewritten to a `file:` path in the consumer's `package.json` and `npm install` runs in that consumer, so `node_modules` holds the link. The local source is the directory the same package is linked from elsewhere in the graph, or else the sibling directory named after the package (the `file:../<name>` convention), accepted only when its `package.json` carries that exact name. A range whose local source cannot be found either way is reported and left alone. Nothing is asked. A package with `"files": false` in its `.globalize.json5` keeps npm refs on purpose and is not touched. Each outcome (rewritten, or not found) is repeated as one line in the end-of-run **Issues Summary**. A failed `npm install` stops the run unless `-force`. `-dry-run` prints what would be rewritten. (2026-09-21, fixer 2026-09-23)
|
|
98
98
|
|
|
99
|
+
**A `file:` dependency declared under the wrong key.** `"ubntdefs": "file:../ubntdefs"` links fine locally even when that package's own `package.json` says `@bobfrankston/ubntdefs`, because npm links a `file:` dep under whatever key you wrote. But the published `package.json` carries the version range under that key, so the registry copy of the consumer says `"ubntdefs": "^1.0.5"`, a name that is not on npm, and every registry install of it or of anything depending on it dies with E404 (dbx10 → setcolor, 2026-09-24). The prescan compares every `file:` key with the target's own name and **fixes** a mismatch: the key is renamed to the real name in the consumer's `package.json` (the `file:` path is kept), `npm uninstall <old key>` drops the link under the old name, and `npm install` links it under the real one. Nothing is asked; `-dry-run` prints what would change; each rename and any failed uninstall is repeated in the **Issues Summary**; a failed install stops the run unless `-force`. Imports in the consumer's code are not touched, so if anything imported by the old key, fix that by hand. A run with `-no-prescan` that reaches such a key stops in the transform with the same explanation rather than publishing a broken `package.json`. (2026-09-24)
|
|
100
|
+
|
|
99
101
|
Skip the prescan with `-no-prescan` / `-nps`.
|
|
100
102
|
|
|
101
103
|
#### Workspace skip prescan
|
|
@@ -351,6 +353,8 @@ For single-developer projects, this safely pulls remote changes before publishin
|
|
|
351
353
|
|
|
352
354
|
**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
355
|
|
|
356
|
+
**Per-directory lock (parallel runs)**: two `npmglobalize` instances can reach the same package within seconds — one run in a package, another in a consumer whose cascade walks into it. Without a guard both bump and publish, and the second one's tag-conflict handler deletes the first one's tag and republishes (dbx10 and upsnarf, 2026-09-24: 1.0.8 then 1.0.9 of each). Every package a run works on is now locked for the duration under `<temp>/npmglobalize-locks/` (outside the package, so the pre-release `git add -A` never commits it). A second instance that meets a live lock **waits** — polling every second, printing `Waiting for npmglobalize PID … to finish with <dir>` every 10 s — and then re-reads the package and continues, which normally means it finds the version already published and does nothing. A lock whose owner PID is no longer running is stale: it is removed, named, and taken. The wait gives up after 15 minutes with the holder's PID, start time, command line and the lock path, so a process that is not an npmglobalize can be spotted and the file deleted by hand. The lock is reentrant within one process, so a diamond in the dependency graph does not deadlock on the shared dependency, and two instances take locks in the same consumer-then-dependency order, so they cannot deadlock on each other. (2026-09-24)
|
|
357
|
+
|
|
354
358
|
**Note:** This tool is designed for single-developer, single-branch workflows where automatic rebase and tag cleanup are safe operations.
|
|
355
359
|
|
|
356
360
|
### 📂 Git Repository Setup
|
package/lib.d.ts
CHANGED
|
@@ -425,9 +425,13 @@ export interface PrescanIssue {
|
|
|
425
425
|
/** 'unscoped': an unscoped file: dep with private intent. Not an error and not a
|
|
426
426
|
* question — globalize reports it once, remembers it, and stops.
|
|
427
427
|
* 'registryRef': a package in the file: graph depends on one of its own-scope packages
|
|
428
|
-
* by version range instead of file: — rewritten to file: by fixRegistryRefs (2026-09-23), nothing asked.
|
|
429
|
-
|
|
428
|
+
* by version range instead of file: — rewritten to file: by fixRegistryRefs (2026-09-23), nothing asked.
|
|
429
|
+
* 'nameMismatch': a file: dep declared under a key that is not the target's package.json name —
|
|
430
|
+
* key renamed, old name uninstalled, reinstalled by fixDepKeyMismatches (2026-09-24), nothing asked. */
|
|
431
|
+
kind?: 'unscoped' | 'registryRef' | 'nameMismatch';
|
|
430
432
|
package: string;
|
|
433
|
+
/** 'nameMismatch' only: the key as written in the consumer's package.json (`ubntdefs`). */
|
|
434
|
+
key?: string;
|
|
431
435
|
/** For 'registryRef': the directory of the CONSUMER whose package.json holds the range. */
|
|
432
436
|
path: string;
|
|
433
437
|
/** 'registryRef' only: name of the package whose package.json declares the dep. */
|
|
@@ -465,6 +469,18 @@ export declare function fixRegistryRefs(registryRefs: PrescanIssue[], fileTarget
|
|
|
465
469
|
dryRun?: boolean;
|
|
466
470
|
verbose?: boolean;
|
|
467
471
|
}): Promise<boolean>;
|
|
472
|
+
/** Rename file: dependency keys that differ from the target package's own name, then
|
|
473
|
+
* `npm uninstall` the old name and `npm install` so node_modules holds the link under the
|
|
474
|
+
* real name. The published package.json carries the version range under the KEY, so a
|
|
475
|
+
* wrong key publishes a dependency nobody can install (dbx10 → `"ubntdefs": "^1.0.5"`,
|
|
476
|
+
* setcolor's install died with E404 on 2026-09-24). Nothing is asked; -dry-run prints what
|
|
477
|
+
* would change; each outcome lands in the Issues Summary.
|
|
478
|
+
* 2026-09-24 16:20 EDT — Claude Code (Fable 5.1), at Bob's direction: "A check would be to
|
|
479
|
+
* look at the file; to uninstall from the old name and reinstall with the new. This is safe." */
|
|
480
|
+
export declare function fixDepKeyMismatches(mismatches: PrescanIssue[], opts?: {
|
|
481
|
+
dryRun?: boolean;
|
|
482
|
+
verbose?: boolean;
|
|
483
|
+
}): Promise<boolean>;
|
|
468
484
|
/** Report unscoped `file:` deps found by the prescan, without asking anything.
|
|
469
485
|
*
|
|
470
486
|
* An unscoped dep with private intent cannot reach npm, and npmglobalize does not
|
|
@@ -860,6 +876,28 @@ export declare function runNpmAuditFix(cwd: string): Promise<void>;
|
|
|
860
876
|
export declare function runNpmAudit(cwd: string): AuditResult;
|
|
861
877
|
/** Get the version of npmglobalize itself */
|
|
862
878
|
export declare function getToolVersion(): string;
|
|
879
|
+
/** Where the lock for a package directory lives. Exported for the test. */
|
|
880
|
+
export declare function dirLockPath(dir: string): string;
|
|
881
|
+
/** Take the lock for `dir`, waiting for a live holder to finish. Returns the lock path to
|
|
882
|
+
* hand back to releaseDirLock. Throws after `maxWaitMs` naming the holder and the lock file. */
|
|
883
|
+
export declare function acquireDirLock(dir: string, opts?: {
|
|
884
|
+
maxWaitMs?: number;
|
|
885
|
+
}): Promise<string>;
|
|
886
|
+
/** Release a lock taken by acquireDirLock. The file goes only when the outermost holder in
|
|
887
|
+
* this process releases, and only if it is still ours. */
|
|
888
|
+
export declare function releaseDirLock(lockPath: string): void;
|
|
889
|
+
/** For the exit handler: drop every lock this process holds, so a ^C leaves nothing behind
|
|
890
|
+
* (a leftover would only cost the next run a "stale lock removed" line, but be tidy). */
|
|
891
|
+
export declare function releaseAllDirLocks(): void;
|
|
892
|
+
/**
|
|
893
|
+
* Publish one package (and, by default, its file: dependencies first). Holds the
|
|
894
|
+
* per-directory lock for the whole run so a second npmglobalize reaching the same
|
|
895
|
+
* package waits instead of double-bumping.
|
|
896
|
+
* @param cwd - the package directory
|
|
897
|
+
* @param options - CLI options for this run
|
|
898
|
+
* @param configOptions - values read from the package's .globalize.json5
|
|
899
|
+
* @returns true when the package was published (or needed nothing), false on failure
|
|
900
|
+
*/
|
|
863
901
|
export declare function globalize(cwd: string, options?: GlobalizeOptions, configOptions?: Partial<GlobalizeOptions>): Promise<boolean>;
|
|
864
902
|
/** Orchestrate globalize across all packages in an npm workspace */
|
|
865
903
|
export declare function globalizeWorkspace(rootDir: string, options?: GlobalizeOptions, configOptions?: Partial<GlobalizeOptions>): Promise<WorkspaceResult>;
|
package/lib.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import os from 'os';
|
|
14
14
|
import path from 'path';
|
|
15
|
+
import { createHash } from 'crypto';
|
|
15
16
|
import { execSync, spawn, spawnSync } from 'child_process';
|
|
16
17
|
import { builtinModules } from 'module';
|
|
17
18
|
import { readConfig as readUserConfig, writeConfig as writeUserConfig, configDir, configPath as userConfigPath } from '@bobfrankston/userconfig';
|
|
@@ -826,6 +827,7 @@ export function installCleanupHandlers() {
|
|
|
826
827
|
return;
|
|
827
828
|
cleanupHandlersInstalled = true;
|
|
828
829
|
process.on('exit', emergencyRestoreDeps);
|
|
830
|
+
process.on('exit', releaseAllDirLocks); // 2026-09-24 per-directory lock: drop ours on ^C / process.exit
|
|
829
831
|
// Idempotent abort: first ^C prints an immediate ack so the user doesn't
|
|
830
832
|
// press again and accidentally kill the process mid-restore. Subsequent
|
|
831
833
|
// signals while already aborting are absorbed (with a brief reminder).
|
|
@@ -1755,6 +1757,13 @@ export function transformDeps(pkg, baseDir, verbose = false, forcePublish = fals
|
|
|
1755
1757
|
console.log(colors.blue(` + ${name} → ${value}`));
|
|
1756
1758
|
try {
|
|
1757
1759
|
const targetPkg = readPackageJson(targetPath);
|
|
1760
|
+
// 2026-09-24 — Claude Code (Fable 5.1), at Bob's direction: the range below goes
|
|
1761
|
+
// under the KEY, so a key that is not the target's name would publish a dep nobody
|
|
1762
|
+
// can install (dbx10's "ubntdefs": "^1.0.5"). The prescan renames such keys; this
|
|
1763
|
+
// is the stop for a run that skipped it (-no-prescan).
|
|
1764
|
+
if (targetPkg.name && targetPkg.name !== name) {
|
|
1765
|
+
throw new Error(`dependency key "${name}" names a package whose package.json says ${targetPkg.name}; published as "${name}" it could not be installed. Rerun without -no-prescan to have the key renamed, or rename it by hand`);
|
|
1766
|
+
}
|
|
1758
1767
|
const targetVersion = targetPkg.version;
|
|
1759
1768
|
const npmVersion = '^' + targetVersion;
|
|
1760
1769
|
// Check if this version exists on npm (or if force publish)
|
|
@@ -2073,6 +2082,21 @@ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false, f
|
|
|
2073
2082
|
}
|
|
2074
2083
|
const depName = targetPkg.name || name;
|
|
2075
2084
|
fileTargets.set(depName, targetPath);
|
|
2085
|
+
// 2026-09-24 16:20 EDT — Claude Code (Fable 5.1), at Bob's direction. A file: dep
|
|
2086
|
+
// declared under a key that is not the target's package.json name ("ubntdefs":
|
|
2087
|
+
// file:… whose package.json says "@bobfrankston/ubntdefs") links fine locally, but
|
|
2088
|
+
// transformDeps writes the version range under the KEY, so the published dbx10 said
|
|
2089
|
+
// `"ubntdefs": "^1.0.5"` — a name that is not on npm — and every registry install of
|
|
2090
|
+
// a consumer (setcolor) died with E404. Flagged here; fixDepKeyMismatches renames the
|
|
2091
|
+
// key, uninstalls the old name and reinstalls. Bob: "uninstall from the old name and
|
|
2092
|
+
// reinstall with the new. This is safe."
|
|
2093
|
+
if (targetPkg.name && targetPkg.name !== name) {
|
|
2094
|
+
issues.push({
|
|
2095
|
+
severity: 'warning', kind: 'nameMismatch',
|
|
2096
|
+
package: targetPkg.name, key: name, path: baseDir, consumer: consumerName, range: value,
|
|
2097
|
+
message: `${consumerName} declares ${targetPkg.name} under the key "${name}" (${value})`
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2076
2100
|
// Unscoped + private-intent. This used to be a WARNING whose "continue" led
|
|
2077
2101
|
// into the cascade prompting to rename the dep by adding a scope. The cascade
|
|
2078
2102
|
// never renames a dependency now (see the _fromDep check in globalize), so it
|
|
@@ -2195,6 +2219,72 @@ export async function fixRegistryRefs(registryRefs, fileTargets, opts = {}) {
|
|
|
2195
2219
|
console.log('');
|
|
2196
2220
|
return ok;
|
|
2197
2221
|
}
|
|
2222
|
+
/** Rename file: dependency keys that differ from the target package's own name, then
|
|
2223
|
+
* `npm uninstall` the old name and `npm install` so node_modules holds the link under the
|
|
2224
|
+
* real name. The published package.json carries the version range under the KEY, so a
|
|
2225
|
+
* wrong key publishes a dependency nobody can install (dbx10 → `"ubntdefs": "^1.0.5"`,
|
|
2226
|
+
* setcolor's install died with E404 on 2026-09-24). Nothing is asked; -dry-run prints what
|
|
2227
|
+
* would change; each outcome lands in the Issues Summary.
|
|
2228
|
+
* 2026-09-24 16:20 EDT — Claude Code (Fable 5.1), at Bob's direction: "A check would be to
|
|
2229
|
+
* look at the file; to uninstall from the old name and reinstall with the new. This is safe." */
|
|
2230
|
+
export async function fixDepKeyMismatches(mismatches, opts = {}) {
|
|
2231
|
+
if (!mismatches.length)
|
|
2232
|
+
return true;
|
|
2233
|
+
const { dryRun = false, verbose = false } = opts;
|
|
2234
|
+
console.log(colors.yellow(`${mismatches.length} file: dependenc${mismatches.length === 1 ? 'y is' : 'ies are'} declared under a key that is not the package's own name (published that way, npm would look for a name that does not exist):`));
|
|
2235
|
+
// One uninstall + install per consumer, after all of its keys are renamed.
|
|
2236
|
+
const touched = new Map();
|
|
2237
|
+
for (const m of mismatches) {
|
|
2238
|
+
const pkgJsonPath = path.join(m.path, 'package.json');
|
|
2239
|
+
console.log(` ${colors.yellow('≠')} ${m.consumer}: "${m.key}" → ${m.package} (${m.range})`);
|
|
2240
|
+
console.log(colors.dim(` in ${pkgJsonPath}`));
|
|
2241
|
+
if (dryRun) {
|
|
2242
|
+
console.log(colors.dim(` [dry-run] would rename the key to ${m.package}, then npm uninstall ${m.key} and npm install in ${m.consumer}`));
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
const pkg = readPackageJson(m.path);
|
|
2246
|
+
let rewrote = false;
|
|
2247
|
+
for (const key of DEP_KEYS) {
|
|
2248
|
+
// A `.dependencies` backup (left by an interrupted transform) is the file: truth;
|
|
2249
|
+
// prescanDepGraph read the key from it, so the rename goes there too.
|
|
2250
|
+
for (const depsKey of ['.' + key, key]) {
|
|
2251
|
+
const deps = pkg[depsKey];
|
|
2252
|
+
if (!deps || deps[m.key] !== m.range)
|
|
2253
|
+
continue;
|
|
2254
|
+
delete deps[m.key];
|
|
2255
|
+
deps[m.package] = m.range;
|
|
2256
|
+
rewrote = true;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
if (!rewrote) {
|
|
2260
|
+
console.log(colors.dim(` key not found in ${pkgJsonPath} any more — left as is`));
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
writePackageJson(m.path, pkg);
|
|
2264
|
+
console.log(colors.green(` ✓ renamed to ${m.package}`));
|
|
2265
|
+
recordBuildIssue(m.consumer, 'warning', `dependency key "${m.key}" renamed to ${m.package} (the file: target's own name) in ${pkgJsonPath}`);
|
|
2266
|
+
const entry = touched.get(m.path) ?? { consumer: m.consumer, oldKeys: [], newNames: [] };
|
|
2267
|
+
entry.oldKeys.push(m.key);
|
|
2268
|
+
entry.newNames.push(m.package);
|
|
2269
|
+
touched.set(m.path, entry);
|
|
2270
|
+
}
|
|
2271
|
+
let ok = true;
|
|
2272
|
+
for (const [dir, { consumer, oldKeys, newNames }] of touched) {
|
|
2273
|
+
console.log(colors.yellow(`↻ npm uninstall ${oldKeys.join(' ')} in ${consumer} (${dir}) — drops the link under the old name`));
|
|
2274
|
+
const uninstall = await runCommandAsync('npm', ['uninstall', ...oldKeys], { cwd: dir, silent: !verbose });
|
|
2275
|
+
if (!uninstall.success) {
|
|
2276
|
+
// Not fatal: the install below links the real name; a leftover link under the old
|
|
2277
|
+
// name is inert (nothing imports it) and is named so it can be removed by hand.
|
|
2278
|
+
console.log(colors.yellow(` npm uninstall failed in ${dir}; node_modules/${oldKeys.join(', node_modules/')} may remain — continuing with npm install`));
|
|
2279
|
+
recordBuildIssue(consumer, 'warning', `npm uninstall ${oldKeys.join(' ')} failed in ${dir}; the old link may remain in node_modules`);
|
|
2280
|
+
}
|
|
2281
|
+
console.log(colors.yellow(`↻ npm install in ${consumer} (${dir}) to link ${newNames.join(', ')} under the real name`));
|
|
2282
|
+
if (!await npmInstallWithCleanRetry(dir, verbose, `fixDepKeyMismatches: ${dir}`))
|
|
2283
|
+
ok = false;
|
|
2284
|
+
}
|
|
2285
|
+
console.log('');
|
|
2286
|
+
return ok;
|
|
2287
|
+
}
|
|
2198
2288
|
/** Report unscoped `file:` deps found by the prescan, without asking anything.
|
|
2199
2289
|
*
|
|
2200
2290
|
* An unscoped dep with private intent cannot reach npm, and npmglobalize does not
|
|
@@ -7325,7 +7415,185 @@ async function doLocalInstall(cwd, options) {
|
|
|
7325
7415
|
}
|
|
7326
7416
|
return true;
|
|
7327
7417
|
}
|
|
7418
|
+
// ─── Per-directory lock ──────────────────────────────────────────────────────────────────
|
|
7419
|
+
// 2026-09-24 15:55 EDT — Claude Code (Fable 5.1), at Bob's direction.
|
|
7420
|
+
// Two npmglobalize instances reached the same packages within seconds (a dbx10 run and a
|
|
7421
|
+
// setcolor run whose cascade walks into dbx10 and upsnarf): both bumped, both published, and
|
|
7422
|
+
// the second one's tag-conflict handler deleted the first one's v1.0.8 tag and republished as
|
|
7423
|
+
// v1.0.9. Nothing stopped it because nothing knew another instance was there. Every
|
|
7424
|
+
// globalize() call now holds a lock on its package directory for its whole duration. A second
|
|
7425
|
+
// instance that meets a live lock WAITS for it (polling, with a status line every 10 s), then
|
|
7426
|
+
// re-reads the directory and continues — normally to "already published, nothing to do". A
|
|
7427
|
+
// lock whose owner PID is dead is stale and is removed. Reentrant within one process, so a
|
|
7428
|
+
// diamond in the dependency graph (A→B→D, A→C→D) does not deadlock on D. Two instances cannot
|
|
7429
|
+
// deadlock on each other either: both take locks consumer-first, dependency-second, in the
|
|
7430
|
+
// same DAG order.
|
|
7431
|
+
//
|
|
7432
|
+
// The lock lives OUTSIDE the package directory, under the temp dir, because the pre-release
|
|
7433
|
+
// commit does `git add -A` and would commit a file placed in the package.
|
|
7434
|
+
const DIR_LOCK_POLL_MS = 1_000;
|
|
7435
|
+
const DIR_LOCK_STATUS_EVERY_MS = 10_000;
|
|
7436
|
+
/** How long to wait on a live lock before giving up. A whole cascade can take minutes on Y:. */
|
|
7437
|
+
const DIR_LOCK_MAX_WAIT_MS = 15 * 60_000;
|
|
7438
|
+
/** An unreadable lock (half-written or garbage) is given this many polls to become readable
|
|
7439
|
+
* before it is treated as stale. */
|
|
7440
|
+
const DIR_LOCK_UNREADABLE_POLLS = 3;
|
|
7441
|
+
/** Locks this process holds: lock file path → reentrancy count. */
|
|
7442
|
+
const heldDirLocks = new Map();
|
|
7443
|
+
/** Where the lock for a package directory lives. Exported for the test. */
|
|
7444
|
+
export function dirLockPath(dir) {
|
|
7445
|
+
// Windows paths compare case-insensitively and npm/junctions mix y:/Y: (see
|
|
7446
|
+
// .llm/arborist-trace.md), so fold case there so both spellings map to one lock.
|
|
7447
|
+
let key = path.resolve(dir);
|
|
7448
|
+
if (process.platform === 'win32')
|
|
7449
|
+
key = key.toLowerCase();
|
|
7450
|
+
const slug = key.replace(/[^a-z0-9]+/gi, '_').slice(-80);
|
|
7451
|
+
const hash = createHash('sha1').update(key).digest('hex').slice(0, 8);
|
|
7452
|
+
return path.join(os.tmpdir(), 'npmglobalize-locks', `${slug}-${hash}.lock`);
|
|
7453
|
+
}
|
|
7454
|
+
function isPidAlive(pid) {
|
|
7455
|
+
try {
|
|
7456
|
+
process.kill(pid, 0);
|
|
7457
|
+
return true;
|
|
7458
|
+
}
|
|
7459
|
+
catch (error) {
|
|
7460
|
+
// EPERM: the process exists but is not ours to signal — still alive.
|
|
7461
|
+
return error.code === 'EPERM';
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
7464
|
+
function readDirLock(lockPath) {
|
|
7465
|
+
try {
|
|
7466
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
7467
|
+
}
|
|
7468
|
+
catch (error) {
|
|
7469
|
+
// Justified swallow: the lock vanished between our EEXIST and this read (its owner
|
|
7470
|
+
// finished), or it is half-written / garbage. The caller treats null as "unreadable",
|
|
7471
|
+
// re-polls a few times, and then removes the lock as stale — so an unreadable lock
|
|
7472
|
+
// cannot hold anyone forever and nothing is lost by not reporting the parse error.
|
|
7473
|
+
return null;
|
|
7474
|
+
}
|
|
7475
|
+
}
|
|
7476
|
+
function fmtLockTime(iso) {
|
|
7477
|
+
const time = new Date(iso);
|
|
7478
|
+
return isNaN(time.getTime()) ? iso : time.toLocaleTimeString();
|
|
7479
|
+
}
|
|
7480
|
+
/** Take the lock for `dir`, waiting for a live holder to finish. Returns the lock path to
|
|
7481
|
+
* hand back to releaseDirLock. Throws after `maxWaitMs` naming the holder and the lock file. */
|
|
7482
|
+
export async function acquireDirLock(dir, opts = {}) {
|
|
7483
|
+
const lockPath = dirLockPath(dir);
|
|
7484
|
+
const held = heldDirLocks.get(lockPath);
|
|
7485
|
+
if (held) { // reentrant: this process already holds it
|
|
7486
|
+
heldDirLocks.set(lockPath, held + 1);
|
|
7487
|
+
return lockPath;
|
|
7488
|
+
}
|
|
7489
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
7490
|
+
const info = {
|
|
7491
|
+
pid: process.pid,
|
|
7492
|
+
started: new Date().toISOString(),
|
|
7493
|
+
dir: path.resolve(dir),
|
|
7494
|
+
argv: process.argv.slice(1)
|
|
7495
|
+
};
|
|
7496
|
+
const maxWait = opts.maxWaitMs ?? DIR_LOCK_MAX_WAIT_MS;
|
|
7497
|
+
const waitStart = Date.now();
|
|
7498
|
+
let lastStatus = 0;
|
|
7499
|
+
let announced = false;
|
|
7500
|
+
let unreadablePolls = 0;
|
|
7501
|
+
for (;;) {
|
|
7502
|
+
try {
|
|
7503
|
+
fs.writeFileSync(lockPath, JSON.stringify(info, null, 2), { flag: 'wx' }); // atomic create
|
|
7504
|
+
heldDirLocks.set(lockPath, 1);
|
|
7505
|
+
if (announced)
|
|
7506
|
+
console.log(colors.green(`✓ ${info.dir} is free — continuing`));
|
|
7507
|
+
return lockPath;
|
|
7508
|
+
}
|
|
7509
|
+
catch (error) {
|
|
7510
|
+
if (error.code !== 'EEXIST')
|
|
7511
|
+
throw error;
|
|
7512
|
+
}
|
|
7513
|
+
const holder = readDirLock(lockPath);
|
|
7514
|
+
if (!holder) {
|
|
7515
|
+
if (++unreadablePolls >= DIR_LOCK_UNREADABLE_POLLS) {
|
|
7516
|
+
console.log(colors.yellow(`Removing unreadable npmglobalize lock ${lockPath}`));
|
|
7517
|
+
removeLockFile(lockPath);
|
|
7518
|
+
unreadablePolls = 0;
|
|
7519
|
+
continue;
|
|
7520
|
+
}
|
|
7521
|
+
}
|
|
7522
|
+
else if (!isPidAlive(holder.pid)) {
|
|
7523
|
+
console.log(colors.yellow(`Removing stale npmglobalize lock for ${holder.dir}: PID ${holder.pid} (started ${fmtLockTime(holder.started)}) is no longer running`));
|
|
7524
|
+
removeLockFile(lockPath);
|
|
7525
|
+
continue;
|
|
7526
|
+
}
|
|
7527
|
+
else {
|
|
7528
|
+
const waitedMs = Date.now() - waitStart;
|
|
7529
|
+
if (waitedMs > maxWait) {
|
|
7530
|
+
throw new Error(`Gave up after ${Math.round(waitedMs / 60_000)} min waiting for npmglobalize PID ${holder.pid} to finish with ${holder.dir}.\n` +
|
|
7531
|
+
` It has held the lock since ${fmtLockTime(holder.started)} running: ${holder.argv.join(' ')}\n` +
|
|
7532
|
+
` If PID ${holder.pid} is not an npmglobalize, delete ${lockPath} and rerun.`);
|
|
7533
|
+
}
|
|
7534
|
+
if (Date.now() - lastStatus >= DIR_LOCK_STATUS_EVERY_MS) {
|
|
7535
|
+
lastStatus = Date.now();
|
|
7536
|
+
announced = true;
|
|
7537
|
+
const soFar = waitedMs >= DIR_LOCK_STATUS_EVERY_MS ? ` — ${Math.round(waitedMs / 1000)} s so far` : '';
|
|
7538
|
+
console.log(colors.yellow(`Waiting for npmglobalize PID ${holder.pid} (started ${fmtLockTime(holder.started)}) to finish with ${holder.dir}${soFar}`));
|
|
7539
|
+
}
|
|
7540
|
+
}
|
|
7541
|
+
await new Promise(resolve => setTimeout(resolve, DIR_LOCK_POLL_MS));
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7544
|
+
function removeLockFile(lockPath) {
|
|
7545
|
+
try {
|
|
7546
|
+
fs.unlinkSync(lockPath);
|
|
7547
|
+
}
|
|
7548
|
+
catch (error) {
|
|
7549
|
+
// ENOENT means the owner removed it first — that is the outcome we wanted.
|
|
7550
|
+
if (error.code !== 'ENOENT')
|
|
7551
|
+
throw error;
|
|
7552
|
+
}
|
|
7553
|
+
}
|
|
7554
|
+
/** Release a lock taken by acquireDirLock. The file goes only when the outermost holder in
|
|
7555
|
+
* this process releases, and only if it is still ours. */
|
|
7556
|
+
export function releaseDirLock(lockPath) {
|
|
7557
|
+
const held = heldDirLocks.get(lockPath);
|
|
7558
|
+
if (!held)
|
|
7559
|
+
return;
|
|
7560
|
+
if (held > 1) {
|
|
7561
|
+
heldDirLocks.set(lockPath, held - 1);
|
|
7562
|
+
return;
|
|
7563
|
+
}
|
|
7564
|
+
heldDirLocks.delete(lockPath);
|
|
7565
|
+
const current = readDirLock(lockPath);
|
|
7566
|
+
if (current && current.pid !== process.pid)
|
|
7567
|
+
return; // someone else's now; leave it
|
|
7568
|
+
removeLockFile(lockPath);
|
|
7569
|
+
}
|
|
7570
|
+
/** For the exit handler: drop every lock this process holds, so a ^C leaves nothing behind
|
|
7571
|
+
* (a leftover would only cost the next run a "stale lock removed" line, but be tidy). */
|
|
7572
|
+
export function releaseAllDirLocks() {
|
|
7573
|
+
for (const lockPath of [...heldDirLocks.keys()]) {
|
|
7574
|
+
heldDirLocks.set(lockPath, 1);
|
|
7575
|
+
releaseDirLock(lockPath);
|
|
7576
|
+
}
|
|
7577
|
+
}
|
|
7578
|
+
/**
|
|
7579
|
+
* Publish one package (and, by default, its file: dependencies first). Holds the
|
|
7580
|
+
* per-directory lock for the whole run so a second npmglobalize reaching the same
|
|
7581
|
+
* package waits instead of double-bumping.
|
|
7582
|
+
* @param cwd - the package directory
|
|
7583
|
+
* @param options - CLI options for this run
|
|
7584
|
+
* @param configOptions - values read from the package's .globalize.json5
|
|
7585
|
+
* @returns true when the package was published (or needed nothing), false on failure
|
|
7586
|
+
*/
|
|
7328
7587
|
export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
7588
|
+
const lockPath = await acquireDirLock(cwd);
|
|
7589
|
+
try {
|
|
7590
|
+
return await globalizeUnderLock(cwd, options, configOptions);
|
|
7591
|
+
}
|
|
7592
|
+
finally {
|
|
7593
|
+
releaseDirLock(lockPath);
|
|
7594
|
+
}
|
|
7595
|
+
}
|
|
7596
|
+
async function globalizeUnderLock(cwd, options = {}, configOptions = {}) {
|
|
7329
7597
|
const { bump = 'patch', noPublish = false, cleanup = false, install = false, link = false, wsl = false, force = false, files = true, dryRun = false, quiet = true, verbose = false, init = false, gitVisibility = 'private', npmVisibility = 'private', message, conform = false, asis = false, updateDeps = false, updateMajor = false, publishDeps = true, // Default to publishing deps for safety
|
|
7330
7598
|
publishDepsYes = false, // -pd: auto-yes to dep-cascade prompts (private only)
|
|
7331
7599
|
publicDeps = false, // -public-deps: cascade public visibility to all deps
|
|
@@ -7437,7 +7705,15 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
7437
7705
|
console.error(colors.red('npm install failed while linking own-scope deps as file: (see above). Stopping; use -force to continue anyway.'));
|
|
7438
7706
|
return false;
|
|
7439
7707
|
}
|
|
7440
|
-
|
|
7708
|
+
// 2026-09-24 — Claude Code (Fable 5.1), at Bob's direction: file: deps declared under a
|
|
7709
|
+
// key that is not the target's name are renamed + reinstalled the same way (see
|
|
7710
|
+
// fixDepKeyMismatches); a failed install stops the run unless -force.
|
|
7711
|
+
const mismatchOk = await fixDepKeyMismatches(scanned.filter(i => i.kind === 'nameMismatch'), { dryRun, verbose });
|
|
7712
|
+
if (!mismatchOk && !force) {
|
|
7713
|
+
console.error(colors.red('npm install failed after renaming a dependency key to the package\'s own name (see above). Stopping; use -force to continue anyway.'));
|
|
7714
|
+
return false;
|
|
7715
|
+
}
|
|
7716
|
+
const allIssues = scanned.filter(i => i.kind !== 'registryRef' && i.kind !== 'nameMismatch');
|
|
7441
7717
|
// Unscoped deps are handled apart from the error/warning prompt below: nothing the
|
|
7442
7718
|
// user answers could change the outcome, so nothing is asked. Report, remember,
|
|
7443
7719
|
// stop. See reportUnscopedDeps.
|