@gjsify/cli 0.52.0 → 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.
Files changed (81) hide show
  1. package/dist/cli.gjs.mjs +329 -298
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +5 -2
  7. package/lib/commands/env.d.ts +31 -0
  8. package/lib/commands/env.js +114 -0
  9. package/lib/commands/exec.d.ts +10 -0
  10. package/lib/commands/exec.js +208 -0
  11. package/lib/commands/foreach.js +5 -97
  12. package/lib/commands/format.js +26 -6
  13. package/lib/commands/index.d.ts +3 -0
  14. package/lib/commands/index.js +3 -0
  15. package/lib/commands/install.d.ts +2 -2
  16. package/lib/commands/install.js +5 -5
  17. package/lib/commands/self-update.js +2 -1
  18. package/lib/commands/test.d.ts +18 -9
  19. package/lib/commands/test.js +56 -11
  20. package/lib/commands/tsc.js +4 -4
  21. package/lib/commands/upgrade.d.ts +15 -0
  22. package/lib/commands/upgrade.js +76 -2
  23. package/lib/commands/webext/index.d.ts +2 -0
  24. package/lib/commands/webext/index.js +15 -0
  25. package/lib/commands/webext/subcommands.d.ts +16 -0
  26. package/lib/commands/webext/subcommands.js +204 -0
  27. package/lib/types/config-data.d.ts +1 -1
  28. package/lib/utils/bin-shim.d.ts +8 -2
  29. package/lib/utils/bin-shim.js +6 -5
  30. package/lib/utils/bundle-inputs.d.ts +84 -0
  31. package/lib/utils/bundle-inputs.js +268 -0
  32. package/lib/utils/check-system-deps.d.ts +4 -2
  33. package/lib/utils/check-system-deps.js +21 -26
  34. package/lib/utils/dev-link.d.ts +9 -6
  35. package/lib/utils/dev-link.js +71 -19
  36. package/lib/utils/exec-bin.d.ts +137 -0
  37. package/lib/utils/exec-bin.js +321 -0
  38. package/lib/utils/gi-runtime-paths.js +3 -2
  39. package/lib/utils/gitignore.d.ts +15 -0
  40. package/lib/utils/gitignore.js +178 -0
  41. package/lib/utils/gjsify-shim.d.ts +47 -0
  42. package/lib/utils/gjsify-shim.js +97 -8
  43. package/lib/utils/install-backend-native.d.ts +19 -1
  44. package/lib/utils/install-backend-native.js +93 -7
  45. package/lib/utils/install-lock.js +4 -25
  46. package/lib/utils/native-skip-scan.d.ts +20 -0
  47. package/lib/utils/native-skip-scan.js +343 -0
  48. package/lib/utils/node-script.js +1 -1
  49. package/lib/utils/oxc-resolve.js +42 -3
  50. package/lib/utils/process-table.d.ts +22 -0
  51. package/lib/utils/process-table.js +188 -0
  52. package/lib/utils/prune-prefix.js +1 -1
  53. package/lib/utils/run-gjs.d.ts +15 -1
  54. package/lib/utils/run-gjs.js +16 -7
  55. package/lib/utils/run-node.d.ts +2 -0
  56. package/lib/utils/run-node.js +2 -1
  57. package/lib/utils/runtimes.js +14 -13
  58. package/lib/utils/ship/gzip.js +1 -1
  59. package/lib/utils/ship/icons.d.ts +6 -0
  60. package/lib/utils/ship/icons.js +6 -5
  61. package/lib/utils/spawn.d.ts +13 -0
  62. package/lib/utils/spawn.js +10 -5
  63. package/lib/utils/watch-loop.d.ts +15 -1
  64. package/lib/utils/watch-loop.js +9 -5
  65. package/lib/utils/webext/build.d.ts +42 -0
  66. package/lib/utils/webext/build.js +120 -0
  67. package/lib/utils/webext/config.d.ts +72 -0
  68. package/lib/utils/webext/config.js +234 -0
  69. package/lib/utils/webext/icons.d.ts +24 -0
  70. package/lib/utils/webext/icons.js +83 -0
  71. package/lib/utils/webext/launch.d.ts +32 -0
  72. package/lib/utils/webext/launch.js +77 -0
  73. package/lib/utils/webext/manifest.d.ts +53 -0
  74. package/lib/utils/webext/manifest.js +219 -0
  75. package/lib/utils/webext/pages.d.ts +26 -0
  76. package/lib/utils/webext/pages.js +61 -0
  77. package/lib/utils/webext/targets.d.ts +13 -0
  78. package/lib/utils/webext/targets.js +36 -0
  79. package/lib/utils/win32-command.d.ts +17 -0
  80. package/lib/utils/win32-command.js +21 -6
  81. package/package.json +19 -19
@@ -8,6 +8,16 @@
8
8
  * undoing the repair one level down.
9
9
  */
10
10
  export declare function usingSelfShim(): boolean;
11
+ /**
12
+ * `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
13
+ *
14
+ * The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
15
+ * runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
16
+ * back, the temp copy of the version it had just replaced, so a successful update
17
+ * inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
18
+ * (v0.51.1)" and exited 1.
19
+ */
20
+ export declare function pathWithoutSelfShim(pathValue?: string, shimDir?: string | undefined, sep?: string): string;
11
21
  /**
12
22
  * Does this invocation need its own `gjsify` on PATH, rather than the workspace's
13
23
  * npm bin?
@@ -31,6 +41,43 @@ export declare function needsSelfShim(opts: {
31
41
  * reuse it instead of creating their own.
32
42
  */
33
43
  export declare function ensureGjsifyShimOnPath(): void;
44
+ /**
45
+ * The tree whose `node_modules` holds the running CLI and its prebuilds: the
46
+ * install prefix above the first `node_modules` segment, or the workspace root
47
+ * for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
48
+ */
49
+ export declare function selfShimScanRoot(selfEntry: string): string;
50
+ /**
51
+ * The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
52
+ * with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
53
+ * the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
54
+ */
55
+ export declare function selfShimPreamble(selfEntry: string): string;
56
+ /**
57
+ * The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
58
+ *
59
+ * On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
60
+ * `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
61
+ * is one, so the child `gjs` otherwise starts without the loader path the install
62
+ * launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
63
+ * `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
64
+ * the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
65
+ * `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
66
+ * through the tree's own launcher worked.
67
+ *
68
+ * Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
69
+ * shim lives in a temp dir for ONE process tree and carries exactly the environment
70
+ * that tree's root was started with.
71
+ */
72
+ export declare function buildSelfShimScript(opts: {
73
+ interpreter: string;
74
+ interpreterArgs: readonly string[];
75
+ target: string;
76
+ platform?: string;
77
+ env?: Record<string, string | undefined>;
78
+ }): string;
79
+ /** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
80
+ export declare function dyldCarryPreamble(platform: string, env: Record<string, string | undefined>): string;
34
81
  /**
35
82
  * Directory carrying a `node` that runs the file through
36
83
  * `gjsify run --node-script`, or null when this host needs none.
@@ -41,10 +41,11 @@
41
41
  // nested builds off the GJS bundle.
42
42
  import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
43
43
  import { tmpdir } from 'node:os';
44
- import { join, delimiter, relative, isAbsolute } from 'node:path';
44
+ import { join, delimiter, relative, isAbsolute, dirname } from 'node:path';
45
45
  import { fileURLToPath } from 'node:url';
46
46
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
47
- import { buildLauncherShims } from './bin-shim.js';
47
+ import { buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, shQuoteArg } from './bin-shim.js';
48
+ import { detectNativePackages } from './detect-native-packages.js';
48
49
  import { resolveBinOnPath } from './install-global.js';
49
50
  import { findWorkspaceRoot } from './workspace-root.js';
50
51
  let _ensured = false;
@@ -61,6 +62,23 @@ let _selfShimActive = false;
61
62
  export function usingSelfShim() {
62
63
  return _selfShimActive;
63
64
  }
65
+ /**
66
+ * `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
67
+ *
68
+ * The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
69
+ * runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
70
+ * back, the temp copy of the version it had just replaced, so a successful update
71
+ * inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
72
+ * (v0.51.1)" and exited 1.
73
+ */
74
+ export function pathWithoutSelfShim(pathValue = process.env.PATH ?? '', shimDir = process.env.GJSIFY_SHIM_DIR, sep = delimiter) {
75
+ if (!shimDir)
76
+ return pathValue;
77
+ return pathValue
78
+ .split(sep)
79
+ .filter((entry) => entry !== shimDir)
80
+ .join(sep);
81
+ }
64
82
  /**
65
83
  * Does this invocation need its own `gjsify` on PATH, rather than the workspace's
66
84
  * npm bin?
@@ -117,23 +135,90 @@ export function ensureGjsifyShimOnPath() {
117
135
  // `process.execPath`: the batch member cannot quote an interpreter argument
118
136
  // (`<interpreter> "<target>" %*`), so `C:\Program Files\nodejs\node.exe` splits.
119
137
  const interpreter = gjs ? process.env.GJS_CONSOLE || 'gjs' : 'node';
120
- const interpreterArgs = gjs ? ['-m'] : [];
121
- const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
122
- writeFileSync(shim, `#!/bin/sh\nexec "${interpreter}" ${argv}"${selfEntry}" "$@"\n`, { mode: 0o755 });
138
+ // Under GJS the shim is the SAME launcher `gjsify install` writes, preamble
139
+ // included (`selfShimPreamble`). A bare `exec gjs -m` is not enough on a Mac:
140
+ // it runs behind `/bin/sh`, and SIP strips every `DYLD_*` from a protected
141
+ // binary's environment, so a nested `gjsify` found its typelibs through the
142
+ // surviving GI_TYPELIB_PATH and none of their libraries. Reported by the
143
+ // beifahrer consumer on macOS: `gjsify workspace <pkg> build` died in
144
+ // `get columns` with "Unsupported type void".
145
+ const envPreamble = gjs ? selfShimPreamble(selfEntry) : '';
146
+ writeFileSync(shim, gjs
147
+ ? buildShLauncher(selfEntry, { isGjsBundle: true, envPreamble, gjs: interpreter })
148
+ : `#!/bin/sh\nexec "${interpreter}" "${selfEntry}" "$@"\n`, { mode: 0o755 });
123
149
  chmodSync(shim, 0o755);
124
150
  // cmd.exe and pwsh cannot run the extension-less member: not on PATHEXT, and
125
151
  // Windows has no shebang handling. `buildLauncherShims` ports npm's
126
152
  // three-sibling answer, including the IF/ELSE ERRORLEVEL handling that
127
153
  // `&&`/`||` chaining gets wrong.
128
154
  if (!gjs && process.platform === 'win32') {
129
- const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs, target: selfEntry });
155
+ const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs: [], target: selfEntry });
130
156
  writeFileSync(`${shim}.cmd`, cmd);
131
157
  writeFileSync(`${shim}.ps1`, ps1);
132
158
  }
133
159
  _selfShimActive = true;
134
160
  process.env.GJSIFY_SHIM_DIR = dir;
135
161
  process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
136
- writeNodeShim(dir, gjs, selfEntry);
162
+ writeNodeShim(dir, gjs, selfEntry, envPreamble);
163
+ }
164
+ /**
165
+ * The tree whose `node_modules` holds the running CLI and its prebuilds: the
166
+ * install prefix above the first `node_modules` segment, or the workspace root
167
+ * for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
168
+ */
169
+ export function selfShimScanRoot(selfEntry) {
170
+ // Both separators: the entry is a HOST path, and win32 spells it with a backslash.
171
+ const at = selfEntry.search(/[\\/]node_modules[\\/]/);
172
+ if (at > 0)
173
+ return selfEntry.slice(0, at);
174
+ return findWorkspaceRoot(dirname(selfEntry)) ?? dirname(selfEntry);
175
+ }
176
+ /**
177
+ * The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
178
+ * with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
179
+ * the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
180
+ */
181
+ export function selfShimPreamble(selfEntry) {
182
+ let baked = [];
183
+ try {
184
+ baked = detectNativePackages(dirname(selfEntry)).map((p) => p.prebuildsDir);
185
+ }
186
+ catch {
187
+ // The walk is filesystem I/O (EACCES on an unreadable node_modules). The
188
+ // launch-time glob of the scan root still runs; only ancestors go unseen.
189
+ }
190
+ return buildNativeEnvPreamble(selfShimScanRoot(selfEntry), baked);
191
+ }
192
+ /**
193
+ * The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
194
+ *
195
+ * On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
196
+ * `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
197
+ * is one, so the child `gjs` otherwise starts without the loader path the install
198
+ * launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
199
+ * `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
200
+ * the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
201
+ * `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
202
+ * through the tree's own launcher worked.
203
+ *
204
+ * Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
205
+ * shim lives in a temp dir for ONE process tree and carries exactly the environment
206
+ * that tree's root was started with.
207
+ */
208
+ export function buildSelfShimScript(opts) {
209
+ const argv = opts.interpreterArgs.length > 0 ? `${opts.interpreterArgs.join(' ')} ` : '';
210
+ const preamble = dyldCarryPreamble(opts.platform ?? process.platform, opts.env ?? process.env);
211
+ return `#!/bin/sh\n${preamble}exec "${opts.interpreter}" ${argv}"${opts.target}" "$@"\n`;
212
+ }
213
+ /** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
214
+ export function dyldCarryPreamble(platform, env) {
215
+ if (platform !== 'darwin')
216
+ return '';
217
+ return Object.keys(env)
218
+ .filter((name) => /^DYLD_[A-Z_]+$/.test(name) && env[name])
219
+ .sort()
220
+ .map((name) => `${name}=${shQuoteArg(env[name])}\nexport ${name}\n`)
221
+ .join('');
137
222
  }
138
223
  /** Deliberately NOT the shim dir itself — see {@link nodeShimDir}. */
139
224
  const NODE_SHIM_SUBDIR = 'node-shim';
@@ -183,7 +268,7 @@ export function nodeShimDir() {
183
268
  * resolves NOWHERE, and it is reachable only from a package script's PATH (see
184
269
  * {@link nodeShimDir}).
185
270
  */
186
- function writeNodeShim(dir, gjs, selfEntry) {
271
+ function writeNodeShim(dir, gjs, selfEntry, envPreamble) {
187
272
  if (!gjs)
188
273
  return;
189
274
  const sub = join(dir, NODE_SHIM_SUBDIR);
@@ -199,11 +284,15 @@ function writeNodeShim(dir, gjs, selfEntry) {
199
284
  // `--node-script` can honour, and yargs would take `--test` FOR the script
200
285
  // path (`unknown-options-as-args` turns an unknown flag into a positional).
201
286
  writeFileSync(shim, '#!/bin/sh\n' +
287
+ // Same SIP hop as the self-shim — see `buildSelfShimScript`.
288
+ dyldCarryPreamble(process.platform, process.env) +
202
289
  'case "$1" in\n' +
203
290
  ' -*) echo "gjsify: this host has no node; the gjsify shim runs a SCRIPT FILE only" >&2\n' +
204
291
  ' echo "gjsify: got: node $*" >&2\n' +
205
292
  ' exit 127 ;;\n' +
206
293
  'esac\n' +
294
+ // The same hop as the `gjsify` shim, so the same preamble.
295
+ envPreamble +
207
296
  `exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`, { mode: 0o755 });
208
297
  chmodSync(shim, 0o755);
209
298
  }
@@ -22,6 +22,12 @@ export interface ResolvedNode {
22
22
  /** Ranges keyed by name, as published. */
23
23
  dependencies: Record<string, string>;
24
24
  optionalDependencies: Record<string, string>;
25
+ /** As published; absent when the version declares none. Read through {@link requiredPeerEntries}. */
26
+ peerDependencies?: Record<string, string>;
27
+ /** As published — `optional: true` here is what keeps a peer from being installed. */
28
+ peerDependenciesMeta?: Record<string, {
29
+ optional?: boolean;
30
+ }>;
25
31
  bin?: string | Record<string, string>;
26
32
  /**
27
33
  * The version's `os`/`cpu`/`libc` declaration, as published. Recorded here
@@ -156,6 +162,17 @@ export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: s
156
162
  * `dependencies` entry to override), covered by {@link resolveDeps}'s second queue loop.
157
163
  */
158
164
  export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies'>): [string, string][];
165
+ /**
166
+ * A node's REQUIRED peer edges: its `peerDependencies` minus those
167
+ * `peerDependenciesMeta` marks `optional` (npm ≥ 7 installs the rest and skips
168
+ * these) and minus any name it also lists as a dependency — that edge is walked
169
+ * from the node itself by {@link requiredDepEntries}, or is optional because the
170
+ * publisher said so in `optionalDependencies`.
171
+ *
172
+ * Shared by the resolve walk and the optionality fixpoint, for the reason
173
+ * {@link requiredDepEntries} is: two readings of one manifest are two answers.
174
+ */
175
+ export declare function requiredPeerEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'peerDependenciesMeta'>): [string, string][];
159
176
  /**
160
177
  * Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
161
178
  * only writer of the final flag; whatever the nodes arrive carrying is an input
@@ -163,7 +180,8 @@ export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencie
163
180
  * file on the lockfile path) and is overwritten unconditionally.
164
181
  *
165
182
  * DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
166
- * REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
183
+ * REQUIRED edges alone ({@link requiredDepEntries} plus {@link requiredPeerEntries} —
184
+ * a peer npm installs is as mandatory as a dependency); every other node is optional.
167
185
  * Optionality is therefore INHERITED — a plain dependency OF an optional package is
168
186
  * still optional, which is what npm's `optionalSet` computes.
169
187
  *
@@ -45,8 +45,20 @@
45
45
  // (v4); without it the fresh-resolve and lockfile paths disagree about one
46
46
  // tree.
47
47
  //
48
- // Out of scope (still deferred): peerDependencies validation, lifecycle scripts,
49
- // git/file specs.
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';
@@ -172,6 +184,7 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
172
184
  else if (!opts.refreshLockfile &&
173
185
  existingLock &&
174
186
  existingLock.lockfileVersion === LOCKFILE_VERSION &&
187
+ existingLock.peersResolved === true &&
175
188
  lockfileMatchesRequest(existingLock, opts.specs)) {
176
189
  log('install: using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
177
190
  nodes = lockfileToNodes(existingLock);
@@ -179,6 +192,9 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
179
192
  else {
180
193
  // A resolve has to run (new/changed/removed dep, or no lockfile yet).
181
194
  //
195
+ // So does a lockfile without `peersResolved`: it may lack a required peer
196
+ // (Lockfile.peersResolved).
197
+ //
182
198
  // A pre-v4 lockfile lands here too even when it matches the request: each
183
199
  // bump added something the verdict is recomputed FROM, so a file lacking it
184
200
  // cannot be consumed verbatim — pre-v3 entries carry no platform
@@ -533,7 +549,7 @@ optionalSpecs) {
533
549
  // `workspaceInstall` produces when two workspaces declare incompatible ranges of
534
550
  // the same external dep) all compete for the single root slot and the resolver
535
551
  // silently keeps one version for everyone. Per-workspace dedup is Phase D.8
536
- // (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
537
553
  // the resolve (see emitTopLevelConflictWarnings).
538
554
  const topLevelRanges = new Map();
539
555
  const queue = specs.map(parseSpec).map((s) => {
@@ -601,6 +617,23 @@ optionalSpecs) {
601
617
  // {@link computeOptionalFlags} is the only writer of the final flag.
602
618
  continue;
603
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
+ }
604
637
  // No compatible placement. Resolve a version, preferring one already
605
638
  // pinned when it satisfies the range so an add does not bump unchanged
606
639
  // deps.
@@ -629,6 +662,8 @@ optionalSpecs) {
629
662
  installPath,
630
663
  dependencies: v.dependencies ?? {},
631
664
  optionalDependencies: v.optionalDependencies ?? {},
665
+ peerDependencies: nonEmpty(v.peerDependencies),
666
+ peerDependenciesMeta: nonEmpty(v.peerDependenciesMeta),
632
667
  bin: v.bin,
633
668
  // Complete as published: `fetchPkg` reads the FULL document, so all
634
669
  // three fields come from one body — no second fetch, no heuristic.
@@ -653,6 +688,20 @@ optionalSpecs) {
653
688
  name: `${edge.name}@${version}`,
654
689
  });
655
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
+ }
656
705
  // REQUIRED edges only — a name this package also lists in
657
706
  // `optionalDependencies` is queued once by the loop below, as
658
707
  // optional (header note, invariant 3). Queuing it here too would
@@ -693,7 +742,9 @@ optionalSpecs) {
693
742
  // `assertRequiredEdgesResolved` re-judges every entry against the
694
743
  // fixpoint flags, so a genuinely required dependency cannot go missing
695
744
  // just because BFS reached it through an optional edge first.
696
- skippedEdges.add(edgeKey(edge.from, edge.name));
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));
697
748
  log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, errMsg(e));
698
749
  continue;
699
750
  }
@@ -798,6 +849,27 @@ export function requiredDepEntries(node) {
798
849
  }
799
850
  return entries;
800
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
+ }
801
873
  /**
802
874
  * Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
803
875
  * only writer of the final flag; whatever the nodes arrive carrying is an input
@@ -805,7 +877,8 @@ export function requiredDepEntries(node) {
805
877
  * file on the lockfile path) and is overwritten unconditionally.
806
878
  *
807
879
  * DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
808
- * REQUIRED edges alone ({@link requiredDepEntries}); every other node is optional.
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.
809
882
  * Optionality is therefore INHERITED — a plain dependency OF an optional package is
810
883
  * still optional, which is what npm's `optionalSet` computes.
811
884
  *
@@ -861,7 +934,7 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
861
934
  const node = byPath.get(installPath);
862
935
  if (!node)
863
936
  continue;
864
- for (const [depName] of requiredDepEntries(node)) {
937
+ for (const [depName] of [...requiredDepEntries(node), ...requiredPeerEntries(node)]) {
865
938
  // Resolve the edge the way the REQUESTER will at runtime — through
866
939
  // the ancestor `node_modules` chain — so a nested copy is credited
867
940
  // to the requester that nested it and the hoisted one is not
@@ -952,7 +1025,7 @@ function assertRequiredEdgesResolved(nodes, skippedEdges) {
952
1025
  * (dist-tags like `latest`) cannot be compared and are skipped.
953
1026
  *
954
1027
  * This makes the single-root-slot behaviour honest rather than silent; the real fix
955
- * 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).
956
1029
  */
957
1030
  function emitTopLevelConflictWarnings(topLevelRanges, root) {
958
1031
  for (const [name, ranges] of topLevelRanges) {
@@ -1082,6 +1155,11 @@ function findVisible(requesterPath, name, byPath) {
1082
1155
  }
1083
1156
  return null;
1084
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
+ }
1085
1163
  /**
1086
1164
  * Where to install `name@version` for a request from `requesterPath`: hoist to
1087
1165
  * `node_modules/<name>` when the root slot is empty or already holds this version,
@@ -1142,6 +1220,8 @@ function writeLockfile(lockfilePath, specs, nodes) {
1142
1220
  // `computeOptionalFlags` runs on the lockfile path too and cannot recompute
1143
1221
  // what the file does not carry (v4, header note invariant 3).
1144
1222
  optionalDependencies: Object.keys(node.optionalDependencies).length > 0 ? node.optionalDependencies : undefined,
1223
+ peerDependencies: nonEmpty(node.peerDependencies),
1224
+ peerDependenciesMeta: nonEmpty(node.peerDependenciesMeta),
1145
1225
  bin: node.bin,
1146
1226
  // The DECLARATION, never the verdict: `inert` is per-host and deliberately
1147
1227
  // absent from the file, since a lockfile recording which packages the
@@ -1156,12 +1236,16 @@ function writeLockfile(lockfilePath, specs, nodes) {
1156
1236
  lockfileVersion: LOCKFILE_VERSION,
1157
1237
  requested: [...specs],
1158
1238
  packages,
1239
+ peersResolved: true,
1159
1240
  };
1160
1241
  // Atomic tmp+rename so a crash mid-write, or a reader racing the writer, can never
1161
1242
  // observe a torn gjsify-lock.json — `--immutable` would otherwise hard-fail on the
1162
1243
  // corrupt file with a misleading error.
1163
1244
  atomicWriteStrict(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n');
1164
1245
  }
1246
+ function nonEmpty(map) {
1247
+ return map && Object.keys(map).length > 0 ? map : undefined;
1248
+ }
1165
1249
  /**
1166
1250
  * The lockfile-preservation oracle: `name → every version currently pinned` (a name
1167
1251
  * can appear at more than one path/version when the tree nested a conflicting copy).
@@ -1214,6 +1298,8 @@ function lockfileToNodes(lockfile) {
1214
1298
  // blocks reads as required here. `--immutable` consumes it anyway — see
1215
1299
  // READABLE_LOCKFILE_VERSIONS.
1216
1300
  optionalDependencies: entry.optionalDependencies ?? {},
1301
+ peerDependencies: entry.peerDependencies,
1302
+ peerDependenciesMeta: entry.peerDependenciesMeta,
1217
1303
  bin: entry.bin,
1218
1304
  // Rebuilt from the recorded declaration so `applyPlatformFilter` reaches the
1219
1305
  // SAME verdict here as on the fresh-resolve path, for THIS host — generally
@@ -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>` on Linux, readable under Node and GJS
21
- // alike; a kill(0) probe covers non-/proc Nodes), or
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 { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
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;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Prettier parser napi oxfmt would format `fileName` with, or `null` when
3
+ * the native build formats it itself or no oxfmt formats it. `svelte` counts
4
+ * only when the config enables it, as in oxfmt.
5
+ */
6
+ export declare function externalParserFor(fileName: string, svelteEnabled?: boolean): string | null;
7
+ export interface NativeSkipScanOptions {
8
+ /** The `--config` handed to oxfmt. Absent → discovery, as in oxfmt. */
9
+ configPath?: string;
10
+ }
11
+ export interface NativeSkipScanResult {
12
+ /** Files napi oxfmt would format that the native build skips. */
13
+ skipped: string[];
14
+ /** Files the walk hands to oxfmt's classifier. */
15
+ total: number;
16
+ }
17
+ /** Walk `paths` the way oxfmt does and report what the native build skips. */
18
+ export declare function scanForNativeSkips(paths: string[], cwd?: string, options?: NativeSkipScanOptions): NativeSkipScanResult;
19
+ /** JSON with comments and trailing commas; string contents are never touched. */
20
+ export declare function parseJsonc(text: string): unknown;