@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.
- package/dist/cli.gjs.mjs +202 -167
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +850 -58
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/commands/upgrade.d.ts +23 -0
- package/lib/commands/upgrade.js +95 -12
- package/lib/templates/install.mjs.tmpl +2 -2
- package/lib/types/config-data.d.ts +127 -1
- package/lib/utils/gi-search-path.js +11 -3
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +50 -0
- package/lib/utils/ship/depends.js +117 -3
- package/lib/utils/ship/discover.js +103 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak.js +1 -1
- package/lib/utils/ship/formats.d.ts +108 -7
- package/lib/utils/ship/formats.js +778 -15
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +419 -17
- package/lib/utils/ship/layout.d.ts +330 -0
- package/lib/utils/ship/layout.js +504 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +434 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +599 -14
- package/lib/utils/ship/pe-launcher.d.ts +29 -0
- package/lib/utils/ship/pe-launcher.js +663 -0
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +55 -7
- package/lib/utils/ship/plist.d.ts +98 -0
- package/lib/utils/ship/plist.js +256 -0
- package/lib/utils/ship/rpm.js +16 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +91 -2
- package/lib/utils/ship/settings.js +93 -1
- package/lib/utils/ship/share-dirs.d.ts +53 -0
- package/lib/utils/ship/share-dirs.js +76 -0
- package/lib/utils/ship/signing.d.ts +311 -0
- package/lib/utils/ship/signing.js +506 -0
- package/lib/utils/ship/stage-manifest.d.ts +40 -12
- package/lib/utils/ship/stage-manifest.js +99 -13
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +91 -2
- package/lib/utils/ship/types.d.ts +191 -14
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/system-gi.d.ts +20 -5
- package/lib/utils/system-gi.js +36 -13
- package/lib/utils/trust-registry.js +5 -0
- 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
|
-
|
|
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
|
|
127
|
-
|
|
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
|
+
}
|
package/lib/utils/prompt.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
+
}
|