@gjsify/cli 0.44.0 → 0.46.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 +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -11,6 +11,69 @@
11
11
  import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
12
12
  import { classifyTrustList, trustUrl } from './trust-registry.js';
13
13
  export const PROBE_RETRY_DELAY_MS = 400;
14
+ /**
15
+ * A 429 is npm asking to be asked later — never an answer about the package.
16
+ *
17
+ * Measured on the real 703-package sweep of `gjsify/types` at the default
18
+ * concurrency: the first 662 reads answered and the LAST 41 came back `429`, so
19
+ * the plan said `41 unreadable` for packages whose state nobody had actually
20
+ * failed to determine. The tail of an alphabetical list is exactly where a
21
+ * cumulative rate limit lands, which makes the damage look like a property of
22
+ * those packages.
23
+ *
24
+ * Waiting is the whole fix, and it has to be bounded: an unbounded wait turns a
25
+ * throttled sweep into a hang with no output.
26
+ */
27
+ /**
28
+ * A TIME budget, not an attempt count — and generous, because npm tells us
29
+ * nothing.
30
+ *
31
+ * The attempt count was the wrong unit and the real sweep proved it: 4 attempts
32
+ * with a doubling 2s base is 30 seconds of patience, npm's window is longer than
33
+ * that, so every in-flight request spent its budget inside one cooldown and
34
+ * `Done: 502 trusted … 73 failed` — all 73 at the tail, all 429, none of them a
35
+ * fact about those packages.
36
+ *
37
+ * With no `Retry-After` and no rate-limit headers to read (measured), waiting is
38
+ * the only instrument there is. Five minutes per request is worth it: the
39
+ * alternative is failing packages that then need another sweep, which is itself
40
+ * throttled. Bounded all the same, so a throttled sweep still ENDS.
41
+ */
42
+ export const RATE_LIMIT_MAX_WAIT_MS = 300_000;
43
+ export const RATE_LIMIT_BASE_DELAY_MS = 2000;
44
+ /** Ceiling for a single backoff step, so escalation stays legible. */
45
+ export const RATE_LIMIT_MAX_STEP_MS = 60_000;
46
+ /**
47
+ * What npm told us about pacing on a 429 — verbatim, so the answer to "how fast
48
+ * may we go" comes from the registry rather than from a guess.
49
+ *
50
+ * Measured 2026-08-28: npm serves NO `X-RateLimit-*` headers on ordinary
51
+ * responses, so there is no budget to read ahead of time. `Retry-After` on the
52
+ * 429 itself is the only channel, and whether npm populates it on the trust
53
+ * endpoint is exactly what this reports the first time it happens.
54
+ */
55
+ export function describeRateLimitHeaders(headers) {
56
+ if (!headers)
57
+ return 'no headers captured';
58
+ const seen = [];
59
+ headers.forEach((value, key) => {
60
+ if (/^retry-after$/i.test(key) || /ratelimit/i.test(key))
61
+ seen.push(`${key}: ${value}`);
62
+ });
63
+ return seen.length > 0 ? seen.join(', ') : 'npm sent no Retry-After and no rate-limit headers';
64
+ }
65
+ /** Seconds to wait per npm's `Retry-After`, or null when it said nothing usable. */
66
+ export function retryAfterMs(headers) {
67
+ const raw = headers?.get('retry-after');
68
+ if (!raw)
69
+ return null;
70
+ const secs = Number.parseInt(raw.trim(), 10);
71
+ // Only the delta-seconds form. The HTTP-date form is legal and npm does not
72
+ // send it; guessing at a date here would be a second clock to get wrong.
73
+ if (!Number.isFinite(secs) || secs < 0)
74
+ return null;
75
+ return secs * 1000;
76
+ }
14
77
  /**
15
78
  * Does the registry serve a packument for this name?
16
79
  *
@@ -67,6 +130,84 @@ export function actionForState(state) {
67
130
  function defaultSleep(ms) {
68
131
  return new Promise((resolve) => setTimeout(resolve, ms));
69
132
  }
133
+ /**
134
+ * A cool-down shared by every in-flight request of a sweep.
135
+ *
136
+ * Retrying ONE throttled request in isolation is not enough, and the measured
137
+ * run shows why: at serial pace `gjsify/types` was already being 429'd, so every
138
+ * other worker kept hammering the registry through the very window the retry was
139
+ * waiting out — and the retry budget then expired against a limit nobody had
140
+ * stopped provoking. `trust failed (HTTP 429)` was the result.
141
+ *
142
+ * A 429 anywhere therefore pauses EVERYWHERE. That also makes concurrency safe
143
+ * to raise: the sweep self-paces down to whatever npm is willing to serve
144
+ * instead of the operator guessing a number.
145
+ */
146
+ export class RateLimitGate {
147
+ until = 0;
148
+ now;
149
+ sleeper;
150
+ constructor(opts = {}) {
151
+ this.now = opts.now ?? (() => Date.now());
152
+ this.sleeper = opts.sleep ?? defaultSleep;
153
+ }
154
+ /** Block while a cool-down is in effect. */
155
+ async wait() {
156
+ const remaining = this.until - this.now();
157
+ if (remaining > 0)
158
+ await this.sleeper(remaining);
159
+ }
160
+ /** Hold every request back for at least `ms` from now. */
161
+ penalize(ms) {
162
+ this.until = Math.max(this.until, this.now() + ms);
163
+ }
164
+ /** Milliseconds still owed, for tests and for reporting. */
165
+ remainingMs() {
166
+ return Math.max(0, this.until - this.now());
167
+ }
168
+ }
169
+ /**
170
+ * Issue a request, waiting out an HTTP 429 rather than reporting it as a state.
171
+ *
172
+ * Used by BOTH the probe GET and the act-phase trust POST, because a sweep long
173
+ * enough to be throttled on reads is long enough to be throttled on writes, and
174
+ * a throttled POST would otherwise be recorded as `trust failed (HTTP 429)` —
175
+ * a failure attributed to the package instead of to the pacing.
176
+ *
177
+ * Bounded on purpose: it returns the last 429 once the budget is spent, so the
178
+ * caller reports "unreadable" rather than hanging. `Retry-After` wins over the
179
+ * exponential backoff whenever npm sends a usable one.
180
+ */
181
+ export async function requestWaitingOutRateLimit(request, method, url, body, opts = {}) {
182
+ const sleep = opts.sleep ?? defaultSleep;
183
+ const budgetMs = opts.maxRateLimitWaitMs ?? RATE_LIMIT_MAX_WAIT_MS;
184
+ const baseDelay = opts.rateLimitBaseDelayMs ?? RATE_LIMIT_BASE_DELAY_MS;
185
+ const gate = opts.gate;
186
+ // Respect a cool-down somebody else is already serving BEFORE spending an
187
+ // attempt. Without this the sweep keeps provoking the limit it is waiting on.
188
+ if (gate)
189
+ await gate.wait();
190
+ let res = await request(method, url, body);
191
+ let waited = 0;
192
+ for (let attempt = 0; res.status === 429 && waited < budgetMs; attempt++) {
193
+ const advised = retryAfterMs(res.headers);
194
+ const step = Math.min(advised ?? baseDelay * 2 ** attempt, RATE_LIMIT_MAX_STEP_MS);
195
+ // Never overshoot the budget: the last wait is whatever is left of it.
196
+ const delay = Math.min(step, budgetMs - waited);
197
+ opts.onRateLimit?.({
198
+ url,
199
+ headers: describeRateLimitHeaders(res.headers),
200
+ waitMs: delay,
201
+ fromRetryAfter: advised !== null,
202
+ });
203
+ // Tell everyone, not just this call.
204
+ gate?.penalize(delay);
205
+ await (gate ? gate.wait() : sleep(delay));
206
+ waited += delay;
207
+ res = await request(method, url, body);
208
+ }
209
+ return res;
210
+ }
70
211
  /**
71
212
  * Read one package's published + trust state through the SHARED trust requester.
72
213
  * A surviving `auth-required` means the 401 was not an answerable OTP challenge —
@@ -77,10 +218,11 @@ export async function probeTrustState(request, ws, ctx, opts = {}) {
77
218
  const registry = ctx.registryOverride ?? registryFor(ws.name, ctx.npmrc) ?? DEFAULT_REGISTRY;
78
219
  const url = trustUrl(registry, ws.name);
79
220
  const classifyOpts = { repository: ctx.repository, workflow: ctx.workflow };
80
- let res = await request('GET', url);
221
+ const sleep = opts.sleep ?? defaultSleep;
222
+ // A 429 is not an answer — wait it out BEFORE classifying anything.
223
+ let res = await requestWaitingOutRateLimit(request, 'GET', url, undefined, opts);
81
224
  let state = classifyTrustList(res.status, res.json, classifyOpts);
82
225
  if (state === 'auth-required' && opts.retryOn401 !== false) {
83
- const sleep = opts.sleep ?? defaultSleep;
84
226
  await sleep(opts.retryDelayMs ?? PROBE_RETRY_DELAY_MS);
85
227
  res = await request('GET', url);
86
228
  state = classifyTrustList(res.status, res.json, classifyOpts);
@@ -123,8 +265,15 @@ export async function probeTrustState(request, ws, ctx, opts = {}) {
123
265
  export async function probeAllTrustStates(request, selected, concurrency, ctx, opts = {}) {
124
266
  if (selected.length === 0)
125
267
  return [];
126
- const first = await probeTrustState(request, selected[0], ctx, opts);
127
- const rest = await mapWithConcurrency(selected.slice(1), Math.max(1, concurrency), (ws) => probeTrustState(request, ws, ctx, opts));
268
+ const total = selected.length;
269
+ let done = 0;
270
+ const report = (plan) => {
271
+ done++;
272
+ opts.onProgress?.(done, total, plan);
273
+ return plan;
274
+ };
275
+ const first = report(await probeTrustState(request, selected[0], ctx, opts));
276
+ const rest = await mapWithConcurrency(selected.slice(1), Math.max(1, concurrency), async (ws) => report(await probeTrustState(request, ws, ctx, opts)));
128
277
  return [first, ...rest];
129
278
  }
130
279
  /** Map with bounded concurrency, preserving input order in the result. */
@@ -0,0 +1,12 @@
1
+ type Writer = (text: string) => void;
2
+ /** How many prompts are currently on screen. Exposed for tests. */
3
+ export declare function promptDepth(): number;
4
+ /** Mark a prompt as open; every `writeAroundPrompt` is held until it closes. */
5
+ export declare function beginPrompt(): void;
6
+ /** Close a prompt and flush anything held while it was open. */
7
+ export declare function endPrompt(write?: Writer): void;
8
+ /** Write now, or hold until the open prompt closes. */
9
+ export declare function writeAroundPrompt(text: string, write?: Writer): void;
10
+ /** Test seam: forget any held output and reset the depth. */
11
+ export declare function resetPromptOutput(): void;
12
+ export {};
@@ -0,0 +1,54 @@
1
+ // Stdout arbitration between a live prompt and everything else writing to it.
2
+ //
3
+ // A prompt owns the last line of the terminal. On a TTY `prompt.ts` runs in RAW
4
+ // mode and echoes each keystroke by hand, so the cursor sits mid-line with no
5
+ // newline pending — and any other writer lands INSIDE what the user is typing:
6
+ //
7
+ // Enter a NEW OTP: npm rate limit hit. npm sent no Retry-After …
8
+ // 228000
9
+ //
10
+ // (measured on a real `gjsify onboard` sweep: a rate-limit notice from a
11
+ // concurrent worker spliced into the digits being entered, leaving the typed
12
+ // code visually split across two lines).
13
+ //
14
+ // The rule is arbitration, not silence: a message that arrives during a prompt
15
+ // is HELD and flushed the moment the prompt closes. Dropping it would trade a
16
+ // cosmetic problem for a lost one, and the messages that show up here are
17
+ // exactly the ones explaining why the sweep is slow.
18
+ let depth = 0;
19
+ const held = [];
20
+ /** How many prompts are currently on screen. Exposed for tests. */
21
+ export function promptDepth() {
22
+ return depth;
23
+ }
24
+ /** Mark a prompt as open; every `writeAroundPrompt` is held until it closes. */
25
+ export function beginPrompt() {
26
+ depth++;
27
+ }
28
+ /** Close a prompt and flush anything held while it was open. */
29
+ export function endPrompt(write = defaultWrite) {
30
+ depth = Math.max(0, depth - 1);
31
+ if (depth > 0)
32
+ return;
33
+ if (held.length === 0)
34
+ return;
35
+ const pendingLines = held.splice(0, held.length);
36
+ for (const line of pendingLines)
37
+ write(line);
38
+ }
39
+ /** Write now, or hold until the open prompt closes. */
40
+ export function writeAroundPrompt(text, write = defaultWrite) {
41
+ if (depth > 0) {
42
+ held.push(text);
43
+ return;
44
+ }
45
+ write(text);
46
+ }
47
+ function defaultWrite(text) {
48
+ process.stdout.write(text);
49
+ }
50
+ /** Test seam: forget any held output and reset the depth. */
51
+ export function resetPromptOutput() {
52
+ depth = 0;
53
+ held.length = 0;
54
+ }
@@ -15,6 +15,7 @@
15
15
  //
16
16
  // Non-TTY stdin (piped input, CI) keeps a plain line read so
17
17
  // `printf 'user\npass\n' | gjsify login` still works.
18
+ import { beginPrompt, endPrompt } from './prompt-output.js';
18
19
  const CTRL_C = '\x03'; // ETX (Ctrl-C)
19
20
  const DEL = '\x7f'; // DEL (Backspace on most terminals)
20
21
  const BACKSPACE = '\x08'; // BS
@@ -98,8 +99,18 @@ async function runRawSession(fn) {
98
99
  stdin.setEncoding('utf-8');
99
100
  stdin.on('data', onData);
100
101
  const read = (question, mask) => new Promise((resolve) => {
102
+ // A prompt owns the last line: hold back every other writer until it
103
+ // closes, or their output lands inside the digits being typed.
104
+ beginPrompt();
101
105
  out.write(question);
102
- pending = { mask, buf: '', resolve };
106
+ pending = {
107
+ mask,
108
+ buf: '',
109
+ resolve: (value) => {
110
+ endPrompt();
111
+ resolve(value);
112
+ },
113
+ };
103
114
  });
104
115
  try {
105
116
  return await fn(read);
@@ -138,8 +149,17 @@ function readLine() {
138
149
  /** Print a question and read one visible line. */
139
150
  export async function promptLine(question) {
140
151
  if (!isTtyStdin()) {
152
+ // Non-TTY holds back other writers too: the interleaving is not visible
153
+ // here, but a transcript in which a notice sits between the question and
154
+ // the answer is just as hard to read afterwards.
155
+ beginPrompt();
141
156
  process.stdout.write(question);
142
- return readLine();
157
+ try {
158
+ return await readLine();
159
+ }
160
+ finally {
161
+ endPrompt();
162
+ }
143
163
  }
144
164
  return runRawSession((read) => read(question, false));
145
165
  }
@@ -4,7 +4,7 @@ import type { Workspace } from '@gjsify/workspace';
4
4
  * workspaces. Extend this the moment a new such group appears — the list is the
5
5
  * only thing standing between one and a silently broken first release.
6
6
  */
7
- export declare const NON_WORKSPACE_PUBLISHABLE_DIRS: readonly [readonly ["packages", "node-gi"], readonly ["packages", "napi"]];
7
+ export declare const NON_WORKSPACE_PUBLISHABLE_DIRS: readonly [readonly ["packages", "node-gi"], readonly ["packages", "napi"], readonly ["packages", "node-runtime"]];
8
8
  /**
9
9
  * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
10
10
  * `Workspace` entries so callers can treat them like any other package.
@@ -19,6 +19,7 @@ import { dirname, join } from 'node:path';
19
19
  export const NON_WORKSPACE_PUBLISHABLE_DIRS = [
20
20
  ['packages', 'node-gi'], // @gjsify/node-gi + the @gjsify/gtk-runtime-* bundles
21
21
  ['packages', 'napi'], // @gjsify/napi (the N-API host in GJS, own napi.yml)
22
+ ['packages', 'node-runtime'], // the @gjsify/node-runtime-* bundled Node interpreters
22
23
  ];
23
24
  /**
24
25
  * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
@@ -14,3 +14,59 @@ export interface ResolveNpmPackageOptions {
14
14
  * `pathToFileURL().href` before a dynamic `import(...)`.
15
15
  */
16
16
  export declare function resolveNpmPackage(specifier: string, opts?: ResolveNpmPackageOptions): string | null;
17
+ /**
18
+ * The anchor a resolution was asked from — see {@link resolveInstalledPackage}.
19
+ *
20
+ * `'nowhere'` is a directory inside no project. Under Node it can only fail;
21
+ * under Bun it is where the sixth anchor shows itself.
22
+ */
23
+ export type PackageAnchor = 'project' | 'nowhere';
24
+ export interface ResolveInstalledPackageOptions extends ResolveNpmPackageOptions {
25
+ /**
26
+ * TEST SEAM — resolve `specifier` from one anchor, or `null`.
27
+ *
28
+ * Injected so Bun's behaviour below is reproducible from Node: a fake that
29
+ * answers the SAME path for both anchors is exactly what Bun does for a
30
+ * package the project never installed, and it is the only way to red this
31
+ * guard on a runtime that does not have the defect.
32
+ */
33
+ resolve?: (specifier: string, anchor: PackageAnchor) => string | null;
34
+ }
35
+ /**
36
+ * Resolve a bare specifier the way {@link resolveNpmPackage} does, but refuse an
37
+ * answer the RUNTIME invented rather than the project provided.
38
+ *
39
+ * THE SIXTH ANCHOR, which the list above does not mention because it is not
40
+ * ours. Bun auto-installs: `createRequire(<anchor>).resolve('<name>')` falls back
41
+ * to Bun's global install cache instead of throwing, so a package the project
42
+ * never declared resolves anyway. Measured, bun 1.3.14 vs node 24.19.0, asking
43
+ * for `@gjsify/node-gi` from a scratch directory:
44
+ *
45
+ * node → MODULE_NOT_FOUND
46
+ * bun → ~/.bun/install/cache/@gjsify/node-gi@0.44.0@@@1/index.js
47
+ *
48
+ * And it is not only the empty-directory case. A real project with a
49
+ * `package.json` and no `node_modules` — a fresh clone, or any Bun project
50
+ * relying on auto-install — gets the same cache hit, as does one that DECLARES
51
+ * the dependency without installing it. Only a `package.json` beside an EMPTY
52
+ * `node_modules` throws.
53
+ *
54
+ * WHY THAT MATTERS HERE AND NOT FOR {@link resolveNpmPackage}'s other callers.
55
+ * Resolving `rolldown` from a cache is fine: it is a TOOL, it runs and is
56
+ * forgotten. `@gjsify/node-gi`, `@gjsify/gtk-runtime-*` and
57
+ * `@gjsify/node-runtime-*` are PAYLOAD — `utils/ship/app-runtime.ts` copies
58
+ * their bytes into a `.app` that gets redistributed. A cache hit there ships
59
+ * files the project never declared, at whatever version that runtime happened to
60
+ * cache, and silently: the "install `<name>`" line that should have been printed
61
+ * never is. It is also #910's shape one level up, an addon paired with a closure
62
+ * it was not built against.
63
+ *
64
+ * THE DISCRIMINATOR IS A CONTROL, not a list of cache paths. Hardcoding
65
+ * `~/.bun/install/cache` would be this file guessing at another runtime's
66
+ * layout and going stale the first time Bun moves it. Asking the same question
67
+ * from a directory inside no project asks the RUNTIME instead: if both anchors
68
+ * answer with the identical path, the project contributed nothing to that
69
+ * answer. Under Node the control can only throw, so this is a strict no-op
70
+ * there — the resolution it returns is byte-identical to `resolveNpmPackage`'s.
71
+ */
72
+ export declare function resolveInstalledPackage(specifier: string, opts?: ResolveInstalledPackageOptions): string | null;
@@ -23,6 +23,7 @@
23
23
  // (parent-dir walker).
24
24
  import { createRequire } from 'node:module';
25
25
  import { pathToFileURL } from 'node:url';
26
+ import { tmpdir } from 'node:os';
26
27
  import { delimiter, join, resolve } from 'node:path';
27
28
  import { findWorkspaceRoot } from './workspace-root.js';
28
29
  /**
@@ -92,3 +93,71 @@ function tryResolveFromDir(specifier, dir) {
92
93
  return null;
93
94
  }
94
95
  }
96
+ /**
97
+ * A directory that is inside no project, for the control probe.
98
+ *
99
+ * It does not have to EXIST — measured under bun 1.3.14, a non-existent path
100
+ * still reaches the cache, so this costs no filesystem access. It must have a
101
+ * parent, though: an anchor at the filesystem root (`/__r__.js`) throws even
102
+ * under Bun, which would make the control silently useless.
103
+ */
104
+ const CONTROL_ANCHOR = join(tmpdir(), '.gjsify-nonexistent-control-anchor', '__gjsify_resolve__.js');
105
+ /**
106
+ * Resolve a bare specifier the way {@link resolveNpmPackage} does, but refuse an
107
+ * answer the RUNTIME invented rather than the project provided.
108
+ *
109
+ * THE SIXTH ANCHOR, which the list above does not mention because it is not
110
+ * ours. Bun auto-installs: `createRequire(<anchor>).resolve('<name>')` falls back
111
+ * to Bun's global install cache instead of throwing, so a package the project
112
+ * never declared resolves anyway. Measured, bun 1.3.14 vs node 24.19.0, asking
113
+ * for `@gjsify/node-gi` from a scratch directory:
114
+ *
115
+ * node → MODULE_NOT_FOUND
116
+ * bun → ~/.bun/install/cache/@gjsify/node-gi@0.44.0@@@1/index.js
117
+ *
118
+ * And it is not only the empty-directory case. A real project with a
119
+ * `package.json` and no `node_modules` — a fresh clone, or any Bun project
120
+ * relying on auto-install — gets the same cache hit, as does one that DECLARES
121
+ * the dependency without installing it. Only a `package.json` beside an EMPTY
122
+ * `node_modules` throws.
123
+ *
124
+ * WHY THAT MATTERS HERE AND NOT FOR {@link resolveNpmPackage}'s other callers.
125
+ * Resolving `rolldown` from a cache is fine: it is a TOOL, it runs and is
126
+ * forgotten. `@gjsify/node-gi`, `@gjsify/gtk-runtime-*` and
127
+ * `@gjsify/node-runtime-*` are PAYLOAD — `utils/ship/app-runtime.ts` copies
128
+ * their bytes into a `.app` that gets redistributed. A cache hit there ships
129
+ * files the project never declared, at whatever version that runtime happened to
130
+ * cache, and silently: the "install `<name>`" line that should have been printed
131
+ * never is. It is also #910's shape one level up, an addon paired with a closure
132
+ * it was not built against.
133
+ *
134
+ * THE DISCRIMINATOR IS A CONTROL, not a list of cache paths. Hardcoding
135
+ * `~/.bun/install/cache` would be this file guessing at another runtime's
136
+ * layout and going stale the first time Bun moves it. Asking the same question
137
+ * from a directory inside no project asks the RUNTIME instead: if both anchors
138
+ * answer with the identical path, the project contributed nothing to that
139
+ * answer. Under Node the control can only throw, so this is a strict no-op
140
+ * there — the resolution it returns is byte-identical to `resolveNpmPackage`'s.
141
+ */
142
+ export function resolveInstalledPackage(specifier, opts = {}) {
143
+ const resolve = opts.resolve ??
144
+ ((spec, anchor) => anchor === 'project'
145
+ ? resolveNpmPackage(spec, { cwd: opts.cwd, bundleUrl: opts.bundleUrl })
146
+ : tryResolveFromFile(spec, CONTROL_ANCHOR));
147
+ const hit = resolve(specifier, 'project');
148
+ if (hit === null)
149
+ return null;
150
+ const control = resolve(specifier, 'nowhere');
151
+ return control !== null && control === hit ? null : hit;
152
+ }
153
+ /** `createRequire(<file>).resolve(specifier)`, or `null`. The file need not exist. */
154
+ function tryResolveFromFile(specifier, anchorFile) {
155
+ try {
156
+ return createRequire(pathToFileURL(anchorFile).href).resolve(specifier);
157
+ }
158
+ catch {
159
+ // Not resolvable from a directory in no project — which is the answer
160
+ // this probe wants on every runtime without a global-cache fallback.
161
+ return null;
162
+ }
163
+ }