@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.
@@ -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
- // TWO INVARIANTS MAKE THAT CLAIM TRUE, and both are structural rather than
26
- // documented, because the first draft of this feature broke both while reading
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. v2 lockfiles are still READ (see {@link readLockfile}).
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 = 3;
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-v3 lockfile lands here too, even when it matches the request:
165
- // its entries carry no platform declarations, so consuming it verbatim
166
- // would keep installing the foreign-platform tree forever with nothing
167
- // on disk to recompute the verdict from. One resolve — version-
168
- // preserving, per the seeding below, so nothing bumps — upgrades it.
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
- for (const [depName, depRange] of Object.entries(node.dependencies)) {
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 `dependencies` edges alone; every other node is optional. Optionality
775
- * is therefore INHERITED — a plain dependency OF an optional package is still
776
- * optional — which is what npm's `optionalSet` computes.
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 walk reads
792
- * `dependencies` ONLY. A lockfile entry records `dependencies` and nothing else
793
- * (an `optionalDependencies` map is not persisted), so consulting
794
- * `node.optionalDependencies` here would make the fresh-resolve path and the
795
- * lockfile path compute different flags for the same tree — precisely the split
796
- * this pass exists to close. The cost is one npm quirk we do not reproduce: a
797
- * publisher listing the same name in BOTH blocks means "optional" to npm, and
798
- * comes out required here. That shape is vanishingly rare in practice (the
799
- * platform-binary packages this feature is about — rollup, lightningcss,
800
- * esbuild, fsevents — all list their optional deps in one block only), and
801
- * erring toward REQUIRED errs toward the loud failure, not the silent one.
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 Object.keys(node.dependencies)) {
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. This branch is what keeps a darwin-only `fsevents` under a
908
- // required `chokidar` from failing every Linux install.
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
- optionalDependencies: {},
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 — no process.exit, no GLib-mainloop intermingling
11
- // from `ensureMainLoop`.
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
- // `'inherit-stderr'` is our extension on top of node's stdio modes —
56
- // child stdin inherits, child stdout → parent's stderr (fd 2), child
57
- // stderr → parent's stderr. Used by `gjsify pack --json` so the
58
- // prepack's log lines don't get interleaved with the JSON we emit on
59
- // parent stdout. `spawn`'s `stdio` accepts numeric fds in array form
60
- // and routes the child's matching stream to that fd.
61
- const stdioConfig = opts.stdio === 'inherit-stderr' ? ['inherit', 2, 2] : (opts.stdio ?? 'inherit');
62
- await new Promise((resolveOk, reject) => {
63
- const child = spawn(literal, [], {
64
- cwd: wsDir,
65
- env: env,
66
- stdio: stdioConfig,
67
- shell: true,
68
- });
69
- child.on('close', (code) => {
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
  }
@@ -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
- stdio?: 'inherit' | 'pipe';
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
@@ -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 win = forWin32(cmd, args, env);
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: win.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
- process.stdout.write(r.stdout);
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 = forWin32(cmd, args, env);
168
+ const win = invocation;
160
169
  const spawnOpts = {
161
170
  cwd: opts.cwd,
162
- stdio: opts.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'inherit',
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;