@gjsify/cli 0.51.1 → 0.53.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 +539 -493
- package/lib/actions/build.d.ts +22 -0
- package/lib/actions/build.js +37 -9
- package/lib/bundler-pick.d.ts +37 -1
- package/lib/bundler-pick.js +108 -17
- package/lib/cli-app.js +7 -2
- package/lib/commands/affected-classify.d.ts +7 -1
- package/lib/commands/affected-classify.js +66 -9
- package/lib/commands/affected-script-refs.d.ts +20 -0
- package/lib/commands/affected-script-refs.js +218 -0
- package/lib/commands/affected.js +24 -2
- package/lib/commands/env.d.ts +31 -0
- package/lib/commands/env.js +114 -0
- package/lib/commands/exec.d.ts +10 -0
- package/lib/commands/exec.js +208 -0
- package/lib/commands/flatpak/diff.js +8 -2
- package/lib/commands/flatpak/init.js +4 -1
- package/lib/commands/flatpak/sources.js +4 -3
- package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
- package/lib/commands/flatpak/sync-flathub.js +149 -7
- package/lib/commands/flatpak/utils.d.ts +10 -0
- package/lib/commands/flatpak/utils.js +10 -0
- package/lib/commands/foreach.js +5 -97
- package/lib/commands/format.js +26 -6
- package/lib/commands/index.d.ts +4 -0
- package/lib/commands/index.js +4 -0
- package/lib/commands/install.d.ts +2 -2
- package/lib/commands/install.js +81 -5
- package/lib/commands/link.d.ts +13 -0
- package/lib/commands/link.js +243 -0
- package/lib/commands/onboard.js +18 -2
- package/lib/commands/publish.d.ts +34 -0
- package/lib/commands/publish.js +153 -8
- package/lib/commands/self-update.js +2 -1
- package/lib/commands/ship.js +19 -1
- package/lib/commands/test.d.ts +18 -9
- package/lib/commands/test.js +56 -11
- package/lib/commands/tsc.js +4 -4
- package/lib/commands/upgrade.d.ts +15 -0
- package/lib/commands/upgrade.js +76 -2
- package/lib/commands/webext/index.d.ts +2 -0
- package/lib/commands/webext/index.js +15 -0
- package/lib/commands/webext/subcommands.d.ts +16 -0
- package/lib/commands/webext/subcommands.js +204 -0
- package/lib/types/config-data.d.ts +2 -2
- package/lib/utils/bin-shim.d.ts +8 -2
- package/lib/utils/bin-shim.js +6 -5
- package/lib/utils/bundle-inputs.d.ts +84 -0
- package/lib/utils/bundle-inputs.js +268 -0
- package/lib/utils/check-system-deps.d.ts +5 -19
- package/lib/utils/check-system-deps.js +31 -94
- package/lib/utils/dev-link.d.ts +225 -0
- package/lib/utils/dev-link.js +684 -0
- package/lib/utils/exec-bin.d.ts +137 -0
- package/lib/utils/exec-bin.js +321 -0
- package/lib/utils/gi-runtime-paths.js +3 -2
- package/lib/utils/gitignore.d.ts +15 -0
- package/lib/utils/gitignore.js +178 -0
- package/lib/utils/gjsify-shim.d.ts +47 -0
- package/lib/utils/gjsify-shim.js +97 -8
- package/lib/utils/hang-watchdog.d.ts +95 -0
- package/lib/utils/hang-watchdog.js +194 -0
- package/lib/utils/install-backend-native.d.ts +33 -1
- package/lib/utils/install-backend-native.js +184 -20
- package/lib/utils/install-extraneous.d.ts +90 -0
- package/lib/utils/install-extraneous.js +120 -0
- package/lib/utils/install-lock.js +4 -25
- package/lib/utils/install-tarball-cache.d.ts +37 -9
- package/lib/utils/install-tarball-cache.js +59 -14
- package/lib/utils/native-skip-scan.d.ts +20 -0
- package/lib/utils/native-skip-scan.js +343 -0
- package/lib/utils/node-script.js +1 -1
- package/lib/utils/normalize-bundler-options.js +11 -0
- package/lib/utils/oxc-resolve.js +42 -3
- package/lib/utils/process-table.d.ts +22 -0
- package/lib/utils/process-table.js +188 -0
- package/lib/utils/prune-prefix.js +1 -1
- package/lib/utils/publish-pins.d.ts +38 -0
- package/lib/utils/publish-pins.js +47 -0
- package/lib/utils/publish-readback.d.ts +103 -17
- package/lib/utils/publish-readback.js +326 -47
- package/lib/utils/run-gjs.d.ts +15 -1
- package/lib/utils/run-gjs.js +98 -13
- package/lib/utils/run-node.d.ts +2 -0
- package/lib/utils/run-node.js +2 -1
- package/lib/utils/runtimes.js +14 -13
- package/lib/utils/ship/gzip.js +1 -1
- package/lib/utils/ship/icns.d.ts +14 -0
- package/lib/utils/ship/icns.js +83 -0
- package/lib/utils/ship/ico.d.ts +25 -0
- package/lib/utils/ship/ico.js +120 -0
- package/lib/utils/ship/icons.d.ts +197 -0
- package/lib/utils/ship/icons.js +399 -0
- package/lib/utils/ship/launcher.js +16 -1
- package/lib/utils/ship/layout.d.ts +32 -0
- package/lib/utils/ship/layout.js +112 -38
- package/lib/utils/ship/msi.d.ts +1 -1
- package/lib/utils/ship/msi.js +30 -5
- package/lib/utils/ship/pe-launcher.d.ts +18 -0
- package/lib/utils/ship/pe-launcher.js +125 -4
- package/lib/utils/ship/plan.d.ts +1 -34
- package/lib/utils/ship/plan.js +7 -56
- package/lib/utils/ship/plist.d.ts +20 -10
- package/lib/utils/ship/plist.js +29 -12
- package/lib/utils/ship/settings.js +3 -2
- package/lib/utils/spawn.d.ts +13 -0
- package/lib/utils/spawn.js +10 -5
- package/lib/utils/watch-loop.d.ts +15 -1
- package/lib/utils/watch-loop.js +9 -5
- package/lib/utils/webext/build.d.ts +42 -0
- package/lib/utils/webext/build.js +120 -0
- package/lib/utils/webext/config.d.ts +72 -0
- package/lib/utils/webext/config.js +234 -0
- package/lib/utils/webext/icons.d.ts +24 -0
- package/lib/utils/webext/icons.js +83 -0
- package/lib/utils/webext/launch.d.ts +32 -0
- package/lib/utils/webext/launch.js +77 -0
- package/lib/utils/webext/manifest.d.ts +53 -0
- package/lib/utils/webext/manifest.js +219 -0
- package/lib/utils/webext/pages.d.ts +26 -0
- package/lib/utils/webext/pages.js +61 -0
- package/lib/utils/webext/targets.d.ts +13 -0
- package/lib/utils/webext/targets.js +36 -0
- package/lib/utils/win32-command.d.ts +17 -0
- package/lib/utils/win32-command.js +21 -6
- package/package.json +20 -20
|
@@ -45,8 +45,20 @@
|
|
|
45
45
|
// (v4); without it the fresh-resolve and lockfile paths disagree about one
|
|
46
46
|
// tree.
|
|
47
47
|
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
48
|
+
// PEER DEPENDENCIES follow npm ≥ 7: every peer NOT marked optional in
|
|
49
|
+
// `peerDependenciesMeta` is installed where the dependent resolves it — as its
|
|
50
|
+
// SIBLING (queued from the dependent's parent, so the dependent and whatever
|
|
51
|
+
// requires it share one instance), reusing any visible copy whose version
|
|
52
|
+
// satisfies the range. Optional peers are never installed, exactly as npm skips
|
|
53
|
+
// them; ADR 0020's `@gjsify/rolldown-native` depends on that. The defect this
|
|
54
|
+
// closes: `wxt` declares `vite` as a required peer, `gjsify install` exited 0
|
|
55
|
+
// without it, and `wxt prepare` died with "Builder not found". A peer edge is a
|
|
56
|
+
// REQUIRED edge for the optionality fixpoint ({@link requiredPeerEntries}), so
|
|
57
|
+
// the lockfile records the peer maps and marks a peer-aware resolve
|
|
58
|
+
// (`Lockfile.peersResolved` says why that is a marker and not a version bump).
|
|
59
|
+
//
|
|
60
|
+
// Out of scope (still deferred): lifecycle scripts, git/file specs, and npm's
|
|
61
|
+
// strict-peer-deps ERESOLVE (a conflicting peer warns, see `resolveDeps`).
|
|
50
62
|
import * as fs from 'node:fs';
|
|
51
63
|
import * as path from 'node:path';
|
|
52
64
|
import * as os from 'node:os';
|
|
@@ -60,6 +72,9 @@ import { getCachedPackument, putCachedPackument } from './install-packument-cach
|
|
|
60
72
|
import { assertNativeBackendNodeVersion } from './node-version.js';
|
|
61
73
|
import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
|
|
62
74
|
import { detectNativePackages } from './detect-native-packages.js';
|
|
75
|
+
import { findExtraneous, formatExtraneousError } from './install-extraneous.js';
|
|
76
|
+
import { devLinkPath, devLinkRoot } from './dev-link.js';
|
|
77
|
+
import { scanPrefix } from './prune-prefix.js';
|
|
63
78
|
import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
|
|
64
79
|
// 16-wide download pool, matched to the shared Soup.Session's lifted
|
|
65
80
|
// `max-conns-per-host` (@gjsify/fetch `getSharedSession`) — without that lift
|
|
@@ -157,10 +172,19 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
157
172
|
}
|
|
158
173
|
log('install: --immutable, using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
|
|
159
174
|
nodes = lockfileToNodes(existingLock);
|
|
175
|
+
// ...and the tree must hold NOTHING ELSE. The installer only adds, so without
|
|
176
|
+
// this an undescribed package survives the install and exits 0 — the CI cache
|
|
177
|
+
// poisoning of gjsify#1683 (install-extraneous.ts carries the incident).
|
|
178
|
+
// Before the download so a wrong tree costs seconds, not the extract phase,
|
|
179
|
+
// and with the FULL lockfile set: the workspace filter below removes nodes
|
|
180
|
+
// that ARE described, and judging against the smaller set would call them
|
|
181
|
+
// strangers.
|
|
182
|
+
assertNoExtraneous(opts.prefix, nodes);
|
|
160
183
|
}
|
|
161
184
|
else if (!opts.refreshLockfile &&
|
|
162
185
|
existingLock &&
|
|
163
186
|
existingLock.lockfileVersion === LOCKFILE_VERSION &&
|
|
187
|
+
existingLock.peersResolved === true &&
|
|
164
188
|
lockfileMatchesRequest(existingLock, opts.specs)) {
|
|
165
189
|
log('install: using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
|
|
166
190
|
nodes = lockfileToNodes(existingLock);
|
|
@@ -168,6 +192,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
168
192
|
else {
|
|
169
193
|
// A resolve has to run (new/changed/removed dep, or no lockfile yet).
|
|
170
194
|
//
|
|
195
|
+
// So does a lockfile without `peersResolved`: it may lack a required peer
|
|
196
|
+
// (Lockfile.peersResolved).
|
|
197
|
+
//
|
|
171
198
|
// A pre-v4 lockfile lands here too even when it matches the request: each
|
|
172
199
|
// bump added something the verdict is recomputed FROM, so a file lacking it
|
|
173
200
|
// cannot be consumed verbatim — pre-v3 entries carry no platform
|
|
@@ -209,6 +236,16 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
209
236
|
writeLockfile(lockfilePath, opts.specs, nodes);
|
|
210
237
|
log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
|
|
211
238
|
}
|
|
239
|
+
// TWO NAMES FROM HERE ON, and the split is the fix for a measured defect.
|
|
240
|
+
// `nodes` stays the FULL resolved tree — what the lockfile describes and what
|
|
241
|
+
// `topLevelResolutions` answers from. `fetchable` is the subset that still has
|
|
242
|
+
// to come off the network. A package can be present without being fetched, and
|
|
243
|
+
// collapsing the two made the installer forget it existed: with a `gjsify link`
|
|
244
|
+
// active, `gjsify install is-odd` read the FILTERED array, found no `is-odd`
|
|
245
|
+
// resolution, and wrote `"is-odd": "latest"` into package.json and
|
|
246
|
+
// `is-odd@latest` into the lockfile's `requested` — over the `^3.0.1` the
|
|
247
|
+
// control run without the link produced. Never re-merge these two names.
|
|
248
|
+
let fetchable = nodes;
|
|
212
249
|
// A package named after one of the monorepo's own workspaces is provided by a
|
|
213
250
|
// workspace symlink (wired by `workspaceInstall`), NOT by a registry tarball —
|
|
214
251
|
// even when the lockfile or a transitive edge pins a same-named published
|
|
@@ -217,26 +254,55 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
217
254
|
// `--immutable` robust against a committed lockfile that still carries such
|
|
218
255
|
// entries, since that path never runs `resolveDeps`.
|
|
219
256
|
if (opts.workspaceNames && opts.workspaceNames.size > 0) {
|
|
220
|
-
const before =
|
|
221
|
-
|
|
222
|
-
const dropped = before -
|
|
257
|
+
const before = fetchable.length;
|
|
258
|
+
fetchable = fetchable.filter((n) => !opts.workspaceNames.has(n.name));
|
|
259
|
+
const dropped = before - fetchable.length;
|
|
223
260
|
if (dropped > 0) {
|
|
224
261
|
log('install: %d workspace-provided package(s) symlinked, not fetched', dropped);
|
|
225
262
|
}
|
|
226
263
|
}
|
|
264
|
+
// Same exclusion, different reason and a different position in the pipeline: a
|
|
265
|
+
// `gjsify link` override provides these from a local checkout. It runs AFTER the
|
|
266
|
+
// lockfile write above on purpose — the consumer's committed lockfile must stay
|
|
267
|
+
// byte-identical whether or not a link is active (utils/dev-link.ts).
|
|
268
|
+
if (opts.linkedNames && opts.linkedNames.size > 0) {
|
|
269
|
+
const before = fetchable.length;
|
|
270
|
+
fetchable = fetchable.filter((n) => !opts.linkedNames.has(n.name));
|
|
271
|
+
const dropped = before - fetchable.length;
|
|
272
|
+
if (dropped > 0) {
|
|
273
|
+
log('install: %d dev-linked package(s) provided by a local checkout, not fetched', dropped);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
227
276
|
// os/cpu/libc: throws for an incompatible REQUIRED dep, marks incompatible
|
|
228
277
|
// OPTIONAL ones inert. Runs on BOTH paths (fresh resolve and lockfile) —
|
|
229
278
|
// that is what makes one committed lockfile install a per-host subset.
|
|
230
|
-
const installable = applyPlatformFilter(
|
|
279
|
+
const installable = applyPlatformFilter(fetchable, target, force, log);
|
|
231
280
|
log('install: downloading %d tarball(s)', installable.length);
|
|
232
281
|
await downloadAndExtractAll(installable, opts.prefix, npmrc, log, opts.signal, progress);
|
|
233
282
|
await linkBins(installable, opts.prefix, log);
|
|
234
283
|
warnMissingNativeBuilds(installable, opts.prefix, log);
|
|
235
284
|
log('install: done');
|
|
236
285
|
// Top-level requested packages only, so callers can write the resolved version
|
|
237
|
-
// back into package.json (`npm install --save`).
|
|
286
|
+
// back into package.json (`npm install --save`). From `nodes`, NEVER from
|
|
287
|
+
// `fetchable`: "what is installed at node_modules/<name>" is not "what was
|
|
288
|
+
// downloaded", and a provided package is installed.
|
|
238
289
|
return topLevelResolutions(opts.specs, nodes);
|
|
239
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* `--immutable` only: refuse a `node_modules` holding packages the lockfile does not
|
|
293
|
+
* describe, naming every one of them.
|
|
294
|
+
*
|
|
295
|
+
* Deliberately NOT a prune — the reasoning, and the incident it comes from, are the
|
|
296
|
+
* header note of `install-extraneous.ts`. Scoped to the frozen path: a plain install
|
|
297
|
+
* resolves a NEW tree and may legitimately leave an older placement behind for its
|
|
298
|
+
* own prune pass to judge, so this rule would be wrong there.
|
|
299
|
+
*/
|
|
300
|
+
function assertNoExtraneous(prefix, nodes) {
|
|
301
|
+
const extraneous = findExtraneous({ prefix, installed: scanPrefix(prefix), expected: nodes });
|
|
302
|
+
if (extraneous.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
throw new Error(formatExtraneousError(extraneous, prefix));
|
|
305
|
+
}
|
|
240
306
|
function errMsg(err) {
|
|
241
307
|
if (err instanceof Error)
|
|
242
308
|
return err.message;
|
|
@@ -483,7 +549,7 @@ optionalSpecs) {
|
|
|
483
549
|
// `workspaceInstall` produces when two workspaces declare incompatible ranges of
|
|
484
550
|
// the same external dep) all compete for the single root slot and the resolver
|
|
485
551
|
// silently keeps one version for everyone. Per-workspace dedup is Phase D.8
|
|
486
|
-
// (status/open-todos.md); until it lands the conflict is surfaced loudly after
|
|
552
|
+
// (status/open-todos/README.md); until it lands the conflict is surfaced loudly after
|
|
487
553
|
// the resolve (see emitTopLevelConflictWarnings).
|
|
488
554
|
const topLevelRanges = new Map();
|
|
489
555
|
const queue = specs.map(parseSpec).map((s) => {
|
|
@@ -551,6 +617,23 @@ optionalSpecs) {
|
|
|
551
617
|
// {@link computeOptionalFlags} is the only writer of the final flag.
|
|
552
618
|
continue;
|
|
553
619
|
}
|
|
620
|
+
// A peer must live in ONE directory — beside its dependent. If that slot
|
|
621
|
+
// already holds a version outside the range, a private copy would give the
|
|
622
|
+
// dependent a different instance from its requester, which is the thing a
|
|
623
|
+
// peer exists to prevent. npm ≥ 7 raises ERESOLVE here in strict mode and,
|
|
624
|
+
// non-strict (`--force`, or a conflict below the root), warns "overriding
|
|
625
|
+
// peer dependency" and keeps the existing copy. We take the lenient answer:
|
|
626
|
+
// warn, keep it, install nothing.
|
|
627
|
+
if (edge.peerOf) {
|
|
628
|
+
const slot = edge.from === null ? `node_modules/${edge.name}` : `${edge.from}/node_modules/${edge.name}`;
|
|
629
|
+
const occupant = byPath.get(slot);
|
|
630
|
+
if (occupant) {
|
|
631
|
+
console.warn(`[gjsify] warning: peer dependency conflict: ${edge.peerOf.name}@${edge.peerOf.version} ` +
|
|
632
|
+
`wants ${edge.name}@${edge.range}, but ${occupant.version} is installed at ` +
|
|
633
|
+
`${occupant.installPath} — keeping it (npm's non-strict behaviour).`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
554
637
|
// No compatible placement. Resolve a version, preferring one already
|
|
555
638
|
// pinned when it satisfies the range so an add does not bump unchanged
|
|
556
639
|
// deps.
|
|
@@ -579,6 +662,8 @@ optionalSpecs) {
|
|
|
579
662
|
installPath,
|
|
580
663
|
dependencies: v.dependencies ?? {},
|
|
581
664
|
optionalDependencies: v.optionalDependencies ?? {},
|
|
665
|
+
peerDependencies: nonEmpty(v.peerDependencies),
|
|
666
|
+
peerDependenciesMeta: nonEmpty(v.peerDependenciesMeta),
|
|
582
667
|
bin: v.bin,
|
|
583
668
|
// Complete as published: `fetchPkg` reads the FULL document, so all
|
|
584
669
|
// three fields come from one body — no second fetch, no heuristic.
|
|
@@ -603,6 +688,20 @@ optionalSpecs) {
|
|
|
603
688
|
name: `${edge.name}@${version}`,
|
|
604
689
|
});
|
|
605
690
|
if (!skipDeps) {
|
|
691
|
+
// Required peers FIRST, so the peer claims its slot beside the
|
|
692
|
+
// dependent before the dependent's own subtree can hoist an
|
|
693
|
+
// incompatible copy of the same name into it. Queued from the PARENT
|
|
694
|
+
// (`Edge.peerOf`); optional peers are never queued, npm ≥ 7 skips them.
|
|
695
|
+
const parent = parentInstallPath(installPath);
|
|
696
|
+
for (const [peerName, peerRange] of requiredPeerEntries(node)) {
|
|
697
|
+
queue.push({
|
|
698
|
+
from: parent,
|
|
699
|
+
name: peerName,
|
|
700
|
+
range: applyOverride(peerName, peerRange),
|
|
701
|
+
required: !node.optional,
|
|
702
|
+
peerOf: node,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
606
705
|
// REQUIRED edges only — a name this package also lists in
|
|
607
706
|
// `optionalDependencies` is queued once by the loop below, as
|
|
608
707
|
// optional (header note, invariant 3). Queuing it here too would
|
|
@@ -643,7 +742,9 @@ optionalSpecs) {
|
|
|
643
742
|
// `assertRequiredEdgesResolved` re-judges every entry against the
|
|
644
743
|
// fixpoint flags, so a genuinely required dependency cannot go missing
|
|
645
744
|
// just because BFS reached it through an optional edge first.
|
|
646
|
-
|
|
745
|
+
// A peer edge is keyed by its DEPENDENT, not by `from`: the dependent
|
|
746
|
+
// is the requester whose final optionality decides the verdict.
|
|
747
|
+
skippedEdges.add(edgeKey(edge.peerOf?.installPath ?? edge.from, edge.name));
|
|
647
748
|
log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
|
|
648
749
|
continue;
|
|
649
750
|
}
|
|
@@ -748,6 +849,27 @@ export function requiredDepEntries(node) {
|
|
|
748
849
|
}
|
|
749
850
|
return entries;
|
|
750
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* A node's REQUIRED peer edges: its `peerDependencies` minus those
|
|
854
|
+
* `peerDependenciesMeta` marks `optional` (npm ≥ 7 installs the rest and skips
|
|
855
|
+
* these) and minus any name it also lists as a dependency — that edge is walked
|
|
856
|
+
* from the node itself by {@link requiredDepEntries}, or is optional because the
|
|
857
|
+
* publisher said so in `optionalDependencies`.
|
|
858
|
+
*
|
|
859
|
+
* Shared by the resolve walk and the optionality fixpoint, for the reason
|
|
860
|
+
* {@link requiredDepEntries} is: two readings of one manifest are two answers.
|
|
861
|
+
*/
|
|
862
|
+
export function requiredPeerEntries(node) {
|
|
863
|
+
const entries = [];
|
|
864
|
+
for (const [name, range] of Object.entries(node.peerDependencies ?? {})) {
|
|
865
|
+
if (node.peerDependenciesMeta?.[name]?.optional === true)
|
|
866
|
+
continue;
|
|
867
|
+
if (name in node.dependencies || name in node.optionalDependencies)
|
|
868
|
+
continue;
|
|
869
|
+
entries.push([name, range]);
|
|
870
|
+
}
|
|
871
|
+
return entries;
|
|
872
|
+
}
|
|
751
873
|
/**
|
|
752
874
|
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
|
|
753
875
|
* only writer of the final flag; whatever the nodes arrive carrying is an input
|
|
@@ -755,7 +877,8 @@ export function requiredDepEntries(node) {
|
|
|
755
877
|
* file on the lockfile path) and is overwritten unconditionally.
|
|
756
878
|
*
|
|
757
879
|
* DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
|
|
758
|
-
* REQUIRED edges alone ({@link requiredDepEntries}
|
|
880
|
+
* REQUIRED edges alone ({@link requiredDepEntries} plus {@link requiredPeerEntries} —
|
|
881
|
+
* a peer npm installs is as mandatory as a dependency); every other node is optional.
|
|
759
882
|
* Optionality is therefore INHERITED — a plain dependency OF an optional package is
|
|
760
883
|
* still optional, which is what npm's `optionalSet` computes.
|
|
761
884
|
*
|
|
@@ -811,7 +934,7 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
|
811
934
|
const node = byPath.get(installPath);
|
|
812
935
|
if (!node)
|
|
813
936
|
continue;
|
|
814
|
-
for (const [depName] of requiredDepEntries(node)) {
|
|
937
|
+
for (const [depName] of [...requiredDepEntries(node), ...requiredPeerEntries(node)]) {
|
|
815
938
|
// Resolve the edge the way the REQUESTER will at runtime — through
|
|
816
939
|
// the ancestor `node_modules` chain — so a nested copy is credited
|
|
817
940
|
// to the requester that nested it and the hoisted one is not
|
|
@@ -902,7 +1025,7 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
|
902
1025
|
* (dist-tags like `latest`) cannot be compared and are skipped.
|
|
903
1026
|
*
|
|
904
1027
|
* This makes the single-root-slot behaviour honest rather than silent; the real fix
|
|
905
|
-
* is a per-workspace dedup pass, Phase D.8 (status/open-todos.md).
|
|
1028
|
+
* is a per-workspace dedup pass, Phase D.8 (status/open-todos/README.md).
|
|
906
1029
|
*/
|
|
907
1030
|
function emitTopLevelConflictWarnings(topLevelRanges, root) {
|
|
908
1031
|
for (const [name, ranges] of topLevelRanges) {
|
|
@@ -1032,6 +1155,11 @@ function findVisible(requesterPath, name, byPath) {
|
|
|
1032
1155
|
}
|
|
1033
1156
|
return null;
|
|
1034
1157
|
}
|
|
1158
|
+
/** The `installPath` whose `node_modules` holds this one; null for a root placement. */
|
|
1159
|
+
function parentInstallPath(installPath) {
|
|
1160
|
+
const idx = installPath.lastIndexOf('/node_modules/');
|
|
1161
|
+
return idx < 0 ? null : installPath.slice(0, idx);
|
|
1162
|
+
}
|
|
1035
1163
|
/**
|
|
1036
1164
|
* Where to install `name@version` for a request from `requesterPath`: hoist to
|
|
1037
1165
|
* `node_modules/<name>` when the root slot is empty or already holds this version,
|
|
@@ -1092,6 +1220,8 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
1092
1220
|
// `computeOptionalFlags` runs on the lockfile path too and cannot recompute
|
|
1093
1221
|
// what the file does not carry (v4, header note invariant 3).
|
|
1094
1222
|
optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
|
|
1223
|
+
peerDependencies: nonEmpty(node.peerDependencies),
|
|
1224
|
+
peerDependenciesMeta: nonEmpty(node.peerDependenciesMeta),
|
|
1095
1225
|
bin: node.bin,
|
|
1096
1226
|
// The DECLARATION, never the verdict: `inert` is per-host and deliberately
|
|
1097
1227
|
// absent from the file, since a lockfile recording which packages the
|
|
@@ -1106,12 +1236,16 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
1106
1236
|
lockfileVersion: LOCKFILE_VERSION,
|
|
1107
1237
|
requested: [...specs],
|
|
1108
1238
|
packages,
|
|
1239
|
+
peersResolved: true,
|
|
1109
1240
|
};
|
|
1110
1241
|
// Atomic tmp+rename so a crash mid-write, or a reader racing the writer, can never
|
|
1111
1242
|
// observe a torn gjsify-lock.json — `--immutable` would otherwise hard-fail on the
|
|
1112
1243
|
// corrupt file with a misleading error.
|
|
1113
1244
|
atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
|
|
1114
1245
|
}
|
|
1246
|
+
function nonEmpty(map) {
|
|
1247
|
+
return map && Object.keys(map).length > 0 ? map : undefined;
|
|
1248
|
+
}
|
|
1115
1249
|
/**
|
|
1116
1250
|
* The lockfile-preservation oracle: `name → every version currently pinned` (a name
|
|
1117
1251
|
* can appear at more than one path/version when the tree nested a conflicting copy).
|
|
@@ -1164,6 +1298,8 @@ function lockfileToNodes(lockfile) {
|
|
|
1164
1298
|
// blocks reads as required here. `--immutable` consumes it anyway — see
|
|
1165
1299
|
// READABLE_LOCKFILE_VERSIONS.
|
|
1166
1300
|
optionalDependencies: entry.optionalDependencies ?? {},
|
|
1301
|
+
peerDependencies: entry.peerDependencies,
|
|
1302
|
+
peerDependenciesMeta: entry.peerDependenciesMeta,
|
|
1167
1303
|
bin: entry.bin,
|
|
1168
1304
|
// Rebuilt from the recorded declaration so `applyPlatformFilter` reaches the
|
|
1169
1305
|
// SAME verdict here as on the fresh-resolve path, for THIS host — generally
|
|
@@ -1378,7 +1514,7 @@ async function extractOne(node, prefix, npmrc, log, signal) {
|
|
|
1378
1514
|
// fail if a workspace package leaked into the fetch/extract queue (the root cause
|
|
1379
1515
|
// fixed in `workspaceInstall`), and refusing here means a resolver regression can
|
|
1380
1516
|
// never again `rmSync` a working-tree source dir.
|
|
1381
|
-
assertNodeModulesDest(dest, node);
|
|
1517
|
+
assertNodeModulesDest(dest, node, prefix);
|
|
1382
1518
|
// Idempotent fast-path — skip the cache read + rm + re-extract for a node already
|
|
1383
1519
|
// extracted at the resolved version. The npm/yarn/pnpm default (only added/changed
|
|
1384
1520
|
// nodes are written) and the dominant cost on a warm re-install.
|
|
@@ -1387,14 +1523,15 @@ async function extractOne(node, prefix, npmrc, log, signal) {
|
|
|
1387
1523
|
log('up-to-date: %s@%s (already extracted at %s)', node.name, node.version, node.installPath);
|
|
1388
1524
|
return true;
|
|
1389
1525
|
}
|
|
1390
|
-
// Content-addressable cache before the network
|
|
1391
|
-
// integrity, so a
|
|
1392
|
-
//
|
|
1393
|
-
|
|
1526
|
+
// Content-addressable cache before the network. Both readers RE-HASH the bytes
|
|
1527
|
+
// against the integrity that named them, so a hit is byte-identical to what the
|
|
1528
|
+
// registry would return because it was checked, not because the path says so —
|
|
1529
|
+
// the store is a restorable CI artifact now, not just a local directory.
|
|
1530
|
+
let bytes = await getCachedTarball(node.integrity);
|
|
1394
1531
|
if (bytes) {
|
|
1395
1532
|
log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
1396
1533
|
}
|
|
1397
|
-
else if ((bytes = getForeignCachedTarball(node.integrity))) {
|
|
1534
|
+
else if ((bytes = await getForeignCachedTarball(node.integrity))) {
|
|
1398
1535
|
// Second chance: npm's cacache content store, same SRI key — anyone who has run
|
|
1399
1536
|
// `npm install` already has the tarball on disk. Written through to OUR store so
|
|
1400
1537
|
// the next `gjsify install` hits first-class even if npm prunes its cache.
|
|
@@ -1514,7 +1651,7 @@ function abortError(signal) {
|
|
|
1514
1651
|
* a workspace source tree, where `rmSync(dest, { recursive: true })` deletes the
|
|
1515
1652
|
* link's target contents.
|
|
1516
1653
|
*/
|
|
1517
|
-
function assertNodeModulesDest(dest, node) {
|
|
1654
|
+
function assertNodeModulesDest(dest, node, prefix) {
|
|
1518
1655
|
const segments = dest.split(path.sep);
|
|
1519
1656
|
if (!segments.includes('node_modules')) {
|
|
1520
1657
|
throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} into ${dest} — ` +
|
|
@@ -1533,9 +1670,36 @@ function assertNodeModulesDest(dest, node) {
|
|
|
1533
1670
|
if (!realSegments.includes('node_modules')) {
|
|
1534
1671
|
throw new Error(`gjsify install: refusing to extract ${node.name}@${node.version} — ${dest} resolves to ${real}, ` +
|
|
1535
1672
|
`which is outside any node_modules/ directory (likely a symlink to a workspace source tree). ` +
|
|
1536
|
-
`Extracting here would delete working-tree source files
|
|
1673
|
+
`Extracting here would delete working-tree source files.${devLinkRemedy(prefix, dest)}`);
|
|
1537
1674
|
}
|
|
1538
1675
|
}
|
|
1676
|
+
/**
|
|
1677
|
+
* The extra sentence for the ONE cause this refusal has that a developer can undo
|
|
1678
|
+
* with a command: a `gjsify link` development link.
|
|
1679
|
+
*
|
|
1680
|
+
* Measured: relinking with a narrower `--packages` left `node_modules/is-number`
|
|
1681
|
+
* pointing into the checkout while the new override no longer named it, and every
|
|
1682
|
+
* later `gjsify install` died on the refusal above — which spoke only of "a
|
|
1683
|
+
* workspace source tree" and named no command at all. `gjsify unlink` did not
|
|
1684
|
+
* rescue it either (it removed the override first, destroying the escape route),
|
|
1685
|
+
* so the only way out was a hand-written `rm`. Both halves are fixed at the
|
|
1686
|
+
* source; this message is what tells someone already wedged by an older CLI.
|
|
1687
|
+
*
|
|
1688
|
+
* The override file may be GONE by the time this fires, so its absence is not
|
|
1689
|
+
* evidence: the remedy is named whenever a `node_modules` entry points outside,
|
|
1690
|
+
* and it names the file only when it is actually there.
|
|
1691
|
+
*/
|
|
1692
|
+
function devLinkRemedy(prefix, dest) {
|
|
1693
|
+
// Same walk the guard and the installer do, so the path this message tells
|
|
1694
|
+
// someone to run `gjsify unlink` in is the one that holds the override.
|
|
1695
|
+
const root = devLinkRoot(prefix);
|
|
1696
|
+
const override = devLinkPath(root);
|
|
1697
|
+
const active = fs.existsSync(override);
|
|
1698
|
+
return (`\n If a \`gjsify link\` development link put it there${active ? ` — ${override} is present` : ''}: ` +
|
|
1699
|
+
`run \`gjsify unlink\` in ${root} to remove the links and restore the registry copies, ` +
|
|
1700
|
+
`or \`gjsify link <checkout> --packages …\` to re-select. ` +
|
|
1701
|
+
`If no override is left, delete ${dest} by hand and re-run.`);
|
|
1702
|
+
}
|
|
1539
1703
|
function depth(installPath) {
|
|
1540
1704
|
// `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, …
|
|
1541
1705
|
return installPath.split('/node_modules/').length;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of `--immutable` that was missing: every locked package is present AND
|
|
3
|
+
* NOTHING ELSE IS.
|
|
4
|
+
*
|
|
5
|
+
* The installer only ever ADDED. Handed a `node_modules` carrying a package the
|
|
6
|
+
* lockfile never mentions, `--immutable` extracted whatever was missing, left the
|
|
7
|
+
* stranger in place and exited 0 — a green install over a tree its own lockfile does
|
|
8
|
+
* not describe.
|
|
9
|
+
*
|
|
10
|
+
* THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
|
|
11
|
+
* `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
|
|
12
|
+
* node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
|
|
13
|
+
* `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
|
|
14
|
+
* The fix push changed the lockfile → new key → miss → the FALLBACK restored the
|
|
15
|
+
* nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
|
|
16
|
+
* saw one type under two identities: TS2883. Worse, the still-nested tree was saved
|
|
17
|
+
* under the NEW key, so no plain re-run could go green — the failure reproduced
|
|
18
|
+
* itself until the caches were deleted by hand. Nothing about the PR's code was
|
|
19
|
+
* wrong, and any lockfile-changing PR could poison itself the same way.
|
|
20
|
+
*
|
|
21
|
+
* WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
|
|
22
|
+
* yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
|
|
23
|
+
* The flag's own name promises not to mutate, and this repo already refuses
|
|
24
|
+
* automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
|
|
25
|
+
* A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
|
|
26
|
+
* some other tool put there — while a verifier that reports cannot. And the failure
|
|
27
|
+
* mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
|
|
28
|
+
* carrying the exact paths and the fix.
|
|
29
|
+
*
|
|
30
|
+
* That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
|
|
31
|
+
* this check fatal, a restored FOREIGN tree stops being silent corruption and
|
|
32
|
+
* becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
|
|
33
|
+
* fallback is gone — see the comment there. The two decisions are one decision.
|
|
34
|
+
*/
|
|
35
|
+
import type { InstalledPackage } from './prune-prefix.js';
|
|
36
|
+
/** A package directory on disk that the lockfile does not describe. */
|
|
37
|
+
export interface ExtraneousPackage {
|
|
38
|
+
/**
|
|
39
|
+
* Path relative to the install prefix, always `/`-separated — the exact shape of
|
|
40
|
+
* a `gjsify-lock.json` `packages` key, so "not in the lockfile" is checkable by
|
|
41
|
+
* grepping the reported string against the file.
|
|
42
|
+
*/
|
|
43
|
+
installPath: string;
|
|
44
|
+
/** From the on-disk manifest; the directory path when it was unreadable. */
|
|
45
|
+
name: string;
|
|
46
|
+
version: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Package directories under `prefix` that no entry of `expected` accounts for.
|
|
50
|
+
*
|
|
51
|
+
* Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
|
|
52
|
+
* reading either, so every branch — nested placements, symlinks, subtree collapsing —
|
|
53
|
+
* is decidable without a filesystem.
|
|
54
|
+
*
|
|
55
|
+
* Three things are deliberately NOT extraneous:
|
|
56
|
+
* - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
|
|
57
|
+
* and by construction absent from the lockfile (`scanPrefix` does not descend
|
|
58
|
+
* into one either, so the user's own sources are never walked);
|
|
59
|
+
* - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
|
|
60
|
+
* - a package the lockfile describes but the PLATFORM filter skipped on this host.
|
|
61
|
+
* It is described, so it is not a stranger; removing the foreign-platform residue
|
|
62
|
+
* is `prune-prefix.ts`'s separate job under a separate rule.
|
|
63
|
+
*
|
|
64
|
+
* Descendants of an extraneous directory are collapsed away: reporting the parent
|
|
65
|
+
* says everything, and the nested `@girs` tree of the incident would otherwise list
|
|
66
|
+
* its whole subtree.
|
|
67
|
+
*/
|
|
68
|
+
export declare function findExtraneous(opts: {
|
|
69
|
+
prefix: string;
|
|
70
|
+
installed: readonly InstalledPackage[];
|
|
71
|
+
expected: readonly {
|
|
72
|
+
installPath: string;
|
|
73
|
+
}[];
|
|
74
|
+
}): ExtraneousPackage[];
|
|
75
|
+
/** How many paths the message names before it starts counting. */
|
|
76
|
+
export declare const EXTRANEOUS_REPORT_LIMIT = 20;
|
|
77
|
+
/**
|
|
78
|
+
* The refusal, built to be actionable from the CI log alone: what is wrong, which
|
|
79
|
+
* paths, and the one thing that fixes it. Truncated past
|
|
80
|
+
* {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
|
|
81
|
+
* under a thousand lines.
|
|
82
|
+
*
|
|
83
|
+
* It says DELETE, and says so as the only remedy, because that is what was measured:
|
|
84
|
+
* a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
|
|
85
|
+
* nested stranger exactly where they were. The installer only adds, and `prune-prefix`
|
|
86
|
+
* judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
|
|
87
|
+
* matches no rule either owns. Sending a developer to `gjsify install` instead would
|
|
88
|
+
* hand them a command that changes nothing and a refusal that repeats verbatim.
|
|
89
|
+
*/
|
|
90
|
+
export declare function formatExtraneousError(entries: readonly ExtraneousPackage[], prefix: string): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of `--immutable` that was missing: every locked package is present AND
|
|
3
|
+
* NOTHING ELSE IS.
|
|
4
|
+
*
|
|
5
|
+
* The installer only ever ADDED. Handed a `node_modules` carrying a package the
|
|
6
|
+
* lockfile never mentions, `--immutable` extracted whatever was missing, left the
|
|
7
|
+
* stranger in place and exited 0 — a green install over a tree its own lockfile does
|
|
8
|
+
* not describe.
|
|
9
|
+
*
|
|
10
|
+
* THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
|
|
11
|
+
* `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
|
|
12
|
+
* node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
|
|
13
|
+
* `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
|
|
14
|
+
* The fix push changed the lockfile → new key → miss → the FALLBACK restored the
|
|
15
|
+
* nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
|
|
16
|
+
* saw one type under two identities: TS2883. Worse, the still-nested tree was saved
|
|
17
|
+
* under the NEW key, so no plain re-run could go green — the failure reproduced
|
|
18
|
+
* itself until the caches were deleted by hand. Nothing about the PR's code was
|
|
19
|
+
* wrong, and any lockfile-changing PR could poison itself the same way.
|
|
20
|
+
*
|
|
21
|
+
* WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
|
|
22
|
+
* yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
|
|
23
|
+
* The flag's own name promises not to mutate, and this repo already refuses
|
|
24
|
+
* automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
|
|
25
|
+
* A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
|
|
26
|
+
* some other tool put there — while a verifier that reports cannot. And the failure
|
|
27
|
+
* mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
|
|
28
|
+
* carrying the exact paths and the fix.
|
|
29
|
+
*
|
|
30
|
+
* That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
|
|
31
|
+
* this check fatal, a restored FOREIGN tree stops being silent corruption and
|
|
32
|
+
* becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
|
|
33
|
+
* fallback is gone — see the comment there. The two decisions are one decision.
|
|
34
|
+
*/
|
|
35
|
+
import { relative, sep } from 'node:path';
|
|
36
|
+
/** `/`-separated, no trailing slash — lockfile key shape on every host. */
|
|
37
|
+
function normalizePath(p) {
|
|
38
|
+
const slashed = sep === '/' ? p : p.split(sep).join('/');
|
|
39
|
+
return slashed.endsWith('/') ? slashed.slice(0, -1) : slashed;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Package directories under `prefix` that no entry of `expected` accounts for.
|
|
43
|
+
*
|
|
44
|
+
* Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
|
|
45
|
+
* reading either, so every branch — nested placements, symlinks, subtree collapsing —
|
|
46
|
+
* is decidable without a filesystem.
|
|
47
|
+
*
|
|
48
|
+
* Three things are deliberately NOT extraneous:
|
|
49
|
+
* - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
|
|
50
|
+
* and by construction absent from the lockfile (`scanPrefix` does not descend
|
|
51
|
+
* into one either, so the user's own sources are never walked);
|
|
52
|
+
* - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
|
|
53
|
+
* - a package the lockfile describes but the PLATFORM filter skipped on this host.
|
|
54
|
+
* It is described, so it is not a stranger; removing the foreign-platform residue
|
|
55
|
+
* is `prune-prefix.ts`'s separate job under a separate rule.
|
|
56
|
+
*
|
|
57
|
+
* Descendants of an extraneous directory are collapsed away: reporting the parent
|
|
58
|
+
* says everything, and the nested `@girs` tree of the incident would otherwise list
|
|
59
|
+
* its whole subtree.
|
|
60
|
+
*/
|
|
61
|
+
export function findExtraneous(opts) {
|
|
62
|
+
const described = new Set(opts.expected.map((n) => normalizePath(n.installPath)));
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const pkg of opts.installed) {
|
|
65
|
+
if (pkg.linked)
|
|
66
|
+
continue;
|
|
67
|
+
const installPath = normalizePath(relative(opts.prefix, pkg.dir));
|
|
68
|
+
// Outside the prefix, or the prefix itself: not this install's to judge.
|
|
69
|
+
if (installPath === '' || installPath.startsWith('..'))
|
|
70
|
+
continue;
|
|
71
|
+
if (described.has(installPath))
|
|
72
|
+
continue;
|
|
73
|
+
found.push({ installPath, name: pkg.name, version: pkg.version });
|
|
74
|
+
}
|
|
75
|
+
found.sort((a, b) => (a.installPath < b.installPath ? -1 : a.installPath > b.installPath ? 1 : 0));
|
|
76
|
+
const roots = [];
|
|
77
|
+
for (const entry of found) {
|
|
78
|
+
const parent = roots[roots.length - 1];
|
|
79
|
+
// Sorted, so an enclosing extraneous directory is always the last one kept.
|
|
80
|
+
if (parent && entry.installPath.startsWith(`${parent.installPath}/`))
|
|
81
|
+
continue;
|
|
82
|
+
roots.push(entry);
|
|
83
|
+
}
|
|
84
|
+
return roots;
|
|
85
|
+
}
|
|
86
|
+
/** How many paths the message names before it starts counting. */
|
|
87
|
+
export const EXTRANEOUS_REPORT_LIMIT = 20;
|
|
88
|
+
/**
|
|
89
|
+
* The refusal, built to be actionable from the CI log alone: what is wrong, which
|
|
90
|
+
* paths, and the one thing that fixes it. Truncated past
|
|
91
|
+
* {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
|
|
92
|
+
* under a thousand lines.
|
|
93
|
+
*
|
|
94
|
+
* It says DELETE, and says so as the only remedy, because that is what was measured:
|
|
95
|
+
* a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
|
|
96
|
+
* nested stranger exactly where they were. The installer only adds, and `prune-prefix`
|
|
97
|
+
* judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
|
|
98
|
+
* matches no rule either owns. Sending a developer to `gjsify install` instead would
|
|
99
|
+
* hand them a command that changes nothing and a refusal that repeats verbatim.
|
|
100
|
+
*/
|
|
101
|
+
export function formatExtraneousError(entries, prefix) {
|
|
102
|
+
const shown = entries.slice(0, EXTRANEOUS_REPORT_LIMIT);
|
|
103
|
+
const lines = shown.map((e) => ` ${e.installPath} (${e.name}@${e.version})`);
|
|
104
|
+
if (entries.length > shown.length)
|
|
105
|
+
lines.push(` … and ${entries.length - shown.length} more`);
|
|
106
|
+
return [
|
|
107
|
+
`install: --immutable, but ${prefix}/node_modules holds ${entries.length} package(s) ` +
|
|
108
|
+
'that gjsify-lock.json does not describe.',
|
|
109
|
+
'--immutable installs the lockfile and removes nothing, so each of these would survive ' +
|
|
110
|
+
'the install and be resolved, bundled and type-checked as if it belonged ' +
|
|
111
|
+
'(gjsify#1683: a nested copy of an already-hoisted package → TS2883, one type ' +
|
|
112
|
+
'with two identities).',
|
|
113
|
+
...lines,
|
|
114
|
+
'Fix: delete those directories — or the whole node_modules — and re-run. A plain ' +
|
|
115
|
+
'`gjsify install` does NOT clear them: it only ever adds, and its prune pass judges ' +
|
|
116
|
+
'foreign-PLATFORM packages alone (prune-prefix.ts), so an undescribed package ' +
|
|
117
|
+
'survives both. Deleting is currently the only thing that removes one. ' +
|
|
118
|
+
'In CI this usually means a cache entry was restored from a DIFFERENT lockfile.',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// `owner.json` inside records pid + start time for staleness checks.
|
|
18
18
|
//
|
|
19
19
|
// Staleness — a crashed install cannot release its lock, so contenders steal when
|
|
20
|
-
// (a) the owner pid is provably dead (`/proc/<pid>`
|
|
21
|
-
//
|
|
20
|
+
// (a) the owner pid is provably dead (`isPidAlive` in `process-table.ts`: `/proc/<pid>` where
|
|
21
|
+
// procfs exists, a signal-0 probe elsewhere), or
|
|
22
22
|
// (b) the lock outlives GJSIFY_INSTALL_LOCK_STALE_MS (default 35 min — just above the 30-min
|
|
23
23
|
// install budget, so a budget-bound install always exits before its lock can be stolen out
|
|
24
24
|
// from under it), or
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
//
|
|
32
32
|
// GJSIFY_INSTALL_LOCK=0 disables locking entirely — for a network filesystem with broken
|
|
33
33
|
// rename/mkdir atomicity, or an outer tool that already serializes installs.
|
|
34
|
-
import {
|
|
34
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
35
|
import { dirname, join, resolve } from 'node:path';
|
|
36
|
+
import { isPidAlive } from './process-table.js';
|
|
36
37
|
const LOCK_DIR_NAME = '.gjsify-install-lock';
|
|
37
38
|
const OWNER_FILE = 'owner.json';
|
|
38
39
|
/** Default staleness budget — see the header comment for why 35 min. */
|
|
@@ -60,28 +61,6 @@ function lockingDisabled() {
|
|
|
60
61
|
const trimmed = flag.trim();
|
|
61
62
|
return trimmed === '0' || trimmed === 'false';
|
|
62
63
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Is `pid` a live process on this machine?
|
|
65
|
-
*
|
|
66
|
-
* `/proc/<pid>` is the reliable cross-runtime probe on Linux, identical under Node and GJS;
|
|
67
|
-
* elsewhere a signal-0 probe (ESRCH dead, EPERM alive-but-foreign). The GJS `process.kill`
|
|
68
|
-
* polyfill shells out and cannot report ESRCH, but GJS only runs on /proc platforms in practice,
|
|
69
|
-
* so the fallback branch is Node's. Unknown outcomes count as ALIVE — never steal a lock that
|
|
70
|
-
* cannot be proven abandoned; the mtime staleness budget still applies.
|
|
71
|
-
*/
|
|
72
|
-
function isPidAlive(pid) {
|
|
73
|
-
// `existsSync` never throws by contract: Node returns false on any error, and the GJS shim is
|
|
74
|
-
// a typeof guard + Gio `query_exists`, neither with a throw path in the GIR.
|
|
75
|
-
if (existsSync('/proc/self'))
|
|
76
|
-
return existsSync(`/proc/${pid}`);
|
|
77
|
-
try {
|
|
78
|
-
process.kill(pid, 0);
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
return err.code === 'EPERM';
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
64
|
function mtimeMs(path) {
|
|
86
65
|
try {
|
|
87
66
|
return statSync(path).mtimeMs;
|