@gjsify/cli 0.51.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  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 +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -0,0 +1,684 @@
1
+ // `gjsify link` — a development OVERRIDE that points a consumer's `@gjsify/*`
2
+ // dependencies at a local gjsify checkout, without touching what the consumer
3
+ // COMMITS.
4
+ //
5
+ // THE NEED (ADR 0065). A consumer repo — Learn6502 is the measured one — pins
6
+ // `@gjsify/*` to registry versions (`^0.51.1`) and commits `gjsify-lock.json`.
7
+ // Developing a change that spans the platform and the app then means waiting for
8
+ // an npm release for every iteration, because the installer knows exactly two
9
+ // spec shapes: a registry range and `workspace:`. ADR 0060 § P6 recommended
10
+ // against `link:`/`portal:` "unless a specific unfixable need appears" — this is
11
+ // that need, and the answer it takes is deliberately NOT a resolution protocol.
12
+ //
13
+ // WHY AN OVERRIDE AND NOT A MANIFEST EDIT. A `link:` spec in `package.json` (or a
14
+ // rewritten lockfile) makes the development tree part of what CI, Flatpak and
15
+ // `--immutable` build. Those three must keep building exactly what is committed,
16
+ // so the override lives in a local, git-ignored file — `.gjsify-link.json` — that
17
+ // only the developer's own installs read.
18
+ //
19
+ // THE TRAP THIS EXISTS TO CLOSE. A bare `npm link` is undone by the next
20
+ // `npm install`: the link is replaced by the registry copy and nothing says so.
21
+ // Here the installer READS the override, excludes the linked names from its
22
+ // fetch/extract set and re-wires the links on every run — and says which ones, so
23
+ // a linked tree is never a silent build input.
24
+ //
25
+ // WHY THE INSTALLER MUST EXCLUDE THEM RATHER THAN RE-LINK AFTERWARDS. Measured:
26
+ // with `node_modules/@gjsify/<name>` a symlink OUT of `node_modules`,
27
+ // `assertNodeModulesDest` (install-backend-native.ts) refuses the extract and
28
+ // aborts the WHOLE install — correctly, since `rmSync` through that link would
29
+ // delete the checkout's own source. Re-linking after the fact is therefore not an
30
+ // option: there is no "after" to link in.
31
+ //
32
+ // WHY THE EXCLUSION IS NOT `workspaceNames`. That option is also handed to
33
+ // `resolveDeps`, which drops the whole subtree from the RESOLVE — and the resolve
34
+ // is what writes `gjsify-lock.json`. Reusing it would rewrite the consumer's
35
+ // committed lockfile the moment a link is active, which is the one thing this
36
+ // feature promises not to do. `linkedNames` therefore applies after the lockfile
37
+ // is written and only to what gets fetched.
38
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
39
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
40
+ import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
41
+ import { linkDirSync } from './dir-link.js';
42
+ import { readPackageJson } from './pkg-json.js';
43
+ import { scanPrefix } from './prune-prefix.js';
44
+ import { findWorkspaceRoot } from './workspace-root.js';
45
+ /** The consumer-local override file. Git-ignored; never committed. */
46
+ export const DEV_LINK_FILE = '.gjsify-link.json';
47
+ export function devLinkPath(root) {
48
+ return join(root, DEV_LINK_FILE);
49
+ }
50
+ /**
51
+ * Read the override at `root`, or `null` when there is none.
52
+ *
53
+ * A file that exists but does not parse is an ERROR, not a `null`: this decides
54
+ * where a build's inputs come from, and "unreadable" must never be answered with
55
+ * the registry's copy while the developer believes their checkout is in play.
56
+ */
57
+ export function readDevLinkOverride(root) {
58
+ const path = devLinkPath(root);
59
+ if (!existsSync(path))
60
+ return null;
61
+ let raw;
62
+ try {
63
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
64
+ }
65
+ catch (err) {
66
+ throw new Error(`gjsify link: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Fix it or run \`gjsify unlink\`.`);
67
+ }
68
+ if (!raw || typeof raw !== 'object') {
69
+ throw new Error(`gjsify link: ${path} must contain a JSON object.`);
70
+ }
71
+ const record = raw;
72
+ if (record.version !== 1) {
73
+ throw new Error(`gjsify link: ${path} declares version ${JSON.stringify(record.version)}; this CLI understands version 1 only.`);
74
+ }
75
+ if (typeof record.checkout !== 'string' || record.checkout.trim() === '') {
76
+ throw new Error(`gjsify link: ${path} has no "checkout" path.`);
77
+ }
78
+ return {
79
+ version: 1,
80
+ // Relative is tolerated on the READ side — a hand-edited file is a
81
+ // legitimate way to move a link — and resolved against the consumer root,
82
+ // which is the only directory the file's own location pins down.
83
+ checkout: isAbsolute(record.checkout) ? record.checkout : resolve(root, record.checkout),
84
+ packages: Array.isArray(record.packages)
85
+ ? record.packages.filter((p) => typeof p === 'string')
86
+ : [],
87
+ };
88
+ }
89
+ export function writeDevLinkOverride(root, override) {
90
+ const path = devLinkPath(root);
91
+ writeFileSync(path, JSON.stringify(override, null, 2) + '\n');
92
+ return path;
93
+ }
94
+ /** Remove the override file; returns its path when one was there. */
95
+ export function removeDevLinkOverride(root) {
96
+ const path = devLinkPath(root);
97
+ if (!existsSync(path))
98
+ return null;
99
+ rmSync(path);
100
+ return path;
101
+ }
102
+ /**
103
+ * Which directory's override governs an install started in `startDir`.
104
+ *
105
+ * Same walk `projectInstallNative` does for the install itself: a member of a
106
+ * workspace installs the workspace, so the override that applies is the
107
+ * workspace root's — otherwise `gjsify install` inside a member would silently
108
+ * ignore a link the root declares.
109
+ */
110
+ export function devLinkRoot(startDir) {
111
+ if (existsSync(devLinkPath(startDir)))
112
+ return startDir;
113
+ const wsRoot = findWorkspaceRoot(startDir);
114
+ if (wsRoot && existsSync(devLinkPath(wsRoot)))
115
+ return wsRoot;
116
+ return startDir;
117
+ }
118
+ /**
119
+ * Turn `override.checkout` into the workspace list behind it, or refuse.
120
+ *
121
+ * A DEAD LINK IS AN ERROR WITH A PATH, never a quiet fall back to the registry:
122
+ * the developer asked for their checkout, and installing the published copy
123
+ * instead produces a build that looks like the one they wanted and is not. Both
124
+ * refusals print the measured path, because "it points somewhere wrong" is only
125
+ * actionable with the somewhere in it.
126
+ */
127
+ export function resolveCheckoutWorkspaces(checkout, overridePath) {
128
+ if (!existsSync(checkout)) {
129
+ throw new Error(`gjsify link: the linked checkout ${checkout} does not exist (named by ${overridePath}). ` +
130
+ `Point it at a gjsify checkout, or run \`gjsify unlink\`.`);
131
+ }
132
+ const manifest = readPackageJson(join(checkout, 'package.json'));
133
+ if (!manifest || manifest.workspaces === undefined) {
134
+ throw new Error(`gjsify link: ${checkout} is not a gjsify workspace — ${join(checkout, 'package.json')} ` +
135
+ `${manifest ? 'declares no "workspaces" field' : 'is missing or unreadable'} (named by ${overridePath}).`);
136
+ }
137
+ let workspaces;
138
+ try {
139
+ workspaces = discoverWorkspaces(checkout);
140
+ }
141
+ catch (err) {
142
+ throw new Error(`gjsify link: ${checkout} declares "workspaces" but they could not be discovered ` +
143
+ `(${err instanceof Error ? err.message : String(err)}); named by ${overridePath}.`);
144
+ }
145
+ if (workspaces.length === 0) {
146
+ throw new Error(`gjsify link: ${checkout} declares "workspaces" but none were found (named by ${overridePath}).`);
147
+ }
148
+ return workspaces;
149
+ }
150
+ /**
151
+ * The dependency names the consumer would otherwise take from the registry.
152
+ *
153
+ * Two readers, one answer: what its manifests DECLARE (so a fresh clone can be
154
+ * linked before it has ever installed) and what its `node_modules` already HOLDS
155
+ * (so transitive `@gjsify/*` packages — the ones nobody declares and every build
156
+ * loads — are covered too).
157
+ */
158
+ export function consumerKnownNames(consumerRoot) {
159
+ const names = new Set();
160
+ const manifests = [readPackageJson(join(consumerRoot, 'package.json'))];
161
+ try {
162
+ for (const ws of discoverWorkspaces(consumerRoot))
163
+ manifests.push(ws.manifest);
164
+ }
165
+ catch {
166
+ // Not a workspace, or an unreadable member — the root manifest and the
167
+ // installed tree still answer the question.
168
+ }
169
+ for (const manifest of manifests) {
170
+ if (!manifest)
171
+ continue;
172
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
173
+ const block = manifest[kind];
174
+ if (!block || typeof block !== 'object')
175
+ continue;
176
+ for (const name of Object.keys(block))
177
+ names.add(name);
178
+ }
179
+ }
180
+ for (const pkg of scanPrefix(consumerRoot))
181
+ names.add(pkg.name);
182
+ return names;
183
+ }
184
+ /**
185
+ * The links an override asks for: the checkout's workspace packages, narrowed by
186
+ * the name globs, INTERSECTED with what the consumer actually depends on.
187
+ *
188
+ * The intersection is the point. Linking every workspace package of a 200-member
189
+ * monorepo into a consumer that uses eight would put 200 directories it never
190
+ * declared into its tree, and leave `gjsify unlink` with no honest answer about
191
+ * what to restore. Pure, so every selection rule is decidable without a
192
+ * filesystem.
193
+ */
194
+ export function planDevLinks(opts) {
195
+ // No pattern is NOT `['*']` — see {@link DevLinkOverride.packages}: a bare star
196
+ // matches no scoped name, so spelling the default as one would select nothing.
197
+ const selected = opts.patterns.length > 0
198
+ ? filterWorkspaces(opts.workspaces, { include: [...opts.patterns] })
199
+ : [...opts.workspaces];
200
+ const links = [];
201
+ for (const ws of selected) {
202
+ if (!ws.name)
203
+ continue;
204
+ if (!opts.knownNames.has(ws.name))
205
+ continue;
206
+ links.push({
207
+ name: ws.name,
208
+ target: ws.location,
209
+ linkPath: join(opts.consumerRoot, 'node_modules', ws.name),
210
+ });
211
+ }
212
+ links.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
213
+ return links;
214
+ }
215
+ /**
216
+ * Read + validate the override at `consumerRoot` and plan its links, or `null`
217
+ * when no override is there. Throws for every other shape — see
218
+ * {@link resolveCheckoutWorkspaces}.
219
+ */
220
+ export function prepareDevLinks(consumerRoot) {
221
+ const override = readDevLinkOverride(consumerRoot);
222
+ if (!override)
223
+ return null;
224
+ const overridePath = devLinkPath(consumerRoot);
225
+ const workspaces = resolveCheckoutWorkspaces(override.checkout, overridePath);
226
+ const links = planDevLinks({
227
+ consumerRoot,
228
+ workspaces,
229
+ patterns: override.packages,
230
+ knownNames: consumerKnownNames(consumerRoot),
231
+ });
232
+ return {
233
+ overridePath,
234
+ consumerRoot,
235
+ checkout: override.checkout,
236
+ links,
237
+ names: new Set(links.map((l) => l.name)),
238
+ };
239
+ }
240
+ /**
241
+ * (Re)create every planned link. Idempotent — a link already pointing at the
242
+ * right target is left alone, so a re-run costs no unlink/relink race against a
243
+ * process reading through it.
244
+ *
245
+ * Returns the links it actually wrote.
246
+ */
247
+ export function applyDevLinks(links) {
248
+ // FAIL CLOSED, over EVERY link and before the first write. Not only over the
249
+ // ones this call would rewrite: the idempotent skip below is exactly how the
250
+ // measured defect stayed invisible — the link was already correct, the checkout
251
+ // had meanwhile lost its `dist/`, `gjsify install` re-ran, skipped the write,
252
+ // printed "development link ACTIVE" and left a tree whose first import died
253
+ // with MODULE_NOT_FOUND. `dist/`/`lib/` are git-ignored, so a branch switch in
254
+ // the checkout produces this state as a matter of course.
255
+ assertDevLinksBuilt(links);
256
+ const written = [];
257
+ for (const link of links) {
258
+ if (linkAlreadyPointsAt(link.linkPath, link.target))
259
+ continue;
260
+ mkdirSync(dirname(link.linkPath), { recursive: true });
261
+ // Whatever is there — the registry copy, a stale link, a leftover file.
262
+ // `force` so a missing entry is not an error on the first link.
263
+ rmSync(link.linkPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
264
+ linkDirSync(link.linkPath, link.target);
265
+ written.push(link);
266
+ }
267
+ return written;
268
+ }
269
+ /**
270
+ * Remove the links an override produced.
271
+ *
272
+ * ONLY a symlink whose target is inside the checkout is removed. A real
273
+ * directory at that path is the registry copy — some other tool's, or an install
274
+ * that ran with the override already gone — and deleting it would turn `unlink`
275
+ * into a destructive command for a tree it did not create. Same asymmetry
276
+ * `install-extraneous.ts` reasons from: a wrong deletion loses work, a wrong
277
+ * refusal costs one message.
278
+ */
279
+ export function removeDevLinks(links) {
280
+ const removed = [];
281
+ for (const link of links) {
282
+ let isLink = false;
283
+ try {
284
+ isLink = lstatSync(link.linkPath).isSymbolicLink();
285
+ }
286
+ catch {
287
+ continue; // Not there at all.
288
+ }
289
+ if (!isLink)
290
+ continue;
291
+ if (!linkAlreadyPointsAt(link.linkPath, link.target))
292
+ continue;
293
+ rmSync(link.linkPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
294
+ removed.push(link);
295
+ }
296
+ return removed;
297
+ }
298
+ /**
299
+ * Does `linkPath` already resolve to `target`?
300
+ *
301
+ * Compares REAL paths, not the stored string: `dirLinkTarget` spells a POSIX
302
+ * target relative to the link's own directory and a Windows junction absolutely,
303
+ * so a string comparison would answer "different" on one platform and "same" on
304
+ * the other for the identical link.
305
+ */
306
+ function linkAlreadyPointsAt(linkPath, target) {
307
+ try {
308
+ if (!lstatSync(linkPath).isSymbolicLink())
309
+ return false;
310
+ return realpathSync(linkPath) === realpathSync(target);
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ /**
317
+ * Every `node_modules/<name>` symlink that resolves INSIDE one of `checkouts`.
318
+ *
319
+ * The PLAN is not a census. `removeDevLinks` only ever visits the links the
320
+ * CURRENT override asks for, so a link the previous override made and the new one
321
+ * dropped is invisible to it — measured: link everything, relink with
322
+ * `--packages 'is-odd'`, and `node_modules/is-number` stays pointing into the
323
+ * checkout while nothing in the plan names it. From there `assertNodeModulesDest`
324
+ * aborted EVERY later `gjsify install`, and `gjsify unlink` could not help because
325
+ * it removed the override (the escape route) before hitting the same abort. Only a
326
+ * hand-written `rm` got the project back.
327
+ *
328
+ * So the ground truth for "what is linked" is the tree, not the file. Reads one
329
+ * level of `node_modules` plus one level into each `@scope/`, which is where
330
+ * top-level links live; `.bin` and real directories are skipped by construction.
331
+ */
332
+ export function scanDevLinks(consumerRoot, checkouts) {
333
+ const roots = [];
334
+ for (const checkout of checkouts) {
335
+ try {
336
+ roots.push(realpathSync(checkout));
337
+ }
338
+ catch {
339
+ // Gone or unreadable: nothing can resolve into it, so it contributes
340
+ // no links. A DANGLING link is not this function's business either —
341
+ // the installer extracts straight over one.
342
+ }
343
+ }
344
+ if (roots.length === 0)
345
+ return [];
346
+ const nodeModules = join(consumerRoot, 'node_modules');
347
+ const found = [];
348
+ const consider = (name) => {
349
+ const linkPath = join(nodeModules, name);
350
+ let target;
351
+ try {
352
+ if (!lstatSync(linkPath).isSymbolicLink())
353
+ return;
354
+ target = realpathSync(linkPath);
355
+ }
356
+ catch {
357
+ return;
358
+ }
359
+ if (!roots.some((root) => target === root || target.startsWith(root + sep)))
360
+ return;
361
+ found.push({ name, target, linkPath });
362
+ };
363
+ let entries;
364
+ try {
365
+ entries = readdirSync(nodeModules);
366
+ }
367
+ catch {
368
+ return [];
369
+ }
370
+ for (const entry of entries) {
371
+ if (entry.startsWith('.'))
372
+ continue;
373
+ if (entry.startsWith('@')) {
374
+ let scoped;
375
+ try {
376
+ scoped = readdirSync(join(nodeModules, entry));
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ for (const inner of scoped)
382
+ consider(`${entry}/${inner}`);
383
+ continue;
384
+ }
385
+ consider(entry);
386
+ }
387
+ found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
388
+ return found;
389
+ }
390
+ /**
391
+ * Remove every dev link into `checkouts` that `keep` does not name.
392
+ *
393
+ * What makes re-linking with a narrower `--packages` a RE-selection rather than an
394
+ * addition, and what makes `unlink` leave the tree as it found it. Pass an empty
395
+ * `keep` to retire all of them.
396
+ */
397
+ export function retireDevLinks(consumerRoot, checkouts, keep) {
398
+ const stale = scanDevLinks(consumerRoot, checkouts).filter((link) => !keep.has(link.name));
399
+ return removeDevLinks(stale);
400
+ }
401
+ /**
402
+ * Which of these links point at something that is not built.
403
+ *
404
+ * Candidates are the string entry points a manifest can carry — `main`, `module`
405
+ * and the string leaves under `exports["."]`. ANY of them existing is enough: a
406
+ * package legitimately declares entries it does not ship on every platform, and a
407
+ * refusal that fires on a healthy tree is a refusal people learn to route around.
408
+ * An entry-LESS manifest is fine, because nothing was promised that could be
409
+ * missing.
410
+ *
411
+ * An UNREADABLE manifest is NOT fine, and that is a flip from the first draft,
412
+ * which answered "built" for it. "I could not tell" is not "yes" in a check whose
413
+ * whole job is to stop a silently unusable tree.
414
+ */
415
+ export function unbuiltDevLinks(links) {
416
+ const out = [];
417
+ for (const link of links) {
418
+ let manifest;
419
+ try {
420
+ manifest = JSON.parse(readFileSync(join(link.target, 'package.json'), 'utf-8'));
421
+ }
422
+ catch (err) {
423
+ out.push({
424
+ link,
425
+ reason: `its package.json is missing or unreadable (${err instanceof Error ? err.message : String(err)})`,
426
+ });
427
+ continue;
428
+ }
429
+ const candidates = [];
430
+ for (const key of ['main', 'module']) {
431
+ const value = manifest[key];
432
+ if (typeof value === 'string')
433
+ candidates.push(value);
434
+ }
435
+ const exportsField = manifest.exports;
436
+ if (typeof exportsField === 'string')
437
+ candidates.push(exportsField);
438
+ else if (exportsField && typeof exportsField === 'object') {
439
+ collectStringLeaves(exportsField['.'], candidates);
440
+ }
441
+ if (candidates.length === 0)
442
+ continue;
443
+ if (candidates.some((rel) => existsSync(join(link.target, rel))))
444
+ continue;
445
+ out.push({ link, reason: `none of its declared entry points exist yet (${candidates.join(', ')})` });
446
+ }
447
+ return out;
448
+ }
449
+ /** The refusal text for {@link unbuiltDevLinks}, naming every path and the build. */
450
+ export function formatUnbuiltDevLinks(unbuilt) {
451
+ const rows = unbuilt.map((u) => ` ${u.link.name} ${u.link.target}\n ${u.reason}`).join('\n');
452
+ return (`gjsify link: ${unbuilt.length} linked package(s) are not built, so linking them would hand the ` +
453
+ `consumer a tree whose first import fails with MODULE_NOT_FOUND:\n${rows}\n` +
454
+ ` Build them in the checkout (\`gjsify run build\` there) and re-run, or narrow the selection with ` +
455
+ `\`gjsify link <checkout> --packages <glob>\`. A link does not build.`);
456
+ }
457
+ /**
458
+ * FAIL CLOSED on a link to something unbuilt.
459
+ *
460
+ * `dist/`/`lib/` are git-ignored in a gjsify checkout, so "the entry point is not
461
+ * there right now" is the ordinary consequence of a branch switch, not an exotic
462
+ * state. Measured: with the entry file deleted, `gjsify install` re-linked and
463
+ * printed only "development link ACTIVE"; the consumer then died on
464
+ * MODULE_NOT_FOUND with a message naming the consumer, not the checkout.
465
+ */
466
+ export function assertDevLinksBuilt(links) {
467
+ const unbuilt = unbuiltDevLinks(links);
468
+ if (unbuilt.length === 0)
469
+ return;
470
+ throw new Error(formatUnbuiltDevLinks(unbuilt));
471
+ }
472
+ function collectStringLeaves(value, out) {
473
+ if (typeof value === 'string') {
474
+ out.push(value);
475
+ return;
476
+ }
477
+ if (!value || typeof value !== 'object')
478
+ return;
479
+ for (const nested of Object.values(value))
480
+ collectStringLeaves(nested, out);
481
+ }
482
+ /** The comment line `ensureLocallyIgnored` writes above the pattern. */
483
+ const IGNORE_COMMENT = '# gjsify link — local development override, never commit';
484
+ /**
485
+ * Make `.gjsify-link.json` invisible to git WITHOUT editing a tracked file.
486
+ *
487
+ * `info/exclude` is git's own per-clone ignore list: it is not committed and
488
+ * not shared between clones, which is exactly the lifetime of the thing it
489
+ * hides. Writing to `.gitignore` instead would put a developer's local override
490
+ * into everyone else's diff — the manifest edit this whole feature exists to
491
+ * avoid, one file over. A WORKTREE is the case to get right: the file is
492
+ * written under the repository's COMMON dir, because `info/exclude` is shared
493
+ * across a repository's worktrees and the worktree's own gitdir is not read
494
+ * (see {@link resolveGitDir}).
495
+ *
496
+ * Returns what it did, so the caller can say so. A consumer that is not a git
497
+ * repository is not an error: nothing can commit the file there either. A
498
+ * consumer whose `.git` POINTER cannot be followed IS worth a word, though —
499
+ * there the file stays visible to `git status` and one `git add -A` commits it.
500
+ */
501
+ export function ensureLocallyIgnored(consumerRoot) {
502
+ const found = resolveGitDir(consumerRoot);
503
+ if (!found.commonDir)
504
+ return found.why;
505
+ const gitignore = join(consumerRoot, '.gitignore');
506
+ if (existsSync(gitignore) && fileMentionsPattern(gitignore, DEV_LINK_FILE))
507
+ return 'already-ignored';
508
+ const excludePath = join(found.commonDir, 'info', 'exclude');
509
+ if (existsSync(excludePath) && fileMentionsPattern(excludePath, DEV_LINK_FILE))
510
+ return 'already-ignored';
511
+ mkdirSync(dirname(excludePath), { recursive: true });
512
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
513
+ const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
514
+ writeFileSync(excludePath, `${existing}${prefix}${IGNORE_COMMENT}\n${DEV_LINK_FILE}\n`);
515
+ return 'added';
516
+ }
517
+ /**
518
+ * Undo {@link ensureLocallyIgnored} — the other half of `unlink`.
519
+ *
520
+ * Measured: the `info/exclude` block outlived `gjsify unlink` (idempotently,
521
+ * so it never grew — it just never left). A command that promises to undo itself
522
+ * and leaves a line behind is not an undo, and the leftover is a rule about a file
523
+ * that no longer exists, which the next reader has to research to delete.
524
+ *
525
+ * ONLY the two lines this module wrote, matched as a pair: a `.gitignore` entry or
526
+ * a hand-written exclude line is someone else's decision, and removing it would be
527
+ * the same overreach `removeDevLinks` refuses.
528
+ */
529
+ export function removeLocalIgnore(consumerRoot) {
530
+ const found = resolveGitDir(consumerRoot);
531
+ if (!found.commonDir)
532
+ return found.why;
533
+ const excludePath = join(found.commonDir, 'info', 'exclude');
534
+ let text;
535
+ try {
536
+ text = readFileSync(excludePath, 'utf-8');
537
+ }
538
+ catch {
539
+ return 'absent';
540
+ }
541
+ const lines = text.split(/\r?\n/);
542
+ const out = [];
543
+ let removed = false;
544
+ for (let i = 0; i < lines.length; i++) {
545
+ if (!removed &&
546
+ lines[i]?.trim() === IGNORE_COMMENT &&
547
+ (lines[i + 1]?.trim() === DEV_LINK_FILE || lines[i + 1]?.trim() === `/${DEV_LINK_FILE}`)) {
548
+ i++; // skip the pattern line too
549
+ removed = true;
550
+ continue;
551
+ }
552
+ out.push(lines[i] ?? '');
553
+ }
554
+ if (!removed)
555
+ return 'absent';
556
+ writeFileSync(excludePath, out.join('\n'));
557
+ return 'removed';
558
+ }
559
+ /** A line naming exactly this pattern, ignoring comments and whitespace. */
560
+ function fileMentionsPattern(file, pattern) {
561
+ let text;
562
+ try {
563
+ text = readFileSync(file, 'utf-8');
564
+ }
565
+ catch {
566
+ return false;
567
+ }
568
+ return text.split(/\r?\n/).some((line) => {
569
+ const trimmed = line.trim();
570
+ return trimmed === pattern || trimmed === `/${pattern}`;
571
+ });
572
+ }
573
+ /**
574
+ * The COMMON dir of `root`'s repository — the one whose `info/exclude` git
575
+ * actually reads. Two indirections get there, and in a worktree neither the
576
+ * repository root nor the gitdir it points at IS that directory.
577
+ *
578
+ * The first is `.git`: a directory in a clone, a `gitdir:` pointer file in a
579
+ * submodule, and a `gitdir:` pointer to `<common>/worktrees/<name>` in a worktree.
580
+ * Skipping that indirection would write an `info/exclude` inside a plain file's
581
+ * parent and silently ignore nothing at all.
582
+ *
583
+ * The second is `commondir`, and it is the one that was missed: `info/exclude` is
584
+ * SHARED across a repository's worktrees (git-worktree(1)), so in a worktree the
585
+ * pointer leads AWAY from the file git reads. Measured: with the entry written
586
+ * under `<common>/worktrees/<name>/info/exclude`, `git status` in the worktree
587
+ * still printed `?? .gjsify-link.json` and `git check-ignore` named no rule — one
588
+ * `git add -A` from committing the override this feature exists to keep out.
589
+ *
590
+ * TWO outcomes, not one. "There is no repository here" and "there is one and its
591
+ * pointer does not parse" call for opposite reactions — the first is fine, the
592
+ * second leaves the override exposed to the next `git add -A` — and answering
593
+ * both with `null` made the second announce the first.
594
+ */
595
+ function resolveGitDir(root) {
596
+ const dotGit = join(root, '.git');
597
+ let stat;
598
+ try {
599
+ stat = statSync(dotGit);
600
+ }
601
+ catch {
602
+ return { commonDir: null, why: 'no-git' };
603
+ }
604
+ if (stat.isDirectory())
605
+ return { commonDir: dotGit, why: 'no-git' };
606
+ let pointer;
607
+ try {
608
+ pointer = readFileSync(dotGit, 'utf-8').trim();
609
+ }
610
+ catch {
611
+ return { commonDir: null, why: 'unreadable-git' };
612
+ }
613
+ const match = /^gitdir:\s*(.+)$/.exec(pointer);
614
+ const target = match?.[1]?.trim();
615
+ if (!target)
616
+ return { commonDir: null, why: 'unreadable-git' };
617
+ return followCommondir(isAbsolute(target) ? target : resolve(root, target));
618
+ }
619
+ /**
620
+ * The shared directory of the gitdir a `gitdir:` pointer named.
621
+ *
622
+ * A plain clone and a submodule have no `commondir` file: there the gitdir IS the
623
+ * common dir, and nothing changes. A worktree has one — `../..`, relative to the
624
+ * worktree gitdir, is what `git worktree add` writes — and an absolute path is
625
+ * honored rather than resolved against the gitdir, where it would name a
626
+ * directory that does not exist.
627
+ *
628
+ * A `commondir` that names NOTHING is not read as absent, which is the decision
629
+ * this shape exists to force. Measured against real git, all three such shapes
630
+ * exit 128: blank → "error reading …/commondir", a directory → the same, a path
631
+ * that is not there → "not a git repository". So there is no answer to fall back
632
+ * to, and `no-git` would announce "nothing here can commit the file" about a
633
+ * repository that just refused to be one — the same "I could not tell" that is not
634
+ * a "yes", as everywhere else in this file.
635
+ */
636
+ function followCommondir(gitDir) {
637
+ const file = join(gitDir, 'commondir');
638
+ if (!existsSync(file))
639
+ return { commonDir: gitDir, why: 'no-git' };
640
+ let raw;
641
+ try {
642
+ raw = readFileSync(file, 'utf-8');
643
+ }
644
+ catch {
645
+ return { commonDir: null, why: 'unreadable-git' };
646
+ }
647
+ const target = raw.trim();
648
+ if (!target)
649
+ return { commonDir: null, why: 'unreadable-git' };
650
+ const commonDir = isAbsolute(target) ? target : resolve(gitDir, target);
651
+ // The third shape git refuses: writing an `info/exclude` under a directory that
652
+ // is not there would create a tree that nothing ever reads.
653
+ if (!existsSync(commonDir))
654
+ return { commonDir: null, why: 'unreadable-git' };
655
+ return { commonDir, why: 'no-git' };
656
+ }
657
+ /**
658
+ * `--immutable` must never accept an active override — FAIL CLOSED.
659
+ *
660
+ * CI, a Flatpak build and a release all run `--immutable`, and all three exist to
661
+ * build exactly what is committed. An override is a developer's local tree, so
662
+ * consuming one here would produce an artifact whose inputs are not in any
663
+ * commit and whose build reported nothing unusual. Refusing names the file,
664
+ * because the fix is to delete it and the message is the only place its path
665
+ * appears.
666
+ */
667
+ export function assertNoDevLinkUnderImmutable(startDir) {
668
+ const root = devLinkRoot(startDir);
669
+ const path = devLinkPath(root);
670
+ if (!existsSync(path))
671
+ return;
672
+ let checkout = '(unreadable)';
673
+ try {
674
+ checkout = readDevLinkOverride(root)?.checkout ?? checkout;
675
+ }
676
+ catch {
677
+ // A malformed override still blocks: the question `--immutable` asks is
678
+ // "is a development tree in play", and an unparseable answer is not a no.
679
+ }
680
+ throw new Error(`gjsify install --immutable: a development link override is active — ${path}\n` +
681
+ `It points at ${checkout}, so this install would build from a local checkout instead of ` +
682
+ `what ${join(root, 'gjsify-lock.json')} pins. --immutable builds only what is committed.\n` +
683
+ `Run \`gjsify unlink\` (in ${root}) or delete ${DEV_LINK_FILE}, then re-run.`);
684
+ }