@bobfrankston/npmglobalize 1.0.237 → 1.0.239
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 +3 -1
- package/lib.d.ts +40 -10
- package/lib.js +275 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ 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
|
|
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
99
|
Skip the prescan with `-no-prescan` / `-nps`.
|
|
100
100
|
|
|
@@ -351,6 +351,8 @@ For single-developer projects, this safely pulls remote changes before publishin
|
|
|
351
351
|
|
|
352
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
353
|
|
|
354
|
+
**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)
|
|
355
|
+
|
|
354
356
|
**Note:** This tool is designed for single-developer, single-branch workflows where automatic rebase and tag cleanup are safe operations.
|
|
355
357
|
|
|
356
358
|
### 📂 Git Repository Setup
|
package/lib.d.ts
CHANGED
|
@@ -425,7 +425,7 @@ 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: —
|
|
428
|
+
* by version range instead of file: — rewritten to file: by fixRegistryRefs (2026-09-23), nothing asked. */
|
|
429
429
|
kind?: 'unscoped' | 'registryRef';
|
|
430
430
|
package: string;
|
|
431
431
|
/** For 'registryRef': the directory of the CONSUMER whose package.json holds the range. */
|
|
@@ -448,15 +448,23 @@ export declare function findUndeclaredImports(cwd: string, pkg: any): Undeclared
|
|
|
448
448
|
* user resolve all problems before starting the publish cascade instead of
|
|
449
449
|
* being interrupted mid-run. */
|
|
450
450
|
export declare function prescanDepGraph(baseDir: string, visited?: Set<string>, verbose?: boolean, fileTargets?: Map<string, string>): PrescanIssue[];
|
|
451
|
-
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
|
|
451
|
+
/** Own-scope packages that some package in the file: graph takes from the registry (a version
|
|
452
|
+
* range) instead of from local source get REWRITTEN to a file: path, and npm install runs in
|
|
453
|
+
* that consumer so node_modules holds the link instead of the registry copy. The consumer is
|
|
454
|
+
* usually nested under other file: deps, where neither the `+` list nor the dependency tree
|
|
455
|
+
* shows it, which is why this runs in the prescan, before the cascade reads the consumer.
|
|
456
|
+
* A range whose local source cannot be found (not linked elsewhere in the graph, no sibling
|
|
457
|
+
* directory of that name) is reported as before and left alone. `"files": false` consumers
|
|
458
|
+
* were never collected (see prescanDepGraph). Every outcome is also recorded for the
|
|
459
|
+
* Issues Summary, since the prescan block has scrolled away by the end of a cascade.
|
|
460
|
+
* 2026-09-23 01:40 EDT — Claude Code (Fable 5.1), at Bob's direction: "you should also make
|
|
461
|
+
* sure that package.json for local packages such as httppoly uses file: rather than the npm
|
|
462
|
+
* version". Until now this only reported (v1.0.233), and certsupport kept
|
|
463
|
+
* `miscassists: ^1.0.61` through three runs. Returns false if an npm install failed. */
|
|
464
|
+
export declare function fixRegistryRefs(registryRefs: PrescanIssue[], fileTargets: Map<string, string>, opts?: {
|
|
465
|
+
dryRun?: boolean;
|
|
466
|
+
verbose?: boolean;
|
|
467
|
+
}): Promise<boolean>;
|
|
460
468
|
/** Report unscoped `file:` deps found by the prescan, without asking anything.
|
|
461
469
|
*
|
|
462
470
|
* An unscoped dep with private intent cannot reach npm, and npmglobalize does not
|
|
@@ -852,6 +860,28 @@ export declare function runNpmAuditFix(cwd: string): Promise<void>;
|
|
|
852
860
|
export declare function runNpmAudit(cwd: string): AuditResult;
|
|
853
861
|
/** Get the version of npmglobalize itself */
|
|
854
862
|
export declare function getToolVersion(): string;
|
|
863
|
+
/** Where the lock for a package directory lives. Exported for the test. */
|
|
864
|
+
export declare function dirLockPath(dir: string): string;
|
|
865
|
+
/** Take the lock for `dir`, waiting for a live holder to finish. Returns the lock path to
|
|
866
|
+
* hand back to releaseDirLock. Throws after `maxWaitMs` naming the holder and the lock file. */
|
|
867
|
+
export declare function acquireDirLock(dir: string, opts?: {
|
|
868
|
+
maxWaitMs?: number;
|
|
869
|
+
}): Promise<string>;
|
|
870
|
+
/** Release a lock taken by acquireDirLock. The file goes only when the outermost holder in
|
|
871
|
+
* this process releases, and only if it is still ours. */
|
|
872
|
+
export declare function releaseDirLock(lockPath: string): void;
|
|
873
|
+
/** For the exit handler: drop every lock this process holds, so a ^C leaves nothing behind
|
|
874
|
+
* (a leftover would only cost the next run a "stale lock removed" line, but be tidy). */
|
|
875
|
+
export declare function releaseAllDirLocks(): void;
|
|
876
|
+
/**
|
|
877
|
+
* Publish one package (and, by default, its file: dependencies first). Holds the
|
|
878
|
+
* per-directory lock for the whole run so a second npmglobalize reaching the same
|
|
879
|
+
* package waits instead of double-bumping.
|
|
880
|
+
* @param cwd - the package directory
|
|
881
|
+
* @param options - CLI options for this run
|
|
882
|
+
* @param configOptions - values read from the package's .globalize.json5
|
|
883
|
+
* @returns true when the package was published (or needed nothing), false on failure
|
|
884
|
+
*/
|
|
855
885
|
export declare function globalize(cwd: string, options?: GlobalizeOptions, configOptions?: Partial<GlobalizeOptions>): Promise<boolean>;
|
|
856
886
|
/** Orchestrate globalize across all packages in an npm workspace */
|
|
857
887
|
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).
|
|
@@ -2001,7 +2003,7 @@ export function findUndeclaredImports(cwd, pkg) {
|
|
|
2001
2003
|
* being interrupted mid-run. */
|
|
2002
2004
|
// 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction ("you need to warn about @installs
|
|
2003
2005
|
// 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
|
|
2006
|
+
// walk resolves is recorded (package name → directory) so fixRegistryRefs can say where the
|
|
2005
2007
|
// local source of a registry-installed package is. Own-scope deps given as a version range are
|
|
2006
2008
|
// collected as kind 'registryRef'. Seen in certsupport: `@bobfrankston/miscassists: ^1.0.61`
|
|
2007
2009
|
// committed in its package.json, two levels under pserve, where no tree listing showed it.
|
|
@@ -2104,34 +2106,96 @@ export function prescanDepGraph(baseDir, visited = new Set(), verbose = false, f
|
|
|
2104
2106
|
}
|
|
2105
2107
|
return issues;
|
|
2106
2108
|
}
|
|
2107
|
-
/**
|
|
2108
|
-
* (
|
|
2109
|
-
*
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2109
|
+
/** Where the local source of an own-scope package is, or null. First the map prescanDepGraph
|
|
2110
|
+
* filled (the package is linked as file: elsewhere in the graph); then the sibling-directory
|
|
2111
|
+
* convention every file: ref in these trees follows (`file:../<name>`): a directory named
|
|
2112
|
+
* after the unscoped package name beside the consumer, accepted only when the package.json
|
|
2113
|
+
* there carries exactly that package name. Nothing else is searched. */
|
|
2114
|
+
function findLocalSource(consumerDir, packageName, fileTargets) {
|
|
2115
|
+
const known = fileTargets.get(packageName);
|
|
2116
|
+
if (known)
|
|
2117
|
+
return known;
|
|
2118
|
+
const unscopedName = packageName.includes('/') ? packageName.slice(packageName.indexOf('/') + 1) : packageName;
|
|
2119
|
+
const sibling = path.join(path.dirname(consumerDir), unscopedName);
|
|
2120
|
+
try {
|
|
2121
|
+
if (readPackageJson(sibling).name === packageName)
|
|
2122
|
+
return sibling;
|
|
2123
|
+
}
|
|
2124
|
+
catch (error) {
|
|
2125
|
+
// Justified swallow: no package.json beside the consumer under that name is the
|
|
2126
|
+
// ordinary "not a sibling" outcome. The caller reports "local source not found" on
|
|
2127
|
+
// null, so the information becomes that message rather than being lost.
|
|
2128
|
+
}
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
/** Own-scope packages that some package in the file: graph takes from the registry (a version
|
|
2132
|
+
* range) instead of from local source get REWRITTEN to a file: path, and npm install runs in
|
|
2133
|
+
* that consumer so node_modules holds the link instead of the registry copy. The consumer is
|
|
2134
|
+
* usually nested under other file: deps, where neither the `+` list nor the dependency tree
|
|
2135
|
+
* shows it, which is why this runs in the prescan, before the cascade reads the consumer.
|
|
2136
|
+
* A range whose local source cannot be found (not linked elsewhere in the graph, no sibling
|
|
2137
|
+
* directory of that name) is reported as before and left alone. `"files": false` consumers
|
|
2138
|
+
* were never collected (see prescanDepGraph). Every outcome is also recorded for the
|
|
2139
|
+
* Issues Summary, since the prescan block has scrolled away by the end of a cascade.
|
|
2140
|
+
* 2026-09-23 01:40 EDT — Claude Code (Fable 5.1), at Bob's direction: "you should also make
|
|
2141
|
+
* sure that package.json for local packages such as httppoly uses file: rather than the npm
|
|
2142
|
+
* version". Until now this only reported (v1.0.233), and certsupport kept
|
|
2143
|
+
* `miscassists: ^1.0.61` through three runs. Returns false if an npm install failed. */
|
|
2144
|
+
export async function fixRegistryRefs(registryRefs, fileTargets, opts = {}) {
|
|
2116
2145
|
if (!registryRefs.length)
|
|
2117
|
-
return;
|
|
2146
|
+
return true;
|
|
2147
|
+
const { dryRun = false, verbose = false } = opts;
|
|
2118
2148
|
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):`));
|
|
2149
|
+
// One npm install per consumer, after all of its ranges are rewritten.
|
|
2150
|
+
const rewrittenConsumers = new Map(); // consumer dir → consumer name
|
|
2151
|
+
let ok = true;
|
|
2119
2152
|
for (const ref of registryRefs) {
|
|
2153
|
+
const pkgJsonPath = path.join(ref.path, 'package.json');
|
|
2120
2154
|
console.log(` ${colors.yellow('@')} ${ref.consumer} → ${ref.package} ${ref.range}`);
|
|
2121
|
-
console.log(colors.dim(` in ${
|
|
2122
|
-
const localSource =
|
|
2123
|
-
if (localSource) {
|
|
2124
|
-
console.log(colors.dim(` local source
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2155
|
+
console.log(colors.dim(` in ${pkgJsonPath}`));
|
|
2156
|
+
const localSource = findLocalSource(ref.path, ref.package, fileTargets);
|
|
2157
|
+
if (!localSource) {
|
|
2158
|
+
console.log(colors.dim(` local source not found (not linked as file: elsewhere in this graph, no sibling directory of that name) — left as is`));
|
|
2159
|
+
recordBuildIssue(ref.consumer, 'warning', `${ref.package} ${ref.range} is installed from npm, not file: — ${pkgJsonPath} (local source not found, left as is)`);
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
let relative = path.relative(ref.path, localSource).split(path.sep).join('/');
|
|
2163
|
+
if (!relative.startsWith('.'))
|
|
2164
|
+
relative = './' + relative;
|
|
2165
|
+
const fileSpec = `file:${relative}`;
|
|
2166
|
+
if (dryRun) {
|
|
2167
|
+
console.log(colors.dim(` [dry-run] would set to ${fileSpec} (${localSource}) and run npm install in ${ref.consumer}`));
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const pkg = readPackageJson(ref.path);
|
|
2171
|
+
let rewrote = false;
|
|
2172
|
+
for (const key of DEP_KEYS) {
|
|
2173
|
+
// A `.dependencies` backup (left by an interrupted transform) is the file: truth;
|
|
2174
|
+
// prescanDepGraph read the range from it, so the rewrite goes there too.
|
|
2175
|
+
for (const depsKey of ['.' + key, key]) {
|
|
2176
|
+
const deps = pkg[depsKey];
|
|
2177
|
+
if (deps && deps[ref.package] === ref.range) {
|
|
2178
|
+
deps[ref.package] = fileSpec;
|
|
2179
|
+
rewrote = true;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (!rewrote) {
|
|
2184
|
+
console.log(colors.dim(` range not found in ${pkgJsonPath} any more — left as is`));
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
writePackageJson(ref.path, pkg);
|
|
2188
|
+
console.log(colors.green(` ✓ set to ${fileSpec} (${localSource})`));
|
|
2189
|
+
recordBuildIssue(ref.consumer, 'warning', `${ref.package} ${ref.range} rewritten to ${fileSpec} in ${pkgJsonPath}`);
|
|
2190
|
+
rewrittenConsumers.set(ref.path, ref.consumer);
|
|
2191
|
+
}
|
|
2192
|
+
for (const [consumerDir, consumerName] of rewrittenConsumers) {
|
|
2193
|
+
console.log(colors.yellow(`↻ npm install in ${consumerName} (${consumerDir}) to replace the registry copy with the file: link`));
|
|
2194
|
+
if (!await npmInstallWithCleanRetry(consumerDir, verbose, `fixRegistryRefs: ${consumerDir}`))
|
|
2195
|
+
ok = false;
|
|
2196
|
+
}
|
|
2134
2197
|
console.log('');
|
|
2198
|
+
return ok;
|
|
2135
2199
|
}
|
|
2136
2200
|
/** Report unscoped `file:` deps found by the prescan, without asking anything.
|
|
2137
2201
|
*
|
|
@@ -7263,7 +7327,185 @@ async function doLocalInstall(cwd, options) {
|
|
|
7263
7327
|
}
|
|
7264
7328
|
return true;
|
|
7265
7329
|
}
|
|
7330
|
+
// ─── Per-directory lock ──────────────────────────────────────────────────────────────────
|
|
7331
|
+
// 2026-09-24 15:55 EDT — Claude Code (Fable 5.1), at Bob's direction.
|
|
7332
|
+
// Two npmglobalize instances reached the same packages within seconds (a dbx10 run and a
|
|
7333
|
+
// setcolor run whose cascade walks into dbx10 and upsnarf): both bumped, both published, and
|
|
7334
|
+
// the second one's tag-conflict handler deleted the first one's v1.0.8 tag and republished as
|
|
7335
|
+
// v1.0.9. Nothing stopped it because nothing knew another instance was there. Every
|
|
7336
|
+
// globalize() call now holds a lock on its package directory for its whole duration. A second
|
|
7337
|
+
// instance that meets a live lock WAITS for it (polling, with a status line every 10 s), then
|
|
7338
|
+
// re-reads the directory and continues — normally to "already published, nothing to do". A
|
|
7339
|
+
// lock whose owner PID is dead is stale and is removed. Reentrant within one process, so a
|
|
7340
|
+
// diamond in the dependency graph (A→B→D, A→C→D) does not deadlock on D. Two instances cannot
|
|
7341
|
+
// deadlock on each other either: both take locks consumer-first, dependency-second, in the
|
|
7342
|
+
// same DAG order.
|
|
7343
|
+
//
|
|
7344
|
+
// The lock lives OUTSIDE the package directory, under the temp dir, because the pre-release
|
|
7345
|
+
// commit does `git add -A` and would commit a file placed in the package.
|
|
7346
|
+
const DIR_LOCK_POLL_MS = 1_000;
|
|
7347
|
+
const DIR_LOCK_STATUS_EVERY_MS = 10_000;
|
|
7348
|
+
/** How long to wait on a live lock before giving up. A whole cascade can take minutes on Y:. */
|
|
7349
|
+
const DIR_LOCK_MAX_WAIT_MS = 15 * 60_000;
|
|
7350
|
+
/** An unreadable lock (half-written or garbage) is given this many polls to become readable
|
|
7351
|
+
* before it is treated as stale. */
|
|
7352
|
+
const DIR_LOCK_UNREADABLE_POLLS = 3;
|
|
7353
|
+
/** Locks this process holds: lock file path → reentrancy count. */
|
|
7354
|
+
const heldDirLocks = new Map();
|
|
7355
|
+
/** Where the lock for a package directory lives. Exported for the test. */
|
|
7356
|
+
export function dirLockPath(dir) {
|
|
7357
|
+
// Windows paths compare case-insensitively and npm/junctions mix y:/Y: (see
|
|
7358
|
+
// .llm/arborist-trace.md), so fold case there so both spellings map to one lock.
|
|
7359
|
+
let key = path.resolve(dir);
|
|
7360
|
+
if (process.platform === 'win32')
|
|
7361
|
+
key = key.toLowerCase();
|
|
7362
|
+
const slug = key.replace(/[^a-z0-9]+/gi, '_').slice(-80);
|
|
7363
|
+
const hash = createHash('sha1').update(key).digest('hex').slice(0, 8);
|
|
7364
|
+
return path.join(os.tmpdir(), 'npmglobalize-locks', `${slug}-${hash}.lock`);
|
|
7365
|
+
}
|
|
7366
|
+
function isPidAlive(pid) {
|
|
7367
|
+
try {
|
|
7368
|
+
process.kill(pid, 0);
|
|
7369
|
+
return true;
|
|
7370
|
+
}
|
|
7371
|
+
catch (error) {
|
|
7372
|
+
// EPERM: the process exists but is not ours to signal — still alive.
|
|
7373
|
+
return error.code === 'EPERM';
|
|
7374
|
+
}
|
|
7375
|
+
}
|
|
7376
|
+
function readDirLock(lockPath) {
|
|
7377
|
+
try {
|
|
7378
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
7379
|
+
}
|
|
7380
|
+
catch (error) {
|
|
7381
|
+
// Justified swallow: the lock vanished between our EEXIST and this read (its owner
|
|
7382
|
+
// finished), or it is half-written / garbage. The caller treats null as "unreadable",
|
|
7383
|
+
// re-polls a few times, and then removes the lock as stale — so an unreadable lock
|
|
7384
|
+
// cannot hold anyone forever and nothing is lost by not reporting the parse error.
|
|
7385
|
+
return null;
|
|
7386
|
+
}
|
|
7387
|
+
}
|
|
7388
|
+
function fmtLockTime(iso) {
|
|
7389
|
+
const time = new Date(iso);
|
|
7390
|
+
return isNaN(time.getTime()) ? iso : time.toLocaleTimeString();
|
|
7391
|
+
}
|
|
7392
|
+
/** Take the lock for `dir`, waiting for a live holder to finish. Returns the lock path to
|
|
7393
|
+
* hand back to releaseDirLock. Throws after `maxWaitMs` naming the holder and the lock file. */
|
|
7394
|
+
export async function acquireDirLock(dir, opts = {}) {
|
|
7395
|
+
const lockPath = dirLockPath(dir);
|
|
7396
|
+
const held = heldDirLocks.get(lockPath);
|
|
7397
|
+
if (held) { // reentrant: this process already holds it
|
|
7398
|
+
heldDirLocks.set(lockPath, held + 1);
|
|
7399
|
+
return lockPath;
|
|
7400
|
+
}
|
|
7401
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
7402
|
+
const info = {
|
|
7403
|
+
pid: process.pid,
|
|
7404
|
+
started: new Date().toISOString(),
|
|
7405
|
+
dir: path.resolve(dir),
|
|
7406
|
+
argv: process.argv.slice(1)
|
|
7407
|
+
};
|
|
7408
|
+
const maxWait = opts.maxWaitMs ?? DIR_LOCK_MAX_WAIT_MS;
|
|
7409
|
+
const waitStart = Date.now();
|
|
7410
|
+
let lastStatus = 0;
|
|
7411
|
+
let announced = false;
|
|
7412
|
+
let unreadablePolls = 0;
|
|
7413
|
+
for (;;) {
|
|
7414
|
+
try {
|
|
7415
|
+
fs.writeFileSync(lockPath, JSON.stringify(info, null, 2), { flag: 'wx' }); // atomic create
|
|
7416
|
+
heldDirLocks.set(lockPath, 1);
|
|
7417
|
+
if (announced)
|
|
7418
|
+
console.log(colors.green(`✓ ${info.dir} is free — continuing`));
|
|
7419
|
+
return lockPath;
|
|
7420
|
+
}
|
|
7421
|
+
catch (error) {
|
|
7422
|
+
if (error.code !== 'EEXIST')
|
|
7423
|
+
throw error;
|
|
7424
|
+
}
|
|
7425
|
+
const holder = readDirLock(lockPath);
|
|
7426
|
+
if (!holder) {
|
|
7427
|
+
if (++unreadablePolls >= DIR_LOCK_UNREADABLE_POLLS) {
|
|
7428
|
+
console.log(colors.yellow(`Removing unreadable npmglobalize lock ${lockPath}`));
|
|
7429
|
+
removeLockFile(lockPath);
|
|
7430
|
+
unreadablePolls = 0;
|
|
7431
|
+
continue;
|
|
7432
|
+
}
|
|
7433
|
+
}
|
|
7434
|
+
else if (!isPidAlive(holder.pid)) {
|
|
7435
|
+
console.log(colors.yellow(`Removing stale npmglobalize lock for ${holder.dir}: PID ${holder.pid} (started ${fmtLockTime(holder.started)}) is no longer running`));
|
|
7436
|
+
removeLockFile(lockPath);
|
|
7437
|
+
continue;
|
|
7438
|
+
}
|
|
7439
|
+
else {
|
|
7440
|
+
const waitedMs = Date.now() - waitStart;
|
|
7441
|
+
if (waitedMs > maxWait) {
|
|
7442
|
+
throw new Error(`Gave up after ${Math.round(waitedMs / 60_000)} min waiting for npmglobalize PID ${holder.pid} to finish with ${holder.dir}.\n` +
|
|
7443
|
+
` It has held the lock since ${fmtLockTime(holder.started)} running: ${holder.argv.join(' ')}\n` +
|
|
7444
|
+
` If PID ${holder.pid} is not an npmglobalize, delete ${lockPath} and rerun.`);
|
|
7445
|
+
}
|
|
7446
|
+
if (Date.now() - lastStatus >= DIR_LOCK_STATUS_EVERY_MS) {
|
|
7447
|
+
lastStatus = Date.now();
|
|
7448
|
+
announced = true;
|
|
7449
|
+
const soFar = waitedMs >= DIR_LOCK_STATUS_EVERY_MS ? ` — ${Math.round(waitedMs / 1000)} s so far` : '';
|
|
7450
|
+
console.log(colors.yellow(`Waiting for npmglobalize PID ${holder.pid} (started ${fmtLockTime(holder.started)}) to finish with ${holder.dir}${soFar}`));
|
|
7451
|
+
}
|
|
7452
|
+
}
|
|
7453
|
+
await new Promise(resolve => setTimeout(resolve, DIR_LOCK_POLL_MS));
|
|
7454
|
+
}
|
|
7455
|
+
}
|
|
7456
|
+
function removeLockFile(lockPath) {
|
|
7457
|
+
try {
|
|
7458
|
+
fs.unlinkSync(lockPath);
|
|
7459
|
+
}
|
|
7460
|
+
catch (error) {
|
|
7461
|
+
// ENOENT means the owner removed it first — that is the outcome we wanted.
|
|
7462
|
+
if (error.code !== 'ENOENT')
|
|
7463
|
+
throw error;
|
|
7464
|
+
}
|
|
7465
|
+
}
|
|
7466
|
+
/** Release a lock taken by acquireDirLock. The file goes only when the outermost holder in
|
|
7467
|
+
* this process releases, and only if it is still ours. */
|
|
7468
|
+
export function releaseDirLock(lockPath) {
|
|
7469
|
+
const held = heldDirLocks.get(lockPath);
|
|
7470
|
+
if (!held)
|
|
7471
|
+
return;
|
|
7472
|
+
if (held > 1) {
|
|
7473
|
+
heldDirLocks.set(lockPath, held - 1);
|
|
7474
|
+
return;
|
|
7475
|
+
}
|
|
7476
|
+
heldDirLocks.delete(lockPath);
|
|
7477
|
+
const current = readDirLock(lockPath);
|
|
7478
|
+
if (current && current.pid !== process.pid)
|
|
7479
|
+
return; // someone else's now; leave it
|
|
7480
|
+
removeLockFile(lockPath);
|
|
7481
|
+
}
|
|
7482
|
+
/** For the exit handler: drop every lock this process holds, so a ^C leaves nothing behind
|
|
7483
|
+
* (a leftover would only cost the next run a "stale lock removed" line, but be tidy). */
|
|
7484
|
+
export function releaseAllDirLocks() {
|
|
7485
|
+
for (const lockPath of [...heldDirLocks.keys()]) {
|
|
7486
|
+
heldDirLocks.set(lockPath, 1);
|
|
7487
|
+
releaseDirLock(lockPath);
|
|
7488
|
+
}
|
|
7489
|
+
}
|
|
7490
|
+
/**
|
|
7491
|
+
* Publish one package (and, by default, its file: dependencies first). Holds the
|
|
7492
|
+
* per-directory lock for the whole run so a second npmglobalize reaching the same
|
|
7493
|
+
* package waits instead of double-bumping.
|
|
7494
|
+
* @param cwd - the package directory
|
|
7495
|
+
* @param options - CLI options for this run
|
|
7496
|
+
* @param configOptions - values read from the package's .globalize.json5
|
|
7497
|
+
* @returns true when the package was published (or needed nothing), false on failure
|
|
7498
|
+
*/
|
|
7266
7499
|
export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
7500
|
+
const lockPath = await acquireDirLock(cwd);
|
|
7501
|
+
try {
|
|
7502
|
+
return await globalizeUnderLock(cwd, options, configOptions);
|
|
7503
|
+
}
|
|
7504
|
+
finally {
|
|
7505
|
+
releaseDirLock(lockPath);
|
|
7506
|
+
}
|
|
7507
|
+
}
|
|
7508
|
+
async function globalizeUnderLock(cwd, options = {}, configOptions = {}) {
|
|
7267
7509
|
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
|
|
7268
7510
|
publishDepsYes = false, // -pd: auto-yes to dep-cascade prompts (private only)
|
|
7269
7511
|
publicDeps = false, // -public-deps: cascade public visibility to all deps
|
|
@@ -7366,9 +7608,15 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
|
|
|
7366
7608
|
const fileTargets = new Map();
|
|
7367
7609
|
const scanned = prescanDepGraph(cwd, new Set(), verbose, fileTargets);
|
|
7368
7610
|
// 2026-09-21 — Claude Code (Fable 5.1), at Bob's direction: registry refs to own-scope
|
|
7369
|
-
// packages are
|
|
7370
|
-
//
|
|
7371
|
-
|
|
7611
|
+
// packages are handled apart from the prompt below, like unscoped deps — no question.
|
|
7612
|
+
// 2026-09-23 — Claude Code (Fable 5.1), at Bob's direction: they are now FIXED (rewritten
|
|
7613
|
+
// to file: and npm-installed), not just reported. A failed install stops the run like
|
|
7614
|
+
// ensureFileDepModules does, unless -force.
|
|
7615
|
+
const refsOk = await fixRegistryRefs(scanned.filter(i => i.kind === 'registryRef'), fileTargets, { dryRun, verbose });
|
|
7616
|
+
if (!refsOk && !force) {
|
|
7617
|
+
console.error(colors.red('npm install failed while linking own-scope deps as file: (see above). Stopping; use -force to continue anyway.'));
|
|
7618
|
+
return false;
|
|
7619
|
+
}
|
|
7372
7620
|
const allIssues = scanned.filter(i => i.kind !== 'registryRef');
|
|
7373
7621
|
// Unscoped deps are handled apart from the error/warning prompt below: nothing the
|
|
7374
7622
|
// user answers could change the outcome, so nothing is asked. Report, remember,
|