@gjsify/cli 0.51.1 → 0.52.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 +505 -490
- package/lib/cli-app.js +3 -1
- package/lib/commands/affected-classify.d.ts +7 -1
- package/lib/commands/affected-classify.js +66 -9
- package/lib/commands/affected-script-refs.d.ts +20 -0
- package/lib/commands/affected-script-refs.js +218 -0
- package/lib/commands/affected.js +24 -2
- package/lib/commands/flatpak/diff.js +8 -2
- package/lib/commands/flatpak/init.js +4 -1
- package/lib/commands/flatpak/sources.js +4 -3
- package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
- package/lib/commands/flatpak/sync-flathub.js +149 -7
- package/lib/commands/flatpak/utils.d.ts +10 -0
- package/lib/commands/flatpak/utils.js +10 -0
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +76 -0
- package/lib/commands/link.d.ts +13 -0
- package/lib/commands/link.js +243 -0
- package/lib/commands/onboard.js +18 -2
- package/lib/commands/publish.d.ts +34 -0
- package/lib/commands/publish.js +153 -8
- package/lib/commands/ship.js +19 -1
- package/lib/types/config-data.d.ts +1 -1
- package/lib/utils/check-system-deps.d.ts +1 -17
- package/lib/utils/check-system-deps.js +10 -68
- package/lib/utils/dev-link.d.ts +222 -0
- package/lib/utils/dev-link.js +632 -0
- package/lib/utils/hang-watchdog.d.ts +95 -0
- package/lib/utils/hang-watchdog.js +194 -0
- package/lib/utils/install-backend-native.d.ts +14 -0
- package/lib/utils/install-backend-native.js +91 -13
- package/lib/utils/install-extraneous.d.ts +90 -0
- package/lib/utils/install-extraneous.js +120 -0
- package/lib/utils/install-tarball-cache.d.ts +37 -9
- package/lib/utils/install-tarball-cache.js +59 -14
- package/lib/utils/normalize-bundler-options.js +11 -0
- package/lib/utils/publish-pins.d.ts +38 -0
- package/lib/utils/publish-pins.js +47 -0
- package/lib/utils/publish-readback.d.ts +103 -17
- package/lib/utils/publish-readback.js +326 -47
- package/lib/utils/run-gjs.js +82 -6
- package/lib/utils/ship/icns.d.ts +14 -0
- package/lib/utils/ship/icns.js +83 -0
- package/lib/utils/ship/ico.d.ts +25 -0
- package/lib/utils/ship/ico.js +120 -0
- package/lib/utils/ship/icons.d.ts +191 -0
- package/lib/utils/ship/icons.js +398 -0
- package/lib/utils/ship/launcher.js +16 -1
- package/lib/utils/ship/layout.d.ts +32 -0
- package/lib/utils/ship/layout.js +112 -38
- package/lib/utils/ship/msi.d.ts +1 -1
- package/lib/utils/ship/msi.js +30 -5
- package/lib/utils/ship/pe-launcher.d.ts +18 -0
- package/lib/utils/ship/pe-launcher.js +125 -4
- package/lib/utils/ship/plan.d.ts +1 -34
- package/lib/utils/ship/plan.js +7 -56
- package/lib/utils/ship/plist.d.ts +20 -10
- package/lib/utils/ship/plist.js +29 -12
- package/lib/utils/ship/settings.js +3 -2
- package/package.json +20 -20
|
@@ -105,29 +105,71 @@ export function probeTimeoutFor(configuredMs, remainingMs) {
|
|
|
105
105
|
return Math.min(configuredMs, Math.max(remainingMs, MIN_PROBE_TIMEOUT_MS));
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
|
-
* Default polling budget:
|
|
108
|
+
* Default polling budget: 600 s.
|
|
109
109
|
*
|
|
110
|
-
* The
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
110
|
+
* The lag between a 2xx and the registry recording the version has been measured
|
|
111
|
+
* on two releases, and the second broke the first's ceiling. v0.46.0 (199
|
|
112
|
+
* packages): 19 late writes, maximum 251.7 s (@gjsify/child_process). ts-for-gir
|
|
113
|
+
* v5.1.0 (12 packages, run 34899018849, PUT lines against `time["5.1.0"]`):
|
|
114
|
+
* four recorded ~0.7 s BEFORE the 2xx, eight after it — +74, +74, +75, +75,
|
|
115
|
+
* +96, +248, +248 and +368 s (@ts-for-gir/reporter) — the last past the 300 s
|
|
116
|
+
* this constant used to be, so "above the measured maximum" had quietly
|
|
117
|
+
* stopped being true of it. A budget below the lag turns npm's normal queueing into a false
|
|
118
|
+
* red, and a false red at release time costs a manual re-run of a workflow that
|
|
119
|
+
* did its job. A budget above it costs nothing on the nine in ten that confirm
|
|
120
|
+
* on the first probe; all it delays is the report of a genuinely LOST write —
|
|
121
|
+
* one in 199, measured — by the difference. 600 s is ~1.6x the larger maximum.
|
|
116
122
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
123
|
+
* BOTH MAXIMA ARE SAMPLE MAXIMA, and neither sample looks truncated by nature.
|
|
124
|
+
* The v0.46.0 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
|
|
119
125
|
* 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
|
|
120
126
|
* middle ones, which is the shape of a distribution cut off by the observation
|
|
121
|
-
* rather than one decaying to zero
|
|
122
|
-
*
|
|
123
|
-
* remediation is verified rather than assumed
|
|
124
|
-
* the 409 path, and that path READS BACK too (see
|
|
125
|
-
* `commands/publish.ts`), so a window that turns out
|
|
126
|
-
* re-run that confirms —
|
|
127
|
+
* rather than one decaying to zero; the next release then produced a value past
|
|
128
|
+
* it. So a lag past 600 s is not ruled out either. What makes any finite window
|
|
129
|
+
* affordable is that the remediation is verified rather than assumed: the
|
|
130
|
+
* re-run this reports lands on the 409 path, and that path READS BACK too (see
|
|
131
|
+
* the conflict branch in `commands/publish.ts`), so a window that turns out too
|
|
132
|
+
* short costs one re-run that confirms — never an unverified success.
|
|
133
|
+
*
|
|
134
|
+
* `time[version]` is when the registry RECORDED the write, a lower bound on
|
|
135
|
+
* when the document npm installs from SERVES it: the reporter above was
|
|
136
|
+
* recorded at +368 s and a cache-busted read of its install document still
|
|
137
|
+
* lacked 5.1.0 ~40 min later. No budget waits that out, and none should — that
|
|
138
|
+
* state is what the `recorded-not-served` verdict names.
|
|
127
139
|
*/
|
|
128
|
-
export const DEFAULT_VERIFY_BUDGET_MS =
|
|
140
|
+
export const DEFAULT_VERIFY_BUDGET_MS = 600_000;
|
|
129
141
|
/**
|
|
130
|
-
*
|
|
142
|
+
* Make one probe's URL uncacheable, by giving it a key no cache holds.
|
|
143
|
+
*
|
|
144
|
+
* THE REQUEST HEADER DOES NOT WORK, and this file used to say it did. Measured
|
|
145
|
+
* against registry.npmjs.org, twice, on two packages, one request apart:
|
|
146
|
+
*
|
|
147
|
+
* cache-control: no-cache → cf-cache-status: HIT, age: 192
|
|
148
|
+
* cache-control: no-cache + pragma → cf-cache-status: HIT, age: 193
|
|
149
|
+
* cache-control: max-age=0 → cf-cache-status: HIT, age: 193
|
|
150
|
+
* cache-control: no-store → cf-cache-status: HIT, age: 193
|
|
151
|
+
* ?<unique> → cf-cache-status: MISS
|
|
152
|
+
*
|
|
153
|
+
* The packument is served `cache-control: public, max-age=300`, so an edge may
|
|
154
|
+
* answer with a document up to 300 s old — the sweep's whole 5 s budget sixty
|
|
155
|
+
* times over, and half the fatal default. A read-back polling one such edge
|
|
156
|
+
* re-reads ONE document minted before the PUT for up to 300 s of its window and
|
|
157
|
+
* a short budget then reports the publish unconfirmed: a
|
|
158
|
+
* manufacturable false red on a publish that worked, and a verifier that cries
|
|
159
|
+
* wolf is one somebody turns off. It cannot produce the opposite error, because
|
|
160
|
+
* a document minted before the write cannot carry the version.
|
|
161
|
+
*
|
|
162
|
+
* The header is still sent: it costs nothing and a cache that DOES honour it
|
|
163
|
+
* (a corporate proxy, a Verdaccio) should. The parameter is namespaced so it
|
|
164
|
+
* cannot collide with a registry's own query vocabulary, and {@link probeOnce}
|
|
165
|
+
* falls back to the bare URL if a registry answers a non-404 4xx to it.
|
|
166
|
+
*/
|
|
167
|
+
function cacheBustedUrl(url, nonce) {
|
|
168
|
+
return `${url}${url.includes('?') ? '&' : '?'}__gjsify_readback=${encodeURIComponent(nonce)}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Poll the registry until `name@version` resolves, or the budget runs out, then
|
|
172
|
+
* say WHICH of the three states it found (see {@link ReadbackVerdict}).
|
|
131
173
|
*
|
|
132
174
|
* No side effects, no exits, no printing beyond the injected `log` — the caller
|
|
133
175
|
* owns presentation, same contract as {@link import('./publish-diagnose.js')}.
|
|
@@ -151,6 +193,7 @@ export async function verifyPublishedVersion(input) {
|
|
|
151
193
|
doFetch,
|
|
152
194
|
probeTimeoutMs: probeTimeoutFor(probeTimeoutMs, budgetMs - (now() - started)),
|
|
153
195
|
authorization: input.authorization,
|
|
196
|
+
nonce: `${started}-${attempts}`,
|
|
154
197
|
});
|
|
155
198
|
input.log?.(`gjsify publish: read-back probe ${attempts} of ${name}@${version} → ${last.state}` +
|
|
156
199
|
(last.state === 'present' ? '' : ` (${last.detail})`));
|
|
@@ -161,24 +204,92 @@ export async function verifyPublishedVersion(input) {
|
|
|
161
204
|
break;
|
|
162
205
|
await sleep(Math.min(backoffFor(round), remaining));
|
|
163
206
|
}
|
|
164
|
-
|
|
207
|
+
const base = { attempts, elapsedMs: now() - started, url, last };
|
|
208
|
+
if (last.state === 'present') {
|
|
209
|
+
return {
|
|
210
|
+
...base,
|
|
211
|
+
confirmed: true,
|
|
212
|
+
verdict: 'confirmed',
|
|
213
|
+
verdictDetail: `${url} serves ${version} with a tarball`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (last.state === 'error') {
|
|
217
|
+
// NOTHING was established, so nothing may be concluded — least of all
|
|
218
|
+
// "published". This branch is the whole reason `error` exists next to
|
|
219
|
+
// `absent`, and it sends no corroboration request: a read that failed
|
|
220
|
+
// is not evidence to corroborate.
|
|
221
|
+
return {
|
|
222
|
+
...base,
|
|
223
|
+
confirmed: false,
|
|
224
|
+
verdict: 'unknown',
|
|
225
|
+
verdictDetail: `the read-back could not reach a verdict — ${last.detail}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// ABSENT, which is one observation and two facts. Ask the registry's own
|
|
229
|
+
// record — `time[version]` on the FULL packument, the one field that
|
|
230
|
+
// separates them — ONE extra request, on the failure path only.
|
|
231
|
+
const record = await probeRegistryRecord({
|
|
232
|
+
url,
|
|
233
|
+
version,
|
|
234
|
+
doFetch,
|
|
235
|
+
// The configured per-probe timeout, NOT the budget-derived clamp: the
|
|
236
|
+
// budget is spent by now, and the clamp's floor is sized for the
|
|
237
|
+
// abbreviated document. The FULL packument is a different size class —
|
|
238
|
+
// measured, `typescript`'s is 15.7 MB and took 2.86 s to read, over the
|
|
239
|
+
// 2 s floor — so a corroboration on the floor would have answered
|
|
240
|
+
// `unknown` for exactly the packages whose failure most needs a verdict.
|
|
241
|
+
probeTimeoutMs,
|
|
242
|
+
authorization: input.authorization,
|
|
243
|
+
nonce: `${started}-record`,
|
|
244
|
+
});
|
|
245
|
+
input.log?.(`gjsify publish: read-back record probe of ${name}@${version} → ${record.state} (${record.detail})`);
|
|
246
|
+
if (record.state === 'recorded') {
|
|
247
|
+
return {
|
|
248
|
+
...base,
|
|
249
|
+
confirmed: false,
|
|
250
|
+
verdict: 'recorded-not-served',
|
|
251
|
+
verdictDetail: record.detail,
|
|
252
|
+
recordedAt: record.recordedAt,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (record.state === 'no-record') {
|
|
256
|
+
return {
|
|
257
|
+
...base,
|
|
258
|
+
confirmed: false,
|
|
259
|
+
verdict: 'not-published',
|
|
260
|
+
verdictDetail: record.detail,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// The corroboration itself failed. The `absent` observation still stands —
|
|
264
|
+
// it is in `last` — but the two verdicts it was meant to separate are BOTH
|
|
265
|
+
// unearned now: no record was read, and neither was its absence. Naming
|
|
266
|
+
// `not-published` here would tell the operator to re-publish on the strength
|
|
267
|
+
// of a request that never answered, on the one line written so a tool never
|
|
268
|
+
// claims what it has not established.
|
|
269
|
+
return {
|
|
270
|
+
...base,
|
|
271
|
+
confirmed: false,
|
|
272
|
+
verdict: 'unknown',
|
|
273
|
+
verdictDetail: record.detail,
|
|
274
|
+
};
|
|
165
275
|
}
|
|
166
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Read `time[version]` from the FULL packument.
|
|
278
|
+
*
|
|
279
|
+
* The abbreviated document the polling probe reads is what npm INSTALLS from and
|
|
280
|
+
* carries no `time` map, so this is a different document and deliberately a
|
|
281
|
+
* different request — the full packument for a long-lived package is large, which
|
|
282
|
+
* is exactly why it is asked once, only when the answer is about to be a failure.
|
|
283
|
+
*/
|
|
284
|
+
async function probeRegistryRecord(input) {
|
|
167
285
|
const { url, version, doFetch, probeTimeoutMs } = input;
|
|
168
|
-
// `AbortController` + a timer rather than `AbortSignal.timeout`, which the
|
|
169
|
-
// CLI does not use anywhere and which this file must not be the first to
|
|
170
|
-
// require of a GJS host.
|
|
171
286
|
const controller = new AbortController();
|
|
172
287
|
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
|
|
173
288
|
let res;
|
|
174
289
|
try {
|
|
175
|
-
res = await doFetch(url, {
|
|
290
|
+
res = await doFetch(cacheBustedUrl(url, input.nonce), {
|
|
291
|
+
// No `accept` override: the FULL document, for its `time` map.
|
|
176
292
|
headers: {
|
|
177
|
-
// Abbreviated packument: version keys + `dist`, a fraction of the
|
|
178
|
-
// bytes of the full document. `no-cache` because a CDN edge will
|
|
179
|
-
// otherwise answer with a document minted BEFORE the publish we
|
|
180
|
-
// are asking about — which is the whole question here.
|
|
181
|
-
accept: 'application/vnd.npm.install-v1+json',
|
|
182
293
|
'cache-control': 'no-cache',
|
|
183
294
|
...(input.authorization ? { authorization: input.authorization } : {}),
|
|
184
295
|
},
|
|
@@ -186,13 +297,105 @@ async function probeOnce(input) {
|
|
|
186
297
|
});
|
|
187
298
|
}
|
|
188
299
|
catch (err) {
|
|
189
|
-
// A real throw path: network failure, DNS, or our own abort above.
|
|
190
300
|
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
state: 'unknown',
|
|
303
|
+
detail: `the registry serves no ${version} and its own record was unreadable (${msg})`,
|
|
304
|
+
};
|
|
192
305
|
}
|
|
193
306
|
finally {
|
|
194
307
|
clearTimeout(timer);
|
|
195
308
|
}
|
|
309
|
+
if (res.status === 404) {
|
|
310
|
+
return { state: 'no-record', detail: 'the registry has no packument for this name at all (404)' };
|
|
311
|
+
}
|
|
312
|
+
if (!res.ok) {
|
|
313
|
+
return {
|
|
314
|
+
state: 'unknown',
|
|
315
|
+
detail: `the registry serves no ${version} and its own record answered ${res.status} ${res.statusText}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
let doc;
|
|
319
|
+
try {
|
|
320
|
+
doc = await res.json();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
324
|
+
return {
|
|
325
|
+
state: 'unknown',
|
|
326
|
+
detail: `the registry serves no ${version} and its own record is not JSON (${msg})`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const time = doc?.time;
|
|
330
|
+
const recordedAt = time && typeof time === 'object' ? time[version] : undefined;
|
|
331
|
+
if (typeof recordedAt === 'string' && recordedAt.length > 0) {
|
|
332
|
+
return {
|
|
333
|
+
state: 'recorded',
|
|
334
|
+
recordedAt,
|
|
335
|
+
detail: `the registry RECORDS ${version} at ${recordedAt} and its install document does not serve it`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
state: 'no-record',
|
|
340
|
+
detail: `the registry has no record of ${version} — neither in its install document nor in its own \`time\` map`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* One abbreviated-packument GET: cache-busted, with the non-404 4xx fallback.
|
|
345
|
+
*
|
|
346
|
+
* Extracted so the version read-back and {@link probePackageName} cannot drift
|
|
347
|
+
* into asking the registry two different questions — the same reason the
|
|
348
|
+
* read-back and `verify-published-closure.mjs` read the same document.
|
|
349
|
+
*/
|
|
350
|
+
async function sendPackumentRequest(input) {
|
|
351
|
+
const { url, doFetch, probeTimeoutMs } = input;
|
|
352
|
+
const headers = {
|
|
353
|
+
// Abbreviated packument: version keys + `dist`, a fraction of the bytes
|
|
354
|
+
// of the full document, and the document npm itself installs from.
|
|
355
|
+
// `cache-control` is sent for the caches that honour it; the edge in
|
|
356
|
+
// front of registry.npmjs.org measurably does not, which is what the
|
|
357
|
+
// query parameter is for.
|
|
358
|
+
accept: 'application/vnd.npm.install-v1+json',
|
|
359
|
+
'cache-control': 'no-cache',
|
|
360
|
+
...(input.authorization ? { authorization: input.authorization } : {}),
|
|
361
|
+
};
|
|
362
|
+
// `AbortController` + a timer rather than `AbortSignal.timeout`, which the
|
|
363
|
+
// CLI does not use anywhere and which this file must not be the first to
|
|
364
|
+
// require of a GJS host.
|
|
365
|
+
const send = async (target) => {
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
|
|
368
|
+
try {
|
|
369
|
+
return await doFetch(target, { headers, signal: controller.signal });
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
let res;
|
|
376
|
+
try {
|
|
377
|
+
res = await send(cacheBustedUrl(url, input.nonce));
|
|
378
|
+
// A registry that does not know the parameter must not be reported as a
|
|
379
|
+
// failed publish because of it. 404 is excluded: that is a real answer
|
|
380
|
+
// about the NAME, and re-asking it bare would only re-ask a cache.
|
|
381
|
+
if (!res.ok && res.status !== 404 && res.status >= 400 && res.status < 500) {
|
|
382
|
+
res = await send(url);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
// A real throw path: network failure, DNS, or our own abort above.
|
|
387
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
388
|
+
return { error: `request failed (${msg})` };
|
|
389
|
+
}
|
|
390
|
+
return res;
|
|
391
|
+
}
|
|
392
|
+
async function probeOnce(input) {
|
|
393
|
+
const { version } = input;
|
|
394
|
+
const sent = await sendPackumentRequest(input);
|
|
395
|
+
if ('error' in sent) {
|
|
396
|
+
return { state: 'error', detail: sent.error };
|
|
397
|
+
}
|
|
398
|
+
const res = sent;
|
|
196
399
|
if (res.status === 404) {
|
|
197
400
|
return { state: 'absent', status: 404, detail: '404 — the registry serves no packument for this name' };
|
|
198
401
|
}
|
|
@@ -211,7 +414,7 @@ async function probeOnce(input) {
|
|
|
211
414
|
}
|
|
212
415
|
const versions = doc?.versions;
|
|
213
416
|
const known = versions && typeof versions === 'object' ? Object.keys(versions) : [];
|
|
214
|
-
const entry = versions && typeof versions === 'object' ? versions[version] : undefined;
|
|
417
|
+
const entry = versions && typeof versions === 'object' && Object.hasOwn(versions, version) ? versions[version] : undefined;
|
|
215
418
|
if (!entry) {
|
|
216
419
|
const newest = known.length > 0 ? known[known.length - 1] : '(none)';
|
|
217
420
|
return {
|
|
@@ -245,29 +448,105 @@ export function formatUnconfirmedPublish(opts) {
|
|
|
245
448
|
// Only ever called on an UNCONFIRMED read-back, so `last` is `absent` or
|
|
246
449
|
// `error` — both carry a `detail`; `present` has nothing to explain.
|
|
247
450
|
const answered = readback.last.state === 'present' ? 'served' : readback.last.detail;
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
451
|
+
const claimed = claim === 'already-published' ? `npm says ${version} is ALREADY PUBLISHED` : 'npm ACCEPTED the upload';
|
|
452
|
+
// THE HEADLINE IS THE VERDICT, not the claim. It used to assert "the registry
|
|
453
|
+
// does not serve it" for every unconfirmed read-back, including one whose
|
|
454
|
+
// last probe was a 5xx — a statement the probe had not earned, on the very
|
|
455
|
+
// line written to stop a tool claiming what it had not established.
|
|
456
|
+
const headline = readback.verdict === 'unknown'
|
|
457
|
+
? readback.last.state === 'absent'
|
|
458
|
+
? `${claimed}; its install document does not serve ${version} and the registry's own record could NOT be read.`
|
|
459
|
+
: `${claimed} and the read-back could NOT establish whether the registry serves it.`
|
|
460
|
+
: readback.verdict === 'recorded-not-served'
|
|
461
|
+
? `${claimed}; the registry RECORDS ${version} and its install document does not serve it.`
|
|
462
|
+
: `${claimed} and the registry has no record of ${version}.`;
|
|
463
|
+
const remedy = readback.verdict === 'unknown'
|
|
252
464
|
? [
|
|
253
|
-
'
|
|
254
|
-
' the
|
|
255
|
-
'
|
|
256
|
-
' fix this — wait and re-read (`npm view <name>@<version>`), or check https://status.npmjs.org/.',
|
|
465
|
+
' This is NOT a report that the publish failed, and it is not one that it worked. The read-back',
|
|
466
|
+
' reached no answer it could act on, so the version may or may not be installable — confirm it',
|
|
467
|
+
' before releasing anything that depends on it (`npm view <name>@<version> dist.tarball`).',
|
|
257
468
|
]
|
|
258
|
-
:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
469
|
+
: readback.verdict === 'recorded-not-served'
|
|
470
|
+
? [
|
|
471
|
+
' Do NOT re-publish: the registry holds the write, so a re-run is answered 409 and changes',
|
|
472
|
+
" nothing. The document npm installs from is behind the registry's own record — measured on",
|
|
473
|
+
' the ts-for-gir v5.1.0 release, @ts-for-gir/reporter was recorded at 21:43:00.983Z and a',
|
|
474
|
+
' cache-busted read of its install document still did not carry 5.1.0 ~40 min later. Wait and',
|
|
475
|
+
' re-read; if it persists, check https://status.npmjs.org/.',
|
|
476
|
+
]
|
|
477
|
+
: claim === 'already-published'
|
|
478
|
+
? [
|
|
479
|
+
' A 409 is npm refusing to overwrite a version it holds, which is not the same as serving it:',
|
|
480
|
+
' in the v0.46.0 recovery @gjsify/node-runtime-darwin-arm64 was answered 409 at 09:48:49 and',
|
|
481
|
+
' the registry recorded 0.46.0 at 09:49:07, 18s later. Re-running answers the same 409, so it',
|
|
482
|
+
' cannot fix this — wait and re-read (`npm view <name>@<version>`), or check',
|
|
483
|
+
' https://status.npmjs.org/.',
|
|
484
|
+
]
|
|
485
|
+
: [
|
|
486
|
+
' A 2xx from npm is an ACCEPTED write, not a durable one. In the v0.46.0 release 19 of 199',
|
|
487
|
+
' packages were recorded by the registry 56-252s after their 2xx, and ONE was never recorded',
|
|
488
|
+
' at all while its job stayed green — that is the failure this check exists to name.',
|
|
489
|
+
` Re-run this publish (\`--tolerate-republish\` no-ops if it landed meanwhile). If it keeps`,
|
|
490
|
+
' failing, the write was rejected downstream of the 2xx: check https://status.npmjs.org/.',
|
|
491
|
+
];
|
|
265
492
|
return [
|
|
266
493
|
`gjsify publish: ${name}@${version} — ${headline}`,
|
|
267
494
|
` PUT ${putUrl} (${payloadBytes} bytes) → ${putStatus} ${putStatusText}`,
|
|
268
|
-
` read-back GET ${readback.url} (accept: application/vnd.npm.install-v1+json,
|
|
495
|
+
` read-back GET ${readback.url} (accept: application/vnd.npm.install-v1+json, per-probe cache buster)`,
|
|
269
496
|
` answered ${readback.last.state}: ${answered}`,
|
|
270
497
|
` after ${readback.attempts} probe(s) over ${seconds}s`,
|
|
498
|
+
` verdict ${readback.verdict} — ${readback.verdictDetail}`,
|
|
271
499
|
...remedy,
|
|
272
500
|
].join('\n');
|
|
273
501
|
}
|
|
502
|
+
let nameProbeSeq = 0;
|
|
503
|
+
/**
|
|
504
|
+
* Ask ONE question: does this NAME exist on the registry at all.
|
|
505
|
+
*
|
|
506
|
+
* Deliberately not a version question, and that is what makes it safe to gate a
|
|
507
|
+
* publish on. A version is eventually consistent — measured on v0.46.0, 19 of
|
|
508
|
+
* 199 packages were recorded 56-252 s AFTER their own 2xx — so "is name@version
|
|
509
|
+
* there" answers "not yet" for minutes and a gate built on it would manufacture
|
|
510
|
+
* a red on a publish that worked. Presence of the NAME does not move once the
|
|
511
|
+
* first version lands, which is the same reason
|
|
512
|
+
* `verify-published-closure.mjs --phase pre-release` asks it rather than the
|
|
513
|
+
* version: an answer that does not move when the train moves.
|
|
514
|
+
*/
|
|
515
|
+
export async function probePackageName(input) {
|
|
516
|
+
const doFetch = input.fetchImpl ?? ((...args) => globalThis.fetch(...args));
|
|
517
|
+
const registryClean = input.registry.endsWith('/') ? input.registry.slice(0, -1) : input.registry;
|
|
518
|
+
const sent = await sendPackumentRequest({
|
|
519
|
+
url: `${registryClean}/${escapePackageName(input.name)}`,
|
|
520
|
+
doFetch,
|
|
521
|
+
probeTimeoutMs: input.probeTimeoutMs ?? 30_000,
|
|
522
|
+
authorization: input.authorization,
|
|
523
|
+
nonce: `name-${Date.now()}-${++nameProbeSeq}`,
|
|
524
|
+
});
|
|
525
|
+
if ('error' in sent) {
|
|
526
|
+
return { state: 'unknown', detail: sent.error };
|
|
527
|
+
}
|
|
528
|
+
if (sent.status === 404) {
|
|
529
|
+
return { state: 'absent', status: 404, detail: '404 — the registry serves no packument for this name' };
|
|
530
|
+
}
|
|
531
|
+
if (!sent.ok) {
|
|
532
|
+
return { state: 'unknown', status: sent.status, detail: `${sent.status} ${sent.statusText}` };
|
|
533
|
+
}
|
|
534
|
+
let doc;
|
|
535
|
+
try {
|
|
536
|
+
doc = await sent.json();
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
540
|
+
return { state: 'unknown', status: sent.status, detail: `packument is not JSON (${msg})` };
|
|
541
|
+
}
|
|
542
|
+
const versions = doc?.versions;
|
|
543
|
+
const count = versions && typeof versions === 'object' ? Object.keys(versions).length : 0;
|
|
544
|
+
if (count === 0) {
|
|
545
|
+
// A 200 that carries no version is not a name that exists: every
|
|
546
|
+
// published name has at least one, and an unpublished-then-emptied
|
|
547
|
+
// packument is precisely the state npm serves for a name whose only
|
|
548
|
+
// version was unpublished.
|
|
549
|
+
return { state: 'unknown', status: sent.status, detail: 'a 200 carrying no versions establishes nothing' };
|
|
550
|
+
}
|
|
551
|
+
return { state: 'present', status: sent.status, versions: count };
|
|
552
|
+
}
|
package/lib/utils/run-gjs.js
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Env composition is split out as the pure `computeNativeEnvForBundle()` so the e2e tests can
|
|
11
11
|
// assert the env without spawning gjs.
|
|
12
|
-
import {
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
15
|
import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
|
|
14
16
|
import { describeExit, spawnToCompletion } from './spawn.js';
|
|
17
|
+
import { captureNativeBacktrace, createHangWatchdog, formatHangReport, hangGraceMs, heartbeatEnv, } from './hang-watchdog.js';
|
|
15
18
|
/**
|
|
16
19
|
* Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
|
|
17
20
|
* inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
|
|
@@ -40,12 +43,33 @@ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inher
|
|
|
40
43
|
.join(' ');
|
|
41
44
|
return { env, envPrefix };
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A private directory for the heartbeat file. Returns `undefined` when `/tmp` refuses —
|
|
48
|
+
* a read-only or full temp dir is a reason to run without the watchdog, never to fail the run
|
|
49
|
+
* the watchdog only watches.
|
|
50
|
+
*/
|
|
51
|
+
function safeMkdtemp() {
|
|
52
|
+
try {
|
|
53
|
+
return mkdtempSync(join(tmpdir(), 'gjsify-hb-'));
|
|
54
|
+
}
|
|
55
|
+
catch (_e) {
|
|
56
|
+
/* no writable temp dir — run unwatched rather than not at all */
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
43
60
|
export async function runGjsBundle(bundlePath, extraArgs = [], options) {
|
|
44
61
|
const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
// The breadcrumb `@gjsify/unit` writes what it is running into, and this process polls —
|
|
63
|
+
// the only way a bundle that has stopped turning its main loop can still say which test it
|
|
64
|
+
// died in. Anything that is not a unit run never writes the file, so the watchdog stays
|
|
65
|
+
// inert; see `utils/hang-watchdog.ts`. The path is fresh per spawn, and `heartbeatEnv`
|
|
66
|
+
// REMOVES an inherited one when this spawn mints none, so two runs can never write into
|
|
67
|
+
// one file — but a bundle that spawns a unit run without going through this function still
|
|
68
|
+
// hands it whatever it inherited, so route such a spawn here.
|
|
69
|
+
const graceMs = hangGraceMs(process.env);
|
|
70
|
+
const heartbeatDir = graceMs > 0 ? safeMkdtemp() : undefined;
|
|
71
|
+
const heartbeatPath = heartbeatDir ? join(heartbeatDir, 'unit.heartbeat') : undefined;
|
|
72
|
+
const env = heartbeatEnv({ ...process.env, ...nativeEnv }, heartbeatPath);
|
|
49
73
|
const gjsArgs = ['-m', bundlePath, ...extraArgs];
|
|
50
74
|
// Echo the exact command so users can copy-paste it and run gjs without the wrapper.
|
|
51
75
|
// stderr, so children speaking a protocol on stdout (an MCP stdio server) keep a clean one.
|
|
@@ -64,14 +88,66 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
|
|
|
64
88
|
process.exitCode = code;
|
|
65
89
|
return process.exit(code);
|
|
66
90
|
};
|
|
91
|
+
let child;
|
|
92
|
+
let hung = false;
|
|
93
|
+
const watchdog = heartbeatPath
|
|
94
|
+
? createHangWatchdog({
|
|
95
|
+
read: () => {
|
|
96
|
+
try {
|
|
97
|
+
return readFileSync(heartbeatPath, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch (_e) {
|
|
100
|
+
/* not written yet, or caught mid-rename — both mean "no claim yet" */
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
graceMs,
|
|
105
|
+
onHang: (heartbeat, overdueMs) => {
|
|
106
|
+
const pid = child?.pid;
|
|
107
|
+
if (pid === undefined)
|
|
108
|
+
return;
|
|
109
|
+
hung = true;
|
|
110
|
+
// The backtrace is taken BEFORE the kill: it is the only artefact that
|
|
111
|
+
// survives to say WHERE the wedge was, and a dead process has none.
|
|
112
|
+
console.error(formatHangReport({
|
|
113
|
+
label: heartbeat.label,
|
|
114
|
+
overdueMs,
|
|
115
|
+
pid,
|
|
116
|
+
graceMs,
|
|
117
|
+
backtrace: captureNativeBacktrace(pid),
|
|
118
|
+
}));
|
|
119
|
+
// SIGABRT first so a GJS built with a fatal-signal handler still gets to
|
|
120
|
+
// dump what it knows; SIGKILL behind it because a process blocked in a
|
|
121
|
+
// native call may never run a handler at all.
|
|
122
|
+
child?.kill('SIGABRT');
|
|
123
|
+
setTimeout(() => child?.kill('SIGKILL'), 2_000);
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
: undefined;
|
|
67
127
|
let result;
|
|
68
128
|
try {
|
|
69
|
-
result = await spawnToCompletion('gjs', gjsArgs, {
|
|
129
|
+
result = await spawnToCompletion('gjs', gjsArgs, {
|
|
130
|
+
completion: options.completion,
|
|
131
|
+
env,
|
|
132
|
+
onSpawn: (c) => {
|
|
133
|
+
child = c;
|
|
134
|
+
watchdog?.start();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
70
137
|
}
|
|
71
138
|
catch (err) {
|
|
72
139
|
console.error(err.message);
|
|
73
140
|
return failed(1);
|
|
74
141
|
}
|
|
142
|
+
finally {
|
|
143
|
+
watchdog?.stop();
|
|
144
|
+
if (heartbeatDir)
|
|
145
|
+
rmSync(heartbeatDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
// A killed child reports only its signal; without this the hang report above would be
|
|
148
|
+
// followed by a bare "gjs exited with signal SIGABRT" and read as a crash.
|
|
149
|
+
if (hung)
|
|
150
|
+
return failed(result.code ?? 1);
|
|
75
151
|
if (result.code !== 0) {
|
|
76
152
|
console.error(`gjs exited with ${describeExit(result)}`);
|
|
77
153
|
return failed(result.code ?? 1);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppIconRasters } from './icons.js';
|
|
2
|
+
/** One element of the container: Apple's type code and the pixel size of the PNG it holds. */
|
|
3
|
+
export interface IcnsElement {
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly size: number;
|
|
6
|
+
/** The `.iconset` filename `iconutil` gives this element — the reader's spelling, for messages. */
|
|
7
|
+
readonly iconset: string;
|
|
8
|
+
}
|
|
9
|
+
/** The ten PNG-backed element types of a modern application icon, in `iconutil`'s order. */
|
|
10
|
+
export declare const ICNS_ELEMENTS: readonly IcnsElement[];
|
|
11
|
+
/** The distinct pixel sizes the ten elements need — what the layout asks the raster step for. */
|
|
12
|
+
export declare const ICNS_SIZES: readonly number[];
|
|
13
|
+
/** The `.icns` file, `TOC ` first, then the ten elements in {@link ICNS_ELEMENTS} order. */
|
|
14
|
+
export declare function buildIcns(icon: AppIconRasters): Uint8Array;
|