@gjsify/cli 0.13.2 → 0.15.1

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.
@@ -19,6 +19,8 @@ import * as os from 'node:os';
19
19
  import { Range, SemVer, maxSatisfying, satisfies } from '@gjsify/semver';
20
20
  import { DEFAULT_REGISTRY, fetchPackument, fetchPackumentConditional, fetchTarball, parseNpmrc, registryFor, } from '@gjsify/npm-registry';
21
21
  import { extractTarball } from '@gjsify/tar';
22
+ import { atomicWriteStrict } from './install-cache-fs.js';
23
+ import { acquireInstallLock } from './install-lock.js';
22
24
  import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
23
25
  import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
24
26
  import { assertNativeBackendNodeVersion } from './node-version.js';
@@ -40,6 +42,23 @@ export async function installPackagesNative(opts) {
40
42
  fs.mkdirSync(opts.prefix, { recursive: true });
41
43
  const npmrc = await loadNpmrc(opts);
42
44
  const log = makeLogger(opts.verbose ?? false);
45
+ // Serialize every mutation of THIS prefix across processes (ADR 0001):
46
+ // concurrent installs into the same node_modules used to interleave
47
+ // `rmSync` + extract on shared destination dirs and tear the lockfile.
48
+ // Re-entrant within the process (workspace installs already hold the
49
+ // root-prefix lock — this just bumps a refcount). Installs into other
50
+ // prefixes proceed concurrently; the shared XDG caches stay lock-free
51
+ // because their tmp+rename writes are atomic (install-cache-fs.ts).
52
+ const lock = await acquireInstallLock(opts.prefix, { signal: opts.signal });
53
+ try {
54
+ return await installPackagesNativeLocked(opts, npmrc, log);
55
+ }
56
+ finally {
57
+ lock.release();
58
+ }
59
+ }
60
+ /** Body of {@link installPackagesNative}, run while holding the prefix lock. */
61
+ async function installPackagesNativeLocked(opts, npmrc, log) {
43
62
  const progress = opts.progress;
44
63
  const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
45
64
  const existingLock = readLockfile(lockfilePath);
@@ -76,7 +95,7 @@ export async function installPackagesNative(opts) {
76
95
  // (and silently bumping transitive deps) on a one-package add.
77
96
  const preferred = !opts.refreshLockfile && existingLock ? buildPreferredVersions(existingLock) : undefined;
78
97
  log('install: resolving %d top-level spec(s) → %s%s', opts.specs.length, opts.prefix, preferred ? ` (preserving ${preferred.size} locked name(s))` : '');
79
- nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames);
98
+ nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames, opts.specOrigins);
80
99
  if (opts.lockfile) {
81
100
  writeLockfile(lockfilePath, opts.specs, nodes);
82
101
  log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
@@ -171,7 +190,13 @@ preferredVersions,
171
190
  * the published version (and its subtree) never enters the resolved tree or
172
191
  * the lockfile.
173
192
  */
174
- workspaceNames) {
193
+ workspaceNames,
194
+ /**
195
+ * Requester labels for top-level specs (`"<name>@<range>"` → workspace
196
+ * names) — see `InstallOptions.specOrigins`. Used only to attribute the
197
+ * version-conflict warning.
198
+ */
199
+ specOrigins) {
175
200
  progress?.beginPhase('resolve', specs.length);
176
201
  const applyOverride = (name, range) => {
177
202
  if (!overrides)
@@ -197,12 +222,37 @@ workspaceNames) {
197
222
  const byPath = new Map();
198
223
  /** Root placements indexed by name for the hoist-vs-nest decision. */
199
224
  const root = new Map();
200
- const queue = specs.map(parseSpec).map((s) => ({
201
- from: null,
202
- name: s.name,
203
- range: applyOverride(s.name, s.range),
204
- required: true,
205
- }));
225
+ // Top-level range bookkeeping for the version-conflict warning:
226
+ // `name → (applied range → requester labels)`. Only TOP-LEVEL specs
227
+ // participate: conflicting transitive edges are resolved correctly by
228
+ // nesting (Phase D.7b), but conflicting top-level specs — the shape
229
+ // `workspaceInstall` produces when two workspaces declare incompatible
230
+ // ranges of the same external dep — all compete for the single root
231
+ // slot, and today the resolver silently keeps one version for everyone
232
+ // (per-workspace dedup is Phase D.8). Until that lands, the conflict is
233
+ // surfaced loudly after the resolve (see emitTopLevelConflictWarnings).
234
+ const topLevelRanges = new Map();
235
+ const queue = specs.map(parseSpec).map((s) => {
236
+ const range = applyOverride(s.name, s.range);
237
+ let ranges = topLevelRanges.get(s.name);
238
+ if (!ranges) {
239
+ ranges = new Map();
240
+ topLevelRanges.set(s.name, ranges);
241
+ }
242
+ let requesters = ranges.get(range);
243
+ if (!requesters) {
244
+ requesters = new Set();
245
+ ranges.set(range, requesters);
246
+ }
247
+ for (const origin of specOrigins?.get(`${s.name}@${s.range}`) ?? [])
248
+ requesters.add(origin);
249
+ return {
250
+ from: null,
251
+ name: s.name,
252
+ range,
253
+ required: true,
254
+ };
255
+ });
206
256
  // Wave-based BFS. Each iteration drains the current queue level, prefetches
207
257
  // every not-yet-cached packument in that level IN PARALLEL (bounded), then
208
258
  // applies placement SERIALLY in the same FIFO order the single-edge loop
@@ -329,8 +379,50 @@ workspaceNames) {
329
379
  }
330
380
  }
331
381
  progress?.endPhase('resolve');
382
+ emitTopLevelConflictWarnings(topLevelRanges, root);
332
383
  return Array.from(byPath.values());
333
384
  }
385
+ /**
386
+ * Loud, single-line-per-package warning for top-level version-range
387
+ * conflicts (ADR 0001, step 3). Fires when two or more top-level specs
388
+ * requested DIFFERENT ranges of the same package and the version that ended
389
+ * up hoisted to the root does not satisfy all of them — i.e. some requester
390
+ * silently got a version outside its declared range. Compatible ranges
391
+ * (`^1.2` + `^1.4` both satisfied by `1.9.0`) stay silent; ranges that are
392
+ * not semver (dist-tags like `latest`) cannot be compared and are skipped.
393
+ *
394
+ * This makes the current single-root-slot behavior honest instead of silent;
395
+ * the real fix — a per-workspace dedup pass that gives each conflicting
396
+ * requester its own nested copy — is Phase D.8 (see STATUS.md).
397
+ */
398
+ function emitTopLevelConflictWarnings(topLevelRanges, root) {
399
+ for (const [name, ranges] of topLevelRanges) {
400
+ if (ranges.size < 2)
401
+ continue;
402
+ const placed = root.get(name);
403
+ if (!placed)
404
+ continue; // workspace-provided or never placed — nothing installed to warn about
405
+ const describe = (range, requesters) => requesters.size > 0 ? `${range} (requested by ${[...requesters].join(', ')})` : range;
406
+ const satisfied = [];
407
+ const unsatisfied = [];
408
+ for (const [range, requesters] of ranges) {
409
+ let ok;
410
+ try {
411
+ ok = satisfies(placed.version, new Range(range));
412
+ }
413
+ catch {
414
+ continue; // dist-tag / non-semver range — not comparable
415
+ }
416
+ (ok ? satisfied : unsatisfied).push(describe(range, requesters));
417
+ }
418
+ if (unsatisfied.length === 0)
419
+ continue;
420
+ console.warn(`[gjsify] warning: version conflict for ${name}: installed ${placed.version} at ${placed.installPath} — ` +
421
+ `does NOT satisfy ${unsatisfied.join(', ')}` +
422
+ (satisfied.length > 0 ? `; satisfies ${satisfied.join(', ')}` : '') +
423
+ `. One hoisted copy serves all requesters until the per-workspace dedup pass lands (Phase D.8).`);
424
+ }
425
+ }
334
426
  /**
335
427
  * Warm `packumentCache` for a batch of package names with bounded parallelism
336
428
  * (same `DEFAULT_CONCURRENCY` width as the download pool). Rejections — e.g. a
@@ -502,7 +594,10 @@ function writeLockfile(lockfilePath, specs, nodes) {
502
594
  requested: [...specs],
503
595
  packages,
504
596
  };
505
- fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
597
+ // Atomic tmp+rename so a crash mid-write (or a reader racing the writer)
598
+ // can never observe a torn gjsify-lock.json — `--immutable` would
599
+ // otherwise hard-fail on the corrupt file with a misleading error.
600
+ atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
506
601
  }
507
602
  /**
508
603
  * Build the lockfile-preservation oracle: `name → every version currently
@@ -83,6 +83,16 @@ export interface InstallOptions {
83
83
  * itself.
84
84
  */
85
85
  workspaceNames?: Set<string>;
86
+ /**
87
+ * Native backend only: requester labels for top-level specs, keyed by the
88
+ * exact `"<name>@<range>"` spec string. `workspaceInstall` records which
89
+ * workspace(s) declared each aggregated external spec so the resolver's
90
+ * version-conflict warning can name BOTH sides of a conflict ("^1 requested
91
+ * by @scope/a, ^2 requested by @scope/b"). Optional — flows without
92
+ * per-requester attribution (single-project installs, dlx) omit it and the
93
+ * warning falls back to the bare ranges.
94
+ */
95
+ specOrigins?: Map<string, string[]>;
86
96
  }
87
97
  export interface InstallResult {
88
98
  /** Top-level packages that were requested, with the version each
@@ -13,6 +13,15 @@ export declare function gjsifyCacheRoot(kind: string, layoutVersion: string): st
13
13
  * install; the caller proceeds with its in-memory copy.
14
14
  */
15
15
  export declare function atomicWrite(path: string, bytes: Uint8Array | string): void;
16
+ /**
17
+ * Strict sibling of {@link atomicWrite}: identical tmp+`rename` pattern, but
18
+ * failures PROPAGATE to the caller. Use for files whose corruption/loss must
19
+ * abort the operation (the `gjsify-lock.json` writer) rather than degrade
20
+ * silently like a cache entry. `rename` over the destination is atomic on
21
+ * POSIX, so a concurrent reader (or a crash mid-write) sees either the old
22
+ * complete file or the new complete file — never a torn one.
23
+ */
24
+ export declare function atomicWriteStrict(path: string, bytes: Uint8Array | string): void;
16
25
  /**
17
26
  * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
18
27
  * missing file, a zero-byte file (an interrupted previous write), or any read
@@ -1,12 +1,15 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Shared filesystem primitives for `gjsify install`'s on-disk caches.
2
+ // Shared filesystem primitives for `gjsify install`'s on-disk caches and its
3
+ // lockfile writer.
3
4
  //
4
5
  // Both the content-addressable tarball cache (install-tarball-cache.ts) and the
5
6
  // packument metadata cache (install-packument-cache.ts) need the same three
6
7
  // things: an `XDG_CACHE_HOME`-honouring cache root, an atomic write (so a
7
8
  // concurrent install never observes a half-written entry), and a read that
8
9
  // treats a missing / zero-byte / unreadable file as a MISS. Centralising them
9
- // here keeps the two caches byte-for-byte consistent.
10
+ // here keeps the two caches byte-for-byte consistent. The lockfile writer
11
+ // (`gjsify-lock.json`) shares the same tmp+rename pattern via the strict
12
+ // variant so an interrupted install can never leave a torn lockfile behind.
10
13
  //
11
14
  // Out of scope: the dlx cache (`dlx-cache.ts`) has a different layout
12
15
  // (`gjsify/dlx`, sha256 + symlink-swap) and its own TTL/prepare-dir concerns —
@@ -34,15 +37,26 @@ export function gjsifyCacheRoot(kind, layoutVersion) {
34
37
  */
35
38
  export function atomicWrite(path, bytes) {
36
39
  try {
37
- mkdirSync(join(path, '..'), { recursive: true });
38
- const tmp = `${path}.tmp.${process.pid}`;
39
- writeFileSync(tmp, bytes);
40
- renameSync(tmp, path);
40
+ atomicWriteStrict(path, bytes);
41
41
  }
42
42
  catch {
43
43
  /* best-effort — a cache-write failure must not break the install */
44
44
  }
45
45
  }
46
+ /**
47
+ * Strict sibling of {@link atomicWrite}: identical tmp+`rename` pattern, but
48
+ * failures PROPAGATE to the caller. Use for files whose corruption/loss must
49
+ * abort the operation (the `gjsify-lock.json` writer) rather than degrade
50
+ * silently like a cache entry. `rename` over the destination is atomic on
51
+ * POSIX, so a concurrent reader (or a crash mid-write) sees either the old
52
+ * complete file or the new complete file — never a torn one.
53
+ */
54
+ export function atomicWriteStrict(path, bytes) {
55
+ mkdirSync(join(path, '..'), { recursive: true });
56
+ const tmp = `${path}.tmp.${process.pid}`;
57
+ writeFileSync(tmp, bytes);
58
+ renameSync(tmp, path);
59
+ }
46
60
  /**
47
61
  * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
48
62
  * missing file, a zero-byte file (an interrupted previous write), or any read
@@ -0,0 +1,15 @@
1
+ export interface InstallLockHandle {
2
+ /** Release the lock (refcounted; the on-disk dir is removed at count 0). */
3
+ release(): void;
4
+ }
5
+ /**
6
+ * Acquire the cross-process install lock for `prefix`. Resolves once this
7
+ * process may mutate `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`;
8
+ * the caller MUST `release()` in a `finally`. Waits (with backoff) while a
9
+ * live concurrent install holds the lock; steals provably-stale locks (dead
10
+ * owner pid / exceeded staleness budget). `signal` aborts the wait — wired
11
+ * to the overall install budget so a waiter never outlives its own timeout.
12
+ */
13
+ export declare function acquireInstallLock(prefix: string, opts?: {
14
+ signal?: AbortSignal;
15
+ }): Promise<InstallLockHandle>;
@@ -0,0 +1,293 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Cross-process install lock for `gjsify install` (ADR 0001, step 2).
3
+ //
4
+ // WHAT is locked: mutation of ONE install prefix — its `node_modules/` tree,
5
+ // its `gjsify-lock.json`, and (through the same code path) the user-global
6
+ // prefix that `gjsify install -g` extracts into. Two processes installing
7
+ // into the SAME prefix used to interleave `rmSync` + extract on the same
8
+ // destination directories (corrupt trees, spurious ENOENT mid-extract) and
9
+ // tear the lockfile. The lock serializes them. Installs into DIFFERENT
10
+ // prefixes stay fully concurrent.
11
+ //
12
+ // WHAT is deliberately NOT locked: the shared XDG caches
13
+ // (`$XDG_CACHE_HOME/gjsify/{tarballs,metadata}`). Those are content-addressed
14
+ // and written via atomic tmp+`rename` (`install-cache-fs.ts`), so concurrent
15
+ // writers are already safe lock-free — identical-content last-rename-wins.
16
+ //
17
+ // Mechanism: `mkdir` of `<prefix>/node_modules/.gjsify-install-lock` is the
18
+ // atomic acquire. mkdir has exclusive-create semantics on every runtime we
19
+ // target: Node's `mkdirSync` throws EEXIST, and the GJS fs polyfill maps
20
+ // Gio's G_IO_ERROR_EXISTS to the same code. (A lock FILE with `flag: 'wx'`
21
+ // would NOT work — the GJS `writeFileSync` polyfill ignores open flags and
22
+ // happily overwrites.) An `owner.json` inside the dir records pid + start
23
+ // time for staleness checks and diagnostics.
24
+ //
25
+ // Staleness: a crashed install cannot release its lock, so contenders steal
26
+ // when
27
+ // (a) the owner pid is provably dead (`/proc/<pid>` on Linux — readable
28
+ // under both Node and GJS; a kill(0) probe covers non-/proc Nodes), or
29
+ // (b) the lock outlives GJSIFY_INSTALL_LOCK_STALE_MS (default 35 min —
30
+ // just above the 30-min overall install budget, so a budget-bound
31
+ // install always times out and exits before its lock can be stolen
32
+ // out from under it), or
33
+ // (c) `mkdir` succeeded but `owner.json` never appeared within a short
34
+ // grace period (the owner died between the two steps).
35
+ // The steal itself is race-free: the stale dir is claimed via an atomic
36
+ // `rename` to a unique sibling name — exactly one contender's rename
37
+ // succeeds; losers see ENOENT and simply retry the acquire loop.
38
+ //
39
+ // Re-entrant per process: `workspaceInstall` holds the root-prefix lock for
40
+ // the whole workspace flow while the inner `installPackagesNative` calls
41
+ // re-acquire the same prefix — those just bump a refcount.
42
+ //
43
+ // Escape hatch: GJSIFY_INSTALL_LOCK=0 disables locking entirely (e.g. for a
44
+ // network filesystem with broken rename/mkdir atomicity, or an outer tool
45
+ // that already serializes installs).
46
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
47
+ import { dirname, join, resolve } from 'node:path';
48
+ const LOCK_DIR_NAME = '.gjsify-install-lock';
49
+ const OWNER_FILE = 'owner.json';
50
+ /** Default staleness budget — see the header comment for why 35 min. */
51
+ const DEFAULT_STALE_MS = 35 * 60_000;
52
+ /** Window between a winner's `mkdir` and its `owner.json` write. */
53
+ const ACQUIRE_GRACE_MS = 15_000;
54
+ const WAIT_BASE_MS = 150;
55
+ const WAIT_MAX_MS = 1_000;
56
+ /** Locks held by THIS process, keyed by canonical lock-dir path. */
57
+ const held = new Map();
58
+ let stealCounter = 0;
59
+ function staleMs() {
60
+ const raw = process.env.GJSIFY_INSTALL_LOCK_STALE_MS;
61
+ if (raw !== undefined) {
62
+ const n = Number(raw);
63
+ if (Number.isFinite(n) && n > 0)
64
+ return n;
65
+ }
66
+ return DEFAULT_STALE_MS;
67
+ }
68
+ function lockingDisabled() {
69
+ const flag = process.env.GJSIFY_INSTALL_LOCK;
70
+ if (flag === undefined)
71
+ return false;
72
+ const trimmed = flag.trim();
73
+ return trimmed === '0' || trimmed === 'false';
74
+ }
75
+ /**
76
+ * Is `pid` a live process on this machine?
77
+ *
78
+ * `/proc/<pid>` is the reliable cross-runtime probe on Linux (the primary
79
+ * target): it works identically under Node and GJS. Elsewhere fall back to a
80
+ * signal-0 probe — ESRCH means dead, EPERM means alive-but-foreign. The GJS
81
+ * `process.kill` polyfill shells out and cannot report ESRCH, but GJS only
82
+ * runs on /proc platforms in practice, so the fallback branch is Node's.
83
+ * Unknown outcomes are treated as ALIVE (conservative — never steal a lock
84
+ * we cannot prove abandoned; the mtime staleness budget still applies).
85
+ */
86
+ function isPidAlive(pid) {
87
+ try {
88
+ if (existsSync('/proc/self'))
89
+ return existsSync(`/proc/${pid}`);
90
+ }
91
+ catch {
92
+ /* fall through to the signal probe */
93
+ }
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (err) {
99
+ return err.code === 'EPERM';
100
+ }
101
+ }
102
+ function mtimeMs(path) {
103
+ try {
104
+ return statSync(path).mtimeMs;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /** Inspect an existing lock dir: held by a live owner, or stale? */
111
+ function assessLock(lockDir) {
112
+ const ownerPath = join(lockDir, OWNER_FILE);
113
+ let owner = null;
114
+ try {
115
+ owner = JSON.parse(readFileSync(ownerPath, 'utf-8'));
116
+ }
117
+ catch {
118
+ owner = null;
119
+ }
120
+ const now = Date.now();
121
+ if (owner && typeof owner.pid === 'number') {
122
+ if (!isPidAlive(owner.pid)) {
123
+ return { stale: true, reason: `owner pid ${owner.pid} is gone`, ownerPid: owner.pid };
124
+ }
125
+ const started = typeof owner.startedAt === 'number' ? owner.startedAt : mtimeMs(ownerPath);
126
+ if (started !== null && now - started > staleMs()) {
127
+ // Alive but far beyond any plausible install duration — the
128
+ // observed 0%-CPU hung-install shape. Steal so one wedged
129
+ // process can't block every future install of this prefix.
130
+ return {
131
+ stale: true,
132
+ reason: `owner pid ${owner.pid} exceeded the ${staleMs()}ms staleness budget`,
133
+ ownerPid: owner.pid,
134
+ };
135
+ }
136
+ return { stale: false, ownerPid: owner.pid };
137
+ }
138
+ // No readable owner.json: the winner may be between mkdir and the owner
139
+ // write — give it a grace period, then treat the dir as abandoned.
140
+ const dirMtime = mtimeMs(lockDir);
141
+ if (dirMtime === null)
142
+ return { stale: false }; // vanished — retry mkdir
143
+ if (now - dirMtime > ACQUIRE_GRACE_MS) {
144
+ return { stale: true, reason: 'no owner recorded within the acquire grace period' };
145
+ }
146
+ return { stale: false };
147
+ }
148
+ /**
149
+ * Atomically claim + remove a stale lock dir. Returns `true` when THIS
150
+ * process won the claim (the rename succeeded), `false` when another
151
+ * contender got there first — the caller just retries its acquire loop.
152
+ */
153
+ function stealStaleLock(lockDir) {
154
+ const claimed = `${lockDir}.stale-${process.pid}-${++stealCounter}`;
155
+ try {
156
+ renameSync(lockDir, claimed);
157
+ }
158
+ catch {
159
+ return false; // lost the race (ENOENT) — someone else claimed/released it
160
+ }
161
+ try {
162
+ rmSync(claimed, { recursive: true, force: true });
163
+ }
164
+ catch {
165
+ /* a leftover claimed dir is inert — it is not the lock path */
166
+ }
167
+ return true;
168
+ }
169
+ function abortErr(signal) {
170
+ const reason = signal && 'reason' in signal ? signal.reason : undefined;
171
+ if (reason instanceof Error)
172
+ return reason;
173
+ const err = new Error('Aborted');
174
+ err.name = 'AbortError';
175
+ return err;
176
+ }
177
+ function delay(ms, signal) {
178
+ if (ms <= 0)
179
+ return Promise.resolve();
180
+ return new Promise((resolveP, reject) => {
181
+ const onAbort = () => {
182
+ clearTimeout(id);
183
+ reject(abortErr(signal));
184
+ };
185
+ const id = setTimeout(() => {
186
+ signal?.removeEventListener?.('abort', onAbort);
187
+ resolveP();
188
+ }, ms);
189
+ if (signal?.aborted) {
190
+ clearTimeout(id);
191
+ reject(abortErr(signal));
192
+ return;
193
+ }
194
+ signal?.addEventListener?.('abort', onAbort, { once: true });
195
+ });
196
+ }
197
+ function makeHandle(lockDir) {
198
+ let released = false;
199
+ return {
200
+ release() {
201
+ if (released)
202
+ return;
203
+ released = true;
204
+ const entry = held.get(lockDir);
205
+ if (!entry)
206
+ return;
207
+ entry.count--;
208
+ if (entry.count > 0)
209
+ return;
210
+ held.delete(lockDir);
211
+ // Only remove the on-disk dir when it is still OURS — a staleness
212
+ // steal could have handed the path to another process meanwhile.
213
+ try {
214
+ const owner = JSON.parse(readFileSync(join(lockDir, OWNER_FILE), 'utf-8'));
215
+ if (owner.nonce !== undefined && owner.nonce !== entry.nonce)
216
+ return;
217
+ }
218
+ catch {
219
+ /* unreadable owner — we created the dir, attempt removal */
220
+ }
221
+ try {
222
+ rmSync(lockDir, { recursive: true, force: true });
223
+ }
224
+ catch {
225
+ /* best-effort: a leftover lock is reclaimed by staleness */
226
+ }
227
+ },
228
+ };
229
+ }
230
+ /**
231
+ * Acquire the cross-process install lock for `prefix`. Resolves once this
232
+ * process may mutate `<prefix>/node_modules` + `<prefix>/gjsify-lock.json`;
233
+ * the caller MUST `release()` in a `finally`. Waits (with backoff) while a
234
+ * live concurrent install holds the lock; steals provably-stale locks (dead
235
+ * owner pid / exceeded staleness budget). `signal` aborts the wait — wired
236
+ * to the overall install budget so a waiter never outlives its own timeout.
237
+ */
238
+ export async function acquireInstallLock(prefix, opts = {}) {
239
+ if (lockingDisabled()) {
240
+ return {
241
+ release() {
242
+ /* locking disabled — nothing to release */
243
+ },
244
+ };
245
+ }
246
+ // Canonicalize so two spellings of the same prefix (symlink, relative
247
+ // path) share one re-entrancy slot.
248
+ let canonicalPrefix;
249
+ try {
250
+ canonicalPrefix = realpathSync(prefix);
251
+ }
252
+ catch {
253
+ canonicalPrefix = resolve(prefix);
254
+ }
255
+ const lockDir = join(canonicalPrefix, 'node_modules', LOCK_DIR_NAME);
256
+ const heldEntry = held.get(lockDir);
257
+ if (heldEntry) {
258
+ heldEntry.count++;
259
+ return makeHandle(lockDir);
260
+ }
261
+ mkdirSync(dirname(lockDir), { recursive: true });
262
+ const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
263
+ let waitMs = WAIT_BASE_MS;
264
+ let announcedWait = false;
265
+ while (true) {
266
+ if (opts.signal?.aborted)
267
+ throw abortErr(opts.signal);
268
+ try {
269
+ mkdirSync(lockDir); // non-recursive → atomic exclusive create
270
+ writeFileSync(join(lockDir, OWNER_FILE), JSON.stringify({ pid: process.pid, nonce, startedAt: Date.now() }));
271
+ held.set(lockDir, { count: 1, nonce });
272
+ return makeHandle(lockDir);
273
+ }
274
+ catch (err) {
275
+ if (err.code !== 'EEXIST')
276
+ throw err;
277
+ }
278
+ const verdict = assessLock(lockDir);
279
+ if (verdict.stale) {
280
+ if (stealStaleLock(lockDir)) {
281
+ console.warn(`gjsify install: removed stale install lock (${verdict.reason}) — ${lockDir}`);
282
+ }
283
+ continue; // retry mkdir immediately (we or another stealer moved it)
284
+ }
285
+ if (!announcedWait) {
286
+ announcedWait = true;
287
+ const who = verdict.ownerPid !== undefined ? ` (pid ${verdict.ownerPid})` : '';
288
+ console.warn(`gjsify install: waiting for a concurrent install${who} to release ${lockDir} …`);
289
+ }
290
+ await delay(waitMs, opts.signal);
291
+ waitMs = Math.min(Math.round(waitMs * 1.6), WAIT_MAX_MS);
292
+ }
293
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
3
+ * the package directory, or null when it is not installed anywhere on the chain.
4
+ * A plain fs walk (works identically on Node and GJS) — Node's own resolver is
5
+ * not available under the GJS-bundled CLI.
6
+ */
7
+ export declare function resolveNodeGi(startDir: string): string | null;
8
+ /** Pick the node executable: the current one when on Node, else PATH `node`. */
9
+ export declare function nodeBinary(): string;
10
+ export interface RunNodeBundleOptions {
11
+ /** Exit this process with code 0 once the child succeeds (terminal callers). */
12
+ exitOnSuccess?: boolean;
13
+ }
14
+ /**
15
+ * Run a `--app node` bundle on Node.js, auto-setting LD_LIBRARY_PATH and
16
+ * GI_TYPELIB_PATH for any installed native gjsify packages (same detection as
17
+ * {@link runGjsBundle}). Verifies `@gjsify/node-gi` is resolvable first and
18
+ * throws a clear, actionable error otherwise.
19
+ */
20
+ export declare function runNodeBundle(bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // Shared utility for running a `--app node` bundle on Node.js with the native
2
+ // typelib env wired the same way `runGjsBundle` does.
3
+ //
4
+ // A `gjsify build --app node` bundle of a `gi://` source runs the GObject stack
5
+ // through `@gjsify/node-gi` (the Axis-5 reverse bridge). Two things have to be
6
+ // true at runtime:
7
+ //
8
+ // 1. `@gjsify/node-gi` must resolve from the project — it is kept EXTERNAL by
9
+ // the bundler (a native addon), so it has to physically exist in the
10
+ // consumer's node_modules. We verify this up front and emit a clear hint
11
+ // instead of letting Node throw `ERR_MODULE_NOT_FOUND` deep in the bundle.
12
+ // 2. `gi://Gtk` / `gi://Adw` etc. must resolve. System typelibs come from the
13
+ // default GI search path, but any `@gjsify/*` Vala bridge a story pulls in
14
+ // ships its typelib in `prebuilds/` — so we reuse the SAME
15
+ // `computeNativeEnvForBundle()` walker `runGjsBundle` uses to prepend
16
+ // `GI_TYPELIB_PATH` / `LD_LIBRARY_PATH`. It is a no-op when no native
17
+ // gjsify packages are present (the storybook's common case).
18
+ //
19
+ // The node binary is `process.execPath` when the CLI itself runs under Node, and
20
+ // the PATH `node` otherwise (under the committed GJS bundle `process.execPath` is
21
+ // `gjs`, which cannot run a `--app node` bundle).
22
+ import { spawn } from 'node:child_process';
23
+ import { existsSync } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
26
+ import { computeNativeEnvForBundle } from './run-gjs.js';
27
+ /**
28
+ * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
29
+ * the package directory, or null when it is not installed anywhere on the chain.
30
+ * A plain fs walk (works identically on Node and GJS) — Node's own resolver is
31
+ * not available under the GJS-bundled CLI.
32
+ */
33
+ export function resolveNodeGi(startDir) {
34
+ let dir = resolve(startDir);
35
+ while (true) {
36
+ const candidate = join(dir, 'node_modules', '@gjsify', 'node-gi', 'package.json');
37
+ if (existsSync(candidate))
38
+ return join(dir, 'node_modules', '@gjsify', 'node-gi');
39
+ const parent = resolve(dir, '..');
40
+ if (parent === dir)
41
+ return null;
42
+ dir = parent;
43
+ }
44
+ }
45
+ /** Pick the node executable: the current one when on Node, else PATH `node`. */
46
+ export function nodeBinary() {
47
+ return isNode() && process.execPath ? process.execPath : 'node';
48
+ }
49
+ /**
50
+ * Run a `--app node` bundle on Node.js, auto-setting LD_LIBRARY_PATH and
51
+ * GI_TYPELIB_PATH for any installed native gjsify packages (same detection as
52
+ * {@link runGjsBundle}). Verifies `@gjsify/node-gi` is resolvable first and
53
+ * throws a clear, actionable error otherwise.
54
+ */
55
+ export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
56
+ const cwd = process.cwd();
57
+ if (!resolveNodeGi(cwd)) {
58
+ throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed.\n' +
59
+ 'Add @gjsify/node-gi as a dependency to run the storybook on Node, e.g.:\n' +
60
+ ' "devDependencies": { "@gjsify/node-gi": "^0.13.0" }');
61
+ }
62
+ const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath, cwd);
63
+ const env = { ...process.env, ...nativeEnv };
64
+ const nodeBin = nodeBinary();
65
+ const nodeArgs = [bundlePath, ...extraArgs];
66
+ // Echo the exact command (stderr, so a child speaking a protocol on stdout
67
+ // stays uncontaminated) so users can reproduce the run without the wrapper.
68
+ const cmd = [nodeBin, ...nodeArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
69
+ console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${cmd}`);
70
+ const child = spawn(nodeBin, nodeArgs, { env, stdio: 'inherit' });
71
+ await new Promise((resolvePromise, reject) => {
72
+ child.on('close', (code) => {
73
+ if (code !== 0)
74
+ reject(new Error(`node exited with code ${code}`));
75
+ else
76
+ resolvePromise();
77
+ });
78
+ child.on('error', reject);
79
+ }).catch((err) => {
80
+ console.error(err.message);
81
+ process.exit(1);
82
+ });
83
+ if (options.exitOnSuccess)
84
+ process.exit(0);
85
+ }