@gjsify/cli 0.14.0 → 0.16.0
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/dist/cli.gjs.mjs +45 -44
- package/lib/actions/build.js +24 -1
- package/lib/cli-app.js +2 -1
- package/lib/commands/foreach.d.ts +1 -0
- package/lib/commands/foreach.js +48 -13
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +132 -65
- package/lib/commands/login.d.ts +29 -0
- package/lib/commands/login.js +122 -96
- package/lib/commands/onboard.d.ts +14 -0
- package/lib/commands/onboard.js +453 -0
- package/lib/commands/publish.d.ts +92 -0
- package/lib/commands/publish.js +270 -295
- package/lib/commands/trust.d.ts +21 -0
- package/lib/commands/trust.js +40 -70
- package/lib/commands/workspace.d.ts +2 -0
- package/lib/commands/workspace.js +64 -14
- package/lib/utils/build-cache.d.ts +157 -0
- package/lib/utils/build-cache.js +670 -0
- package/lib/utils/install-backend-native.js +104 -9
- package/lib/utils/install-backend.d.ts +10 -0
- package/lib/utils/install-cache-fs.d.ts +9 -0
- package/lib/utils/install-cache-fs.js +20 -6
- package/lib/utils/install-lock.d.ts +15 -0
- package/lib/utils/install-lock.js +293 -0
- package/lib/utils/library-output.d.ts +30 -0
- package/lib/utils/library-output.js +92 -0
- package/lib/utils/npm-otp.d.ts +67 -0
- package/lib/utils/npm-otp.js +117 -0
- package/lib/utils/suggest.d.ts +13 -0
- package/lib/utils/suggest.js +51 -0
- package/package.json +19 -18
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The literal (non-glob) base directories the build's input source files live
|
|
3
|
+
* in, derived from the bundler `input` patterns. A file entry maps to its
|
|
4
|
+
* containing dir, a glob is reduced to the literal prefix before the first
|
|
5
|
+
* wildcard:
|
|
6
|
+
* - `src/index.ts` → `<cwd>/src`
|
|
7
|
+
* - `src/**` + `/*.ts` → `<cwd>/src`
|
|
8
|
+
* - `src` → `<cwd>/src`
|
|
9
|
+
*
|
|
10
|
+
* The project root itself is intentionally excluded — a flat library that
|
|
11
|
+
* keeps its source at the project root and emits there is an explicit layout,
|
|
12
|
+
* not the `src/`-subdir footgun this guard targets.
|
|
13
|
+
*/
|
|
14
|
+
export declare function inputSourceDirs(input: unknown, cwd: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* `true` when writing library output to `outdir` would land build artifacts
|
|
17
|
+
* inside (or directly on top of) the input source tree. Unsafe iff `outdir`
|
|
18
|
+
* equals, or is nested under, any input source directory.
|
|
19
|
+
*
|
|
20
|
+
* isOutdirInsideSource('src', ['<cwd>/src']) → true (leak)
|
|
21
|
+
* isOutdirInsideSource('src/nest', ['<cwd>/src']) → true (leak)
|
|
22
|
+
* isOutdirInsideSource('dist/esm', ['<cwd>/src']) → false (safe)
|
|
23
|
+
* isOutdirInsideSource('lib', ['<cwd>/src']) → false (safe)
|
|
24
|
+
*/
|
|
25
|
+
export declare function isOutdirInsideSource(outdir: string, sourceDirs: readonly string[], cwd: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The error thrown when a `--library` build would emit into its own source
|
|
28
|
+
* tree. Names both remedies so the fix is actionable at the point of failure.
|
|
29
|
+
*/
|
|
30
|
+
export declare function libraryOutputLeakError(outdir: string): Error;
|