@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.
Files changed (61) hide show
  1. package/dist/cli.gjs.mjs +505 -490
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected-classify.d.ts +7 -1
  4. package/lib/commands/affected-classify.js +66 -9
  5. package/lib/commands/affected-script-refs.d.ts +20 -0
  6. package/lib/commands/affected-script-refs.js +218 -0
  7. package/lib/commands/affected.js +24 -2
  8. package/lib/commands/flatpak/diff.js +8 -2
  9. package/lib/commands/flatpak/init.js +4 -1
  10. package/lib/commands/flatpak/sources.js +4 -3
  11. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  12. package/lib/commands/flatpak/sync-flathub.js +149 -7
  13. package/lib/commands/flatpak/utils.d.ts +10 -0
  14. package/lib/commands/flatpak/utils.js +10 -0
  15. package/lib/commands/index.d.ts +1 -0
  16. package/lib/commands/index.js +1 -0
  17. package/lib/commands/install.js +76 -0
  18. package/lib/commands/link.d.ts +13 -0
  19. package/lib/commands/link.js +243 -0
  20. package/lib/commands/onboard.js +18 -2
  21. package/lib/commands/publish.d.ts +34 -0
  22. package/lib/commands/publish.js +153 -8
  23. package/lib/commands/ship.js +19 -1
  24. package/lib/types/config-data.d.ts +1 -1
  25. package/lib/utils/check-system-deps.d.ts +1 -17
  26. package/lib/utils/check-system-deps.js +10 -68
  27. package/lib/utils/dev-link.d.ts +222 -0
  28. package/lib/utils/dev-link.js +632 -0
  29. package/lib/utils/hang-watchdog.d.ts +95 -0
  30. package/lib/utils/hang-watchdog.js +194 -0
  31. package/lib/utils/install-backend-native.d.ts +14 -0
  32. package/lib/utils/install-backend-native.js +91 -13
  33. package/lib/utils/install-extraneous.d.ts +90 -0
  34. package/lib/utils/install-extraneous.js +120 -0
  35. package/lib/utils/install-tarball-cache.d.ts +37 -9
  36. package/lib/utils/install-tarball-cache.js +59 -14
  37. package/lib/utils/normalize-bundler-options.js +11 -0
  38. package/lib/utils/publish-pins.d.ts +38 -0
  39. package/lib/utils/publish-pins.js +47 -0
  40. package/lib/utils/publish-readback.d.ts +103 -17
  41. package/lib/utils/publish-readback.js +326 -47
  42. package/lib/utils/run-gjs.js +82 -6
  43. package/lib/utils/ship/icns.d.ts +14 -0
  44. package/lib/utils/ship/icns.js +83 -0
  45. package/lib/utils/ship/ico.d.ts +25 -0
  46. package/lib/utils/ship/ico.js +120 -0
  47. package/lib/utils/ship/icons.d.ts +191 -0
  48. package/lib/utils/ship/icons.js +398 -0
  49. package/lib/utils/ship/launcher.js +16 -1
  50. package/lib/utils/ship/layout.d.ts +32 -0
  51. package/lib/utils/ship/layout.js +112 -38
  52. package/lib/utils/ship/msi.d.ts +1 -1
  53. package/lib/utils/ship/msi.js +30 -5
  54. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  55. package/lib/utils/ship/pe-launcher.js +125 -4
  56. package/lib/utils/ship/plan.d.ts +1 -34
  57. package/lib/utils/ship/plan.js +7 -56
  58. package/lib/utils/ship/plist.d.ts +20 -10
  59. package/lib/utils/ship/plist.js +29 -12
  60. package/lib/utils/ship/settings.js +3 -2
  61. 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: 300 s.
108
+ * Default polling budget: 600 s.
109
109
  *
110
- * The measured maximum lag between a 2xx and the registry recording the version
111
- * was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
112
- * turns npm's normal queueing into a false red — and a false red at release time
113
- * costs a manual re-run of a workflow that did its job. 300 s clears that
114
- * maximum and is still short enough that a genuinely lost write is reported by
115
- * the job that lost it rather than by a later sweep.
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
- * THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
118
- * nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
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. One release is one sample, so a lag past
122
- * 300 s is not ruled out; what makes 300 s affordable anyway is that the
123
- * remediation is verified rather than assumed. The re-run this reports lands on
124
- * the 409 path, and that path READS BACK too (see the conflict branch in
125
- * `commands/publish.ts`), so a window that turns out to be too short costs one
126
- * re-run that confirms — it does not hand back an unverified success.
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 = 300_000;
140
+ export const DEFAULT_VERIFY_BUDGET_MS = 600_000;
129
141
  /**
130
- * Poll the registry until `name@version` resolves, or the budget runs out.
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
- return { confirmed: last.state === 'present', attempts, elapsedMs: now() - started, url, last };
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
- async function probeOnce(input) {
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 { state: 'error', detail: `request failed (${msg})` };
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 headline = claim === 'already-published'
249
- ? `npm says ${version} is ALREADY PUBLISHED but the registry does not serve it.`
250
- : 'npm ACCEPTED the upload but the registry does not serve it.';
251
- const remedy = claim === 'already-published'
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
- ' A 409 is npm refusing to overwrite a version it holds, which is not the same as serving it: in',
254
- ' the v0.46.0 recovery @gjsify/node-runtime-darwin-arm64 was answered 409 at 09:48:49 and the',
255
- ' registry recorded 0.46.0 at 09:49:07, 18s later. Re-running answers the same 409, so it cannot',
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
- ' A 2xx from npm is an ACCEPTED write, not a durable one. In the v0.46.0 release 19 of 199',
260
- ' packages were recorded by the registry 56-252s after their 2xx, and ONE was never recorded',
261
- ' at all while its job stayed green — that is the failure this check exists to name.',
262
- ` Re-run this publish (\`--tolerate-republish\` no-ops if it landed meanwhile). If it keeps`,
263
- ' failing, the write was rejected downstream of the 2xx: check https://status.npmjs.org/.',
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, cache-control: no-cache)`,
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
+ }
@@ -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 { dirname, resolve } from 'node:path';
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
- const env = {
46
- ...process.env,
47
- ...nativeEnv,
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, { completion: options.completion, env });
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;