@bobfrankston/npmglobalize 1.0.238 → 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.
Files changed (4) hide show
  1. package/README.md +2 -0
  2. package/lib.d.ts +22 -0
  3. package/lib.js +180 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -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
@@ -860,6 +860,28 @@ export declare function runNpmAuditFix(cwd: string): Promise<void>;
860
860
  export declare function runNpmAudit(cwd: string): AuditResult;
861
861
  /** Get the version of npmglobalize itself */
862
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
+ */
863
885
  export declare function globalize(cwd: string, options?: GlobalizeOptions, configOptions?: Partial<GlobalizeOptions>): Promise<boolean>;
864
886
  /** Orchestrate globalize across all packages in an npm workspace */
865
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).
@@ -7325,7 +7327,185 @@ async function doLocalInstall(cwd, options) {
7325
7327
  }
7326
7328
  return true;
7327
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
+ */
7328
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 = {}) {
7329
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
7330
7510
  publishDepsYes = false, // -pd: auto-yes to dep-cascade prompts (private only)
7331
7511
  publicDeps = false, // -public-deps: cascade public visibility to all deps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.238",
3
+ "version": "1.0.239",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",