@gjsify/cli 0.28.0 → 0.30.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 +76 -61
- package/lib/commands/format.js +17 -8
- package/lib/commands/install.d.ts +27 -0
- package/lib/commands/install.js +25 -8
- package/lib/commands/system-check.js +22 -2
- package/lib/config.js +1 -1
- package/lib/utils/bin-shim.d.ts +65 -0
- package/lib/utils/bin-shim.js +115 -13
- package/lib/utils/check-system-deps.d.ts +56 -0
- package/lib/utils/check-system-deps.js +159 -5
- package/lib/utils/detect-native-packages.d.ts +45 -1
- package/lib/utils/detect-native-packages.js +56 -0
- package/lib/utils/dlx-cache.js +1 -1
- package/lib/utils/install-backend-native.d.ts +87 -1
- package/lib/utils/install-backend-native.js +147 -34
- package/lib/utils/install-global.js +13 -1
- package/lib/utils/run-lifecycle-script.js +41 -25
- package/lib/utils/spawn.d.ts +17 -1
- package/lib/utils/spawn.js +23 -6
- package/lib/utils/system-gi.d.ts +71 -0
- package/lib/utils/system-gi.js +221 -0
- package/package.json +17 -17
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
// reported. So the DECLARATIONS are locked and the VERDICT is recomputed per
|
|
23
23
|
// host at install time (see applyPlatformFilter).
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
// documented, because the first draft of this feature broke
|
|
27
|
-
// as if it did not:
|
|
25
|
+
// THREE INVARIANTS MAKE THAT CLAIM TRUE, and each is structural rather than
|
|
26
|
+
// documented, because the first draft of this feature broke the first two while
|
|
27
|
+
// reading as if it did not — and shipped without the third:
|
|
28
28
|
//
|
|
29
29
|
// 1. THE RESOLVE IS TARGET-BLIND. `resolveDeps` is not given the platform
|
|
30
30
|
// target at all — it cannot be, so it cannot record a target-dependent
|
|
@@ -47,6 +47,31 @@
|
|
|
47
47
|
// fixpoint for the same reason (refs/npm-cli/workspaces/arborist/lib/
|
|
48
48
|
// calc-dep-flags.js: "If a node is changed, we add to the queue and
|
|
49
49
|
// continue until no more changes").
|
|
50
|
+
// 3. OPTIONALITY BELONGS TO THE EDGE, NOT TO THE BLOCK THE NAME WAS READ FROM.
|
|
51
|
+
// A name listed in BOTH `dependencies` and `optionalDependencies` is
|
|
52
|
+
// OPTIONAL: "entries in optionalDependencies will override entries of the
|
|
53
|
+
// same name in dependencies" (npm's package.json docs), which is why every
|
|
54
|
+
// walk over a node's dep edges here goes through {@link requiredDepEntries}
|
|
55
|
+
// instead of `Object.keys(node.dependencies)`. `@parcel/rust@2.16.4` is why
|
|
56
|
+
// it is written down: it declares all eight of its per-platform napi
|
|
57
|
+
// packages in both blocks, so a `dependencies`-only walk made
|
|
58
|
+
// `@parcel/rust-darwin-x64` REQUIRED and every Linux install of any tree
|
|
59
|
+
// containing parcel died with EBADPLATFORM — while `npm install` on the same
|
|
60
|
+
// manifest exits 0, installs
|
|
61
|
+
// `@parcel/rust-linux-x64-gnu`, and records the other seven as
|
|
62
|
+
// `"optional": true` in its own lockfile. The rule was already applied to
|
|
63
|
+
// swallowed resolve failures (`assertRequiredEdgesResolved`: "optionality
|
|
64
|
+
// lives on the edge, not on its endpoints") and missing from the two walks
|
|
65
|
+
// that decide fatal-vs-inert, which is the worst place to have half of it.
|
|
66
|
+
// It costs a lockfile field (`optionalDependencies` per entry, v4): without
|
|
67
|
+
// it the fresh-resolve path and the lockfile path would disagree about the
|
|
68
|
+
// same tree, which is the split invariant 2 exists to close.
|
|
69
|
+
// WHY THE DEFECT LOOKED LIKE IT DID NOT EXIST: the same napi shape is
|
|
70
|
+
// overwhelmingly published in ONE block — measured on the latest of `oxlint`,
|
|
71
|
+
// `esbuild` and `rollup`, all three list their per-platform bindings in
|
|
72
|
+
// `optionalDependencies` only (zero overlap), so they installed correctly
|
|
73
|
+
// throughout and every mock corpus reaches for that shape too. The block a
|
|
74
|
+
// publisher chooses is not a property this installer may assume.
|
|
50
75
|
//
|
|
51
76
|
// Out of scope (still deferred): peerDependencies validation,
|
|
52
77
|
// lifecycle scripts, git/file specs.
|
|
@@ -74,9 +99,12 @@ const LOCKFILE_NAME = 'gjsify-lock.json';
|
|
|
74
99
|
/**
|
|
75
100
|
* v3 adds the per-entry platform declaration (`os`/`cpu`/`libc`) plus
|
|
76
101
|
* `optional`, which together let a foreign-platform package be recorded without
|
|
77
|
-
* being installed.
|
|
102
|
+
* being installed. v4 adds the per-entry `optionalDependencies` map, without
|
|
103
|
+
* which the optionality fixpoint cannot see WHICH of an entry's edges are
|
|
104
|
+
* optional and a name declared in both blocks comes out required (header note,
|
|
105
|
+
* invariant 3). Older lockfiles are still READ (see {@link readLockfile}).
|
|
78
106
|
*/
|
|
79
|
-
const LOCKFILE_VERSION =
|
|
107
|
+
const LOCKFILE_VERSION = 4;
|
|
80
108
|
/**
|
|
81
109
|
* Lockfile versions this CLI can read. A v2 lockfile is a valid pin set that
|
|
82
110
|
* simply predates the platform fields; rejecting it (the old
|
|
@@ -84,8 +112,19 @@ const LOCKFILE_VERSION = 3;
|
|
|
84
112
|
* the following fresh resolve bump every `^`-range to the newest in-range
|
|
85
113
|
* version — the exact silent churn lockfile preservation exists to prevent, on
|
|
86
114
|
* every user's first install after upgrading the CLI.
|
|
115
|
+
*
|
|
116
|
+
* READING an older file is not the same as TRUSTING it for everything: only the
|
|
117
|
+
* current version short-circuits the resolve (see the branch below), so a v2/v3
|
|
118
|
+
* file seeds version preservation and is then rewritten. `--immutable` is the one
|
|
119
|
+
* path that must consume what it was handed verbatim, so there — and only there —
|
|
120
|
+
* a pre-v4 file keeps its pre-v4 edge fidelity: a both-blocks optional dep is
|
|
121
|
+
* judged required, exactly as before this fix. That is a strictly unchanged
|
|
122
|
+
* outcome rather than a new failure, and one plain `gjsify install` upgrades the
|
|
123
|
+
* file. Failing `--immutable` on an old-but-readable lockfile instead would break
|
|
124
|
+
* every CI whose committed file predates this CLI, including this repo's own
|
|
125
|
+
* (still v2).
|
|
87
126
|
*/
|
|
88
|
-
const READABLE_LOCKFILE_VERSIONS = new Set([2, LOCKFILE_VERSION]);
|
|
127
|
+
const READABLE_LOCKFILE_VERSIONS = new Set([2, 3, LOCKFILE_VERSION]);
|
|
89
128
|
export async function installPackagesNative(opts) {
|
|
90
129
|
// Fail clearly on an unsupported Node major BEFORE touching the ABI-locked
|
|
91
130
|
// native deps — otherwise they SIGSEGV mid-extract with no actionable message.
|
|
@@ -161,11 +200,18 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
|
|
|
161
200
|
else {
|
|
162
201
|
// A resolve has to run (new/changed/removed dep, or no lockfile yet).
|
|
163
202
|
//
|
|
164
|
-
// A pre-
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
203
|
+
// A pre-v4 lockfile lands here too, even when it matches the request,
|
|
204
|
+
// because each bump added something the verdict is recomputed FROM and a
|
|
205
|
+
// file that lacks it cannot be consumed verbatim: pre-v3 entries carry no
|
|
206
|
+
// platform declaration (so the foreign-platform tree would keep installing
|
|
207
|
+
// forever), pre-v4 entries carry no `optionalDependencies` map (so a name
|
|
208
|
+
// declared in both blocks looks required and its incompatible siblings
|
|
209
|
+
// fail the install — the `@parcel/rust` shape, header note invariant 3).
|
|
210
|
+
// One resolve — version-preserving, per the seeding below, so nothing
|
|
211
|
+
// bumps — upgrades the file. That matters beyond tidiness here: the
|
|
212
|
+
// lockfile is written BEFORE the platform filter runs, so the failing
|
|
213
|
+
// install left a v3 file behind that would otherwise reproduce its own
|
|
214
|
+
// EBADPLATFORM on every later run without ever resolving again.
|
|
169
215
|
//
|
|
170
216
|
// Unless --refresh-lockfile was passed, seed it with the versions
|
|
171
217
|
// already pinned in the existing lockfile so unchanged deps keep their
|
|
@@ -641,7 +687,13 @@ optionalSpecs) {
|
|
|
641
687
|
name: `${edge.name}@${version}`,
|
|
642
688
|
});
|
|
643
689
|
if (!skipDeps) {
|
|
644
|
-
|
|
690
|
+
// REQUIRED edges only — a name this package also lists in
|
|
691
|
+
// `optionalDependencies` is queued by the loop below instead,
|
|
692
|
+
// once, as optional (header note, invariant 3). Queuing it here
|
|
693
|
+
// as well would place it with a `required: true` forward guess,
|
|
694
|
+
// making a resolve failure fatal and (via the fixpoint) its
|
|
695
|
+
// incompatible platform siblings fatal too.
|
|
696
|
+
for (const [depName, depRange] of requiredDepEntries(node)) {
|
|
645
697
|
queue.push({
|
|
646
698
|
from: installPath,
|
|
647
699
|
name: depName,
|
|
@@ -764,6 +816,38 @@ function requiredTopLevelNames(specs, optionalSpecs) {
|
|
|
764
816
|
}
|
|
765
817
|
return names;
|
|
766
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* A node's REQUIRED dependency edges: its `dependencies`, minus every name its
|
|
821
|
+
* `optionalDependencies` also lists. The one place that decides what kind an edge
|
|
822
|
+
* is — both the resolve walk and the optionality fixpoint read it, so the two
|
|
823
|
+
* cannot drift.
|
|
824
|
+
*
|
|
825
|
+
* THE SUBTRACTION IS npm's RULE, not a policy of ours: "entries in
|
|
826
|
+
* optionalDependencies will override entries of the same name in dependencies, so
|
|
827
|
+
* it's usually best to only put in one place" (npm's package.json docs). Measured
|
|
828
|
+
* on `@parcel/rust@2.16.4`, which lists all eight per-platform napi packages in
|
|
829
|
+
* both blocks: `npm install` exits 0, installs `@parcel/rust-linux-x64-gnu` only,
|
|
830
|
+
* and writes `"optional": true` for all eight in its `package-lock.json`.
|
|
831
|
+
*
|
|
832
|
+
* WHY IT IS NOT MERELY A DEDUP: the kind decides what an incompatible
|
|
833
|
+
* `os`/`cpu`/`libc` MEANS (`applyPlatformFilter` — fatal vs. inert) and whether a
|
|
834
|
+
* failed resolve is tolerated. Reading `dependencies` alone therefore does not
|
|
835
|
+
* just visit a name twice, it promotes a foreign-platform binary to a required
|
|
836
|
+
* dependency and fails installs npm completes.
|
|
837
|
+
*
|
|
838
|
+
* The inverse direction is deliberately NOT symmetric: a name in
|
|
839
|
+
* `optionalDependencies` alone is optional (no `dependencies` entry to override),
|
|
840
|
+
* which is what {@link resolveDeps}'s second queue loop covers.
|
|
841
|
+
*/
|
|
842
|
+
export function requiredDepEntries(node) {
|
|
843
|
+
const entries = [];
|
|
844
|
+
for (const [name, range] of Object.entries(node.dependencies)) {
|
|
845
|
+
if (name in node.optionalDependencies)
|
|
846
|
+
continue;
|
|
847
|
+
entries.push([name, range]);
|
|
848
|
+
}
|
|
849
|
+
return entries;
|
|
850
|
+
}
|
|
767
851
|
/**
|
|
768
852
|
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph.
|
|
769
853
|
* The only writer of the final flag; whatever the nodes arrive carrying is an
|
|
@@ -771,9 +855,10 @@ function requiredTopLevelNames(specs, optionalSpecs) {
|
|
|
771
855
|
* back out of a file on the lockfile path) and is overwritten unconditionally.
|
|
772
856
|
*
|
|
773
857
|
* DEFINITION: a node is REQUIRED iff it is reachable from `requiredNames`
|
|
774
|
-
* through
|
|
775
|
-
*
|
|
776
|
-
*
|
|
858
|
+
* through REQUIRED edges alone ({@link requiredDepEntries} — `dependencies` minus
|
|
859
|
+
* the names `optionalDependencies` overrides); every other node is optional.
|
|
860
|
+
* Optionality is therefore INHERITED — a plain dependency OF an optional package
|
|
861
|
+
* is still optional — which is what npm's `optionalSet` computes.
|
|
777
862
|
*
|
|
778
863
|
* WHY A FIXPOINT AND NOT A ONE-SHOT PROMOTION AT THE REUSE SITE (the reviewed
|
|
779
864
|
* defect): the walk queues a node's own dep edges at the moment it is placed,
|
|
@@ -788,23 +873,29 @@ function requiredTopLevelNames(specs, optionalSpecs) {
|
|
|
788
873
|
* refs/npm-cli/workspaces/arborist/lib/calc-dep-flags.js: "If a node is changed,
|
|
789
874
|
* we add to the queue and continue until no more changes."
|
|
790
875
|
*
|
|
791
|
-
* PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the
|
|
792
|
-
* `
|
|
793
|
-
*
|
|
794
|
-
* `node.optionalDependencies`
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
876
|
+
* PATH-INDEPENDENCE IS THE OTHER REQUIREMENT, and it is why the lockfile carries
|
|
877
|
+
* an `optionalDependencies` map per entry (v4). The walk USED to read
|
|
878
|
+
* `dependencies` only, for exactly that reason — a lockfile entry recorded nothing
|
|
879
|
+
* else, so consulting `node.optionalDependencies` would have made the two paths
|
|
880
|
+
* compute different flags for the same tree. The premise was fixable and the
|
|
881
|
+
* consequence was not: a publisher listing the same name in BOTH blocks means
|
|
882
|
+
* "optional" to npm, came out REQUIRED here, and the shape is not rare — it is
|
|
883
|
+
* `@parcel/rust@2.16.4` (all eight per-platform napi packages in both blocks), so
|
|
884
|
+
* every Linux install of any tree containing parcel failed with EBADPLATFORM on
|
|
885
|
+
* `@parcel/rust-darwin-x64` while `npm install` thinned the same tree and exited
|
|
886
|
+
* 0. "Erring toward REQUIRED errs toward the loud failure" is true and was the
|
|
887
|
+
* wrong trade: the loud failure was not a real one, and no `--force` lifts it
|
|
888
|
+
* (nor should it). So the declaration is persisted and both paths subtract it.
|
|
889
|
+
* Reading a pre-v4 lockfile leaves the map empty, which reproduces the old
|
|
890
|
+
* behaviour for that file alone — see READABLE_LOCKFILE_VERSIONS.
|
|
802
891
|
*
|
|
803
892
|
* Runs before `writeLockfile` so the persisted flag is the final one, and before
|
|
804
893
|
* `applyPlatformFilter` so the fatal-vs-inert decision reads it rather than the
|
|
805
|
-
* guess.
|
|
894
|
+
* guess. Exported so the unit spec can drive it with INJECTED graphs: the whole
|
|
895
|
+
* fatal-vs-inert question is decided by these two functions in composition, and
|
|
896
|
+
* that is worth pinning without a registry.
|
|
806
897
|
*/
|
|
807
|
-
function computeOptionalFlags(nodes, requiredNames, log) {
|
|
898
|
+
export function computeOptionalFlags(nodes, requiredNames, log) {
|
|
808
899
|
const byPath = new Map();
|
|
809
900
|
for (const node of nodes)
|
|
810
901
|
byPath.set(node.installPath, node);
|
|
@@ -833,7 +924,7 @@ function computeOptionalFlags(nodes, requiredNames, log) {
|
|
|
833
924
|
const node = byPath.get(installPath);
|
|
834
925
|
if (!node)
|
|
835
926
|
continue;
|
|
836
|
-
for (const depName of
|
|
927
|
+
for (const [depName] of requiredDepEntries(node)) {
|
|
837
928
|
// Resolve the edge the way the REQUESTER will at runtime — through
|
|
838
929
|
// the ancestor `node_modules` chain — so a nested copy is credited
|
|
839
930
|
// to the requester that nested it and the hoisted one is not
|
|
@@ -904,8 +995,14 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
|
|
|
904
995
|
continue;
|
|
905
996
|
// A REQUIRED package's own optionalDependency stays optional whatever
|
|
906
997
|
// the package's flag says: optionality lives on the edge, not on its
|
|
907
|
-
// endpoints
|
|
908
|
-
//
|
|
998
|
+
// endpoints (header note, invariant 3 — the same rule
|
|
999
|
+
// {@link requiredDepEntries} applies to the two graph walks). This branch
|
|
1000
|
+
// is what keeps a darwin-only `fsevents` under a required `chokidar` from
|
|
1001
|
+
// failing every Linux install.
|
|
1002
|
+
//
|
|
1003
|
+
// Membership, not `requiredDepEntries`: the failed edge may name something
|
|
1004
|
+
// no longer in `dependencies` at all, and what is being asked here is
|
|
1005
|
+
// whether the requester declared THIS name optional.
|
|
909
1006
|
//
|
|
910
1007
|
// Safe on both paths because `skippedEdges` is only ever non-empty on
|
|
911
1008
|
// the fresh-resolve path, where `optionalDependencies` is populated; the
|
|
@@ -1137,6 +1234,11 @@ function writeLockfile(lockfilePath, specs, nodes) {
|
|
|
1137
1234
|
resolved: node.tarballUrl,
|
|
1138
1235
|
integrity: node.integrity,
|
|
1139
1236
|
dependencies: Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
|
|
1237
|
+
// The EDGE KINDS, for the same reason the platform declaration is
|
|
1238
|
+
// here: `computeOptionalFlags` runs on the lockfile path too and
|
|
1239
|
+
// cannot recompute what the file does not carry (v4, header note
|
|
1240
|
+
// invariant 3). Omitted when empty, which is most packages.
|
|
1241
|
+
optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
|
|
1140
1242
|
bin: node.bin,
|
|
1141
1243
|
// The DECLARATION, never the verdict: `inert` is per-host and
|
|
1142
1244
|
// deliberately absent from the file (a lockfile that recorded which
|
|
@@ -1208,7 +1310,12 @@ function lockfileToNodes(lockfile) {
|
|
|
1208
1310
|
integrity: entry.integrity,
|
|
1209
1311
|
installPath,
|
|
1210
1312
|
dependencies: entry.dependencies ?? {},
|
|
1211
|
-
|
|
1313
|
+
// Empty for a pre-v4 entry, which is why such a file never
|
|
1314
|
+
// short-circuits the resolve: with no edge kinds recorded, a name the
|
|
1315
|
+
// publisher put in both blocks reads as required here (the pre-fix
|
|
1316
|
+
// behaviour). `--immutable` is the one path that consumes it anyway —
|
|
1317
|
+
// see READABLE_LOCKFILE_VERSIONS.
|
|
1318
|
+
optionalDependencies: entry.optionalDependencies ?? {},
|
|
1212
1319
|
bin: entry.bin,
|
|
1213
1320
|
// Rebuilt from the recorded declaration so `applyPlatformFilter`
|
|
1214
1321
|
// reaches the SAME verdict on this path as on the fresh-resolve
|
|
@@ -1659,7 +1766,7 @@ function readShebang(file) {
|
|
|
1659
1766
|
* the same set from the same templates. See `./bin-shim.ts`.
|
|
1660
1767
|
*/
|
|
1661
1768
|
export function writeBinEntry(opts) {
|
|
1662
|
-
const { binDir, binName, targetAbs, gjs } = opts;
|
|
1769
|
+
const { binDir, binName, targetAbs, gjs, nodeFallbackAbs } = opts;
|
|
1663
1770
|
const platform = opts.platform ?? process.platform;
|
|
1664
1771
|
const linkPath = path.join(binDir, binName);
|
|
1665
1772
|
if (gjs) {
|
|
@@ -1668,7 +1775,7 @@ export function writeBinEntry(opts) {
|
|
|
1668
1775
|
// plain symlink (or npm's three-file shim) under this very name.
|
|
1669
1776
|
for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`])
|
|
1670
1777
|
rmWithRetry(stale);
|
|
1671
|
-
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle }));
|
|
1778
|
+
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble: gjs.envPreamble, isGjsBundle: isBundle, nodeFallbackAbs }));
|
|
1672
1779
|
try {
|
|
1673
1780
|
fs.chmodSync(linkPath, 0o755);
|
|
1674
1781
|
}
|
|
@@ -1686,6 +1793,7 @@ export function writeBinEntry(opts) {
|
|
|
1686
1793
|
interpreterArgs: ['-m'],
|
|
1687
1794
|
target: targetAbs,
|
|
1688
1795
|
prependEnv,
|
|
1796
|
+
fallback: nodeFallbackAbs === undefined ? undefined : { interpreter: 'node', target: nodeFallbackAbs },
|
|
1689
1797
|
});
|
|
1690
1798
|
fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
|
|
1691
1799
|
fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
|
|
@@ -1765,11 +1873,16 @@ async function linkBins(nodes, prefix, log) {
|
|
|
1765
1873
|
catch {
|
|
1766
1874
|
/* best effort */
|
|
1767
1875
|
}
|
|
1876
|
+
// Only a fallback that EXISTS is worth writing: a launcher naming a
|
|
1877
|
+
// missing Node entry would trade exit 127 for a different exit 127.
|
|
1878
|
+
const fallbackRel = picked.nodeFallback?.get(binName);
|
|
1879
|
+
const fallbackAbs = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
|
|
1768
1880
|
writeBinEntry({
|
|
1769
1881
|
binDir,
|
|
1770
1882
|
binName,
|
|
1771
1883
|
targetAbs,
|
|
1772
1884
|
gjs: picked.isGjsBin ? gjsEnvForPrefix() : undefined,
|
|
1885
|
+
nodeFallbackAbs: fallbackAbs !== undefined && fs.existsSync(fallbackAbs) ? fallbackAbs : undefined,
|
|
1773
1886
|
});
|
|
1774
1887
|
created++;
|
|
1775
1888
|
}
|
|
@@ -198,10 +198,19 @@ export function linkGlobalBins(packageNames, layout) {
|
|
|
198
198
|
// then tries to parse JavaScript as shell. Plain Node scripts
|
|
199
199
|
// with shebangs (lib/index.js) keep the direct-exec path.
|
|
200
200
|
const isGjsBundle = isGjsBundlePath(targetAbs);
|
|
201
|
+
// A global install extracts the WHOLE package, so when it declares
|
|
202
|
+
// an npm `bin` beside its `gjsify.bin` that entry is on disk here
|
|
203
|
+
// too and the launcher can fall back to it on a host without `gjs`
|
|
204
|
+
// — the same fix as the project-local backend. Guarded on existence
|
|
205
|
+
// rather than assumed: naming a missing Node entry would only trade
|
|
206
|
+
// one exit 127 for another. See `pickBinMap`.
|
|
207
|
+
const fallbackRel = picked.nodeFallback?.get(binName);
|
|
208
|
+
const fallbackCandidate = fallbackRel === undefined ? undefined : path.join(pkgDir, fallbackRel);
|
|
209
|
+
const nodeFallbackAbs = fallbackCandidate !== undefined && fs.existsSync(fallbackCandidate) ? fallbackCandidate : undefined;
|
|
201
210
|
// Only GJS bundles need the GI typelib search path. Plain Node
|
|
202
211
|
// scripts ignore GI_TYPELIB_PATH, so skipping the preamble there
|
|
203
212
|
// keeps the launcher minimal.
|
|
204
|
-
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble, isGjsBundle }));
|
|
213
|
+
fs.writeFileSync(linkPath, buildShLauncher(targetAbs, { envPreamble, isGjsBundle, nodeFallbackAbs }));
|
|
205
214
|
fs.chmodSync(linkPath, 0o755);
|
|
206
215
|
// Windows runs neither an extension-less file nor a `#!` line, so
|
|
207
216
|
// the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
|
|
@@ -224,6 +233,9 @@ export function linkGlobalBins(packageNames, layout) {
|
|
|
224
233
|
interpreterArgs: ['-m'],
|
|
225
234
|
target: targetAbs,
|
|
226
235
|
prependEnv,
|
|
236
|
+
fallback: nodeFallbackAbs === undefined
|
|
237
|
+
? undefined
|
|
238
|
+
: { interpreter: 'node', target: nodeFallbackAbs },
|
|
227
239
|
})
|
|
228
240
|
: buildLauncherShims({
|
|
229
241
|
interpreter: shebang?.prog || 'node',
|
|
@@ -7,8 +7,28 @@
|
|
|
7
7
|
// behaves like `yarn run` / `npm run`. That's wrong for the embedded use
|
|
8
8
|
// case where pack/publish needs the lifecycle script to finish, then keep
|
|
9
9
|
// running its own logic. This helper resolves to a Promise that settles
|
|
10
|
-
// when the child exits
|
|
11
|
-
//
|
|
10
|
+
// when the child exits.
|
|
11
|
+
//
|
|
12
|
+
// ## Not calling `process.exit()` is what ARMED the hang, not what avoided it
|
|
13
|
+
//
|
|
14
|
+
// This header used to end "no process.exit, no GLib-mainloop intermingling
|
|
15
|
+
// from `ensureMainLoop`". The second half was exactly backwards, and it is why
|
|
16
|
+
// nobody looked here for #1010.
|
|
17
|
+
//
|
|
18
|
+
// `@gjsify/child_process`'s async `spawn()` calls `ensureMainLoop()`
|
|
19
|
+
// unconditionally — the caller has no say. That arms a GJS main-loop hook whose
|
|
20
|
+
// blocking `g_main_loop_run()` only `process.exit()` tears down. So a helper
|
|
21
|
+
// that spawns asynchronously and deliberately does NOT exit leaves the loop
|
|
22
|
+
// armed forever: `gjsify pack` on a package with a `prepack` wrote its tarball,
|
|
23
|
+
// printed its complete JSON, and then parked at 0% CPU until killed at 5m30s.
|
|
24
|
+
// A package with no lifecycle script exited in under a second, and the same
|
|
25
|
+
// pack under Node exited in 0.68s.
|
|
26
|
+
//
|
|
27
|
+
// `utils/spawn.ts` already owns that contract and makes every call site DECLARE
|
|
28
|
+
// which side of it they are on. This helper was the one spawn in the CLI that
|
|
29
|
+
// bypassed it — the seventh of the "six near-identical wrappers" that file was
|
|
30
|
+
// written to consolidate. It now goes through it with `completion: 'return'`,
|
|
31
|
+
// which is what "settle, then keep running our own logic" means.
|
|
12
32
|
//
|
|
13
33
|
// Matches yarn / npm script semantics:
|
|
14
34
|
// - `shell: true` so `&&` / `|` / env-var refs work
|
|
@@ -16,8 +36,8 @@
|
|
|
16
36
|
// - `npm_lifecycle_event` / `npm_package_name` / `npm_package_version`
|
|
17
37
|
// env vars set
|
|
18
38
|
// - FORCE_COLOR=1 default unless caller overrides
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
39
|
import { delimiter, join } from 'node:path';
|
|
40
|
+
import { describeExit, spawnToCompletion } from './spawn.js';
|
|
21
41
|
import { findWorkspaceRoot } from './workspace-root.js';
|
|
22
42
|
/**
|
|
23
43
|
* Run a lifecycle script defined in `pkg.scripts[name]` from `wsDir`.
|
|
@@ -52,28 +72,24 @@ export async function runLifecycleScript(wsDir, pkg, name, opts = {}) {
|
|
|
52
72
|
npm_package_version: pkg.version ?? '',
|
|
53
73
|
...opts.env,
|
|
54
74
|
};
|
|
55
|
-
// `'
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (code === 0)
|
|
71
|
-
resolveOk();
|
|
72
|
-
else {
|
|
73
|
-
reject(new Error(`gjsify lifecycle-script: "${name}" in ${wsDir} exited with code ${code}`));
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
child.on('error', reject);
|
|
75
|
+
// `'ignore'` has no `spawnToCompletion` equivalent and no call site in the
|
|
76
|
+
// tree; folding it onto `'inherit'` would silently start printing where a
|
|
77
|
+
// caller asked for silence, so it is rejected rather than approximated.
|
|
78
|
+
if (opts.stdio === 'ignore') {
|
|
79
|
+
throw new Error('gjsify lifecycle-script: stdio "ignore" is not supported — see utils/spawn.ts');
|
|
80
|
+
}
|
|
81
|
+
const result = await spawnToCompletion(literal, [], {
|
|
82
|
+
// The whole point of this helper: pack/publish waits for the script and
|
|
83
|
+
// then keeps running. Under GJS that is the blocking path, which is what
|
|
84
|
+
// keeps the armed main loop from outliving us — see the header.
|
|
85
|
+
completion: 'return',
|
|
86
|
+
cwd: wsDir,
|
|
87
|
+
env: env,
|
|
88
|
+
shell: true,
|
|
89
|
+
stdio: opts.stdio === 'inherit-stderr' ? 'inherit-stderr' : opts.stdio === 'pipe' ? 'pipe' : 'inherit',
|
|
77
90
|
});
|
|
91
|
+
if (result.code !== 0) {
|
|
92
|
+
throw new Error(`gjsify lifecycle-script: "${name}" in ${wsDir} exited with ${describeExit(result)}`);
|
|
93
|
+
}
|
|
78
94
|
return true;
|
|
79
95
|
}
|
package/lib/utils/spawn.d.ts
CHANGED
|
@@ -33,8 +33,24 @@ export interface SpawnToCompletionOptions {
|
|
|
33
33
|
* `'inherit'` (default) forwards the parent's stdio. `'pipe'` gives the
|
|
34
34
|
* caller `child.stdout` / `child.stderr` via {@link onSpawn} — only
|
|
35
35
|
* meaningful together with `completion: 'exit'` (see {@link onSpawn}).
|
|
36
|
+
*
|
|
37
|
+
* `'inherit-stderr'` inherits, but routes the child's **stdout to the
|
|
38
|
+
* parent's stderr**. For a parent whose own stdout is a machine-readable
|
|
39
|
+
* stream: `gjsify pack --json` emits JSON there, and a lifecycle script's
|
|
40
|
+
* log lines interleaved into it make the result unparseable for every
|
|
41
|
+
* caller that pipes it into `JSON.parse`.
|
|
42
|
+
*/
|
|
43
|
+
stdio?: 'inherit' | 'pipe' | 'inherit-stderr';
|
|
44
|
+
/**
|
|
45
|
+
* Run `cmd` through the platform shell, so `&&`, `|` and env-var
|
|
46
|
+
* references work. `cmd` is then a whole command LINE and `args` must be
|
|
47
|
+
* empty — that is npm/yarn script semantics.
|
|
48
|
+
*
|
|
49
|
+
* Skips the win32 command rewrite by design, per this file's own rule:
|
|
50
|
+
* Node routes a `shell: true` spawn through `%COMSPEC%`, which finds
|
|
51
|
+
* `.cmd` shims on its own, so rewriting the string would corrupt it.
|
|
36
52
|
*/
|
|
37
|
-
|
|
53
|
+
shell?: boolean;
|
|
38
54
|
/**
|
|
39
55
|
* Invoked synchronously with the live `ChildProcess` right after a
|
|
40
56
|
* successful async spawn — the seam for kill-tracking registries and for
|
package/lib/utils/spawn.js
CHANGED
|
@@ -133,34 +133,51 @@ function mustBlock(opts) {
|
|
|
133
133
|
*/
|
|
134
134
|
export function spawnToCompletion(cmd, args, opts) {
|
|
135
135
|
const env = resolveEnv(opts);
|
|
136
|
+
// A shell invocation is a command LINE, not an argv, so the win32 rewrite
|
|
137
|
+
// must not touch it — see `SpawnToCompletionOptions.shell`.
|
|
138
|
+
const invocation = opts.shell
|
|
139
|
+
? { cmd, args: [...args], windowsVerbatimArguments: false }
|
|
140
|
+
: forWin32(cmd, args, env);
|
|
136
141
|
if (mustBlock(opts)) {
|
|
137
142
|
// `'inherit'` is NOT forwarded through `@gjsify/child_process`'s GJS
|
|
138
143
|
// spawnSync path (it drives a private main context via
|
|
139
144
|
// `communicateWithTimeout`), so the child's diagnostics would be
|
|
140
145
|
// silently dropped. Capture them and re-emit after the child exits.
|
|
141
146
|
// stdin stays inherited so an interactive child still reads the tty.
|
|
142
|
-
const
|
|
143
|
-
const r = spawnSync(win.cmd, win.args, {
|
|
147
|
+
const r = spawnSync(invocation.cmd, invocation.args, {
|
|
144
148
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
145
149
|
cwd: opts.cwd,
|
|
146
150
|
env,
|
|
151
|
+
shell: opts.shell,
|
|
147
152
|
maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
|
148
|
-
windowsVerbatimArguments:
|
|
153
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
149
154
|
});
|
|
150
155
|
if (r.error)
|
|
151
156
|
return Promise.reject(mapSpawnError(r.error, opts));
|
|
157
|
+
// The captured stdout goes to the parent's STDERR under
|
|
158
|
+
// `'inherit-stderr'` — same destination the streaming path routes it to,
|
|
159
|
+
// so the two agree on where a lifecycle script's chatter lands.
|
|
160
|
+
const out = opts.stdio === 'inherit-stderr' ? process.stderr : process.stdout;
|
|
152
161
|
if (r.stdout && r.stdout.length > 0)
|
|
153
|
-
|
|
162
|
+
out.write(r.stdout);
|
|
154
163
|
if (r.stderr && r.stderr.length > 0)
|
|
155
164
|
process.stderr.write(r.stderr);
|
|
156
165
|
return Promise.resolve({ code: r.status, signal: r.signal ?? null });
|
|
157
166
|
}
|
|
158
167
|
return new Promise((resolvePromise, reject) => {
|
|
159
|
-
const win =
|
|
168
|
+
const win = invocation;
|
|
160
169
|
const spawnOpts = {
|
|
161
170
|
cwd: opts.cwd,
|
|
162
|
-
|
|
171
|
+
// Numeric fds route the child's matching stream to that fd, so
|
|
172
|
+
// `['inherit', 2, 2]` is stdin inherited, stdout AND stderr → fd 2.
|
|
173
|
+
stdio: opts.stdio === 'pipe'
|
|
174
|
+
? ['ignore', 'pipe', 'pipe']
|
|
175
|
+
: opts.stdio === 'inherit-stderr'
|
|
176
|
+
? ['inherit', 2, 2]
|
|
177
|
+
: 'inherit',
|
|
163
178
|
};
|
|
179
|
+
if (opts.shell)
|
|
180
|
+
spawnOpts.shell = true;
|
|
164
181
|
if (env)
|
|
165
182
|
spawnOpts.env = env;
|
|
166
183
|
if (win.windowsVerbatimArguments)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Injectable host facts — the darwin branch is testable from Linux. */
|
|
2
|
+
export interface SystemGiOptions {
|
|
3
|
+
/** `process.platform` value; defaults to the running process. */
|
|
4
|
+
platform?: string;
|
|
5
|
+
/** Environment to read `GI_TYPELIB_PATH` / `PKG_CONFIG_PATH` from; defaults to `process.env`. */
|
|
6
|
+
env?: Record<string, string | undefined>;
|
|
7
|
+
/** Directory-existence predicate; defaults to a `statSync` probe. */
|
|
8
|
+
existsDir?: (dir: string) => boolean;
|
|
9
|
+
/** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
|
|
10
|
+
searchDirs?: (env: Record<string, string | undefined>) => string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Split an OS search-path variable, dropping empty segments.
|
|
14
|
+
*/
|
|
15
|
+
export declare function splitSearchPath(value: string | undefined, separator?: string): string[];
|
|
16
|
+
/**
|
|
17
|
+
* `pkg-config`'s OWN configured `.pc` search path plus `$PKG_CONFIG_PATH`. Every
|
|
18
|
+
* entry is a `<libdir>/pkgconfig` (or `<datadir>/pkgconfig`) directory, so its
|
|
19
|
+
* parent is a candidate libdir — which is how a prefix nobody hardcoded is
|
|
20
|
+
* found. Measured on the reported host: `pc_path` =
|
|
21
|
+
* `/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:…`,
|
|
22
|
+
* whose first parent is exactly the `/usr/local/lib` that resolves every leaf.
|
|
23
|
+
*
|
|
24
|
+
* `spawnSync` reports a missing executable as `result.error`, not by throwing —
|
|
25
|
+
* an absent `pkg-config` is expected on a runtime-only GTK install, so it is a
|
|
26
|
+
* checked return value here and not an exception path. (`@gjsify/child_process`
|
|
27
|
+
* matches Node here deliberately; see its `spawnSync` note.)
|
|
28
|
+
*/
|
|
29
|
+
export declare function pkgConfigSearchDirs(env: Record<string, string | undefined>): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Directories holding the host's system GObject-Introspection shared libraries —
|
|
32
|
+
* most specific first, deduplicated, each verified on disk.
|
|
33
|
+
*
|
|
34
|
+
* Three sources, in precedence order:
|
|
35
|
+
* 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
|
|
36
|
+
* libraries are the sibling `dirname()`. Accepted on directory existence
|
|
37
|
+
* alone: an explicit statement is not second-guessed.
|
|
38
|
+
* 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
|
|
39
|
+
* general mechanism (any prefix, including a custom one).
|
|
40
|
+
* 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
|
|
41
|
+
* case where GTK is installed and pkg-config is not.
|
|
42
|
+
* Sources 2 and 3 are GUESSES at a prefix, so each must show the
|
|
43
|
+
* `girepository-1.0/` marker before it is believed.
|
|
44
|
+
*
|
|
45
|
+
* Empty off darwin, and that is a statement about LOADERS, not about GI. Linux
|
|
46
|
+
* resolves these leaves through `ld.so`'s system-wide configured cache
|
|
47
|
+
* (`/etc/ld.so.conf.d`), which a package install populates — nothing per-process
|
|
48
|
+
* is needed. dyld is the one loader that neither consults a system-wide config
|
|
49
|
+
* NOR re-reads its search path after launch, which is why darwin needs the
|
|
50
|
+
* variable set at launch and nobody else does.
|
|
51
|
+
*
|
|
52
|
+
* win32 is deliberately absent rather than proven unnecessary: `PATH` is the DLL
|
|
53
|
+
* search path, a Windows GTK distribution puts its `bin` there itself, and
|
|
54
|
+
* Windows re-reads that path at every `LoadLibrary` — so if a system GTK ever
|
|
55
|
+
* turns out not to be on it, the repair is a `PATH` prepend (which
|
|
56
|
+
* {@link buildNativeEnv} already does for the prebuilds), never a second
|
|
57
|
+
* variable. Untested there.
|
|
58
|
+
*
|
|
59
|
+
* @returns absolute directories, or `[]` when this platform's loader needs none
|
|
60
|
+
*/
|
|
61
|
+
export declare function systemGiLibraryDirs({ platform, env, existsDir, searchDirs, }?: SystemGiOptions): string[];
|
|
62
|
+
/**
|
|
63
|
+
* Whether `current` (a split `DYLD_FALLBACK_LIBRARY_PATH`) already covers every
|
|
64
|
+
* directory in `wanted` — the "nothing to do" test.
|
|
65
|
+
*
|
|
66
|
+
* Kept in the port even though this CLI's consumer does not branch on it: the
|
|
67
|
+
* mirror's value is that the two files are the SAME file, and a surface that
|
|
68
|
+
* drifts by one export is a mirror nobody can check. `system-gi.spec.ts` pins
|
|
69
|
+
* both.
|
|
70
|
+
*/
|
|
71
|
+
export declare function pathCovers(wanted: readonly string[], current: readonly string[]): boolean;
|