@gjsify/cli 0.43.0 → 0.45.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 (87) hide show
  1. package/dist/cli.gjs.mjs +453 -425
  2. package/lib/actions/build.js +2 -1
  3. package/lib/commands/affected-classify.js +7 -5
  4. package/lib/commands/build.js +6 -0
  5. package/lib/commands/flatpak/ci.js +10 -2
  6. package/lib/commands/flatpak/init.js +15 -16
  7. package/lib/commands/flatpak/utils.d.ts +0 -26
  8. package/lib/commands/flatpak/utils.js +4 -29
  9. package/lib/commands/gettext.d.ts +2 -1
  10. package/lib/commands/gettext.js +105 -47
  11. package/lib/commands/onboard.d.ts +7 -0
  12. package/lib/commands/onboard.js +349 -96
  13. package/lib/commands/ship.d.ts +3 -0
  14. package/lib/commands/ship.js +911 -70
  15. package/lib/commands/trust.d.ts +6 -0
  16. package/lib/commands/trust.js +1 -1
  17. package/lib/config.js +25 -0
  18. package/lib/types/cli-build-options.d.ts +11 -0
  19. package/lib/types/config-data.d.ts +137 -3
  20. package/lib/utils/flatpak-runtime.d.ts +41 -0
  21. package/lib/utils/flatpak-runtime.js +54 -0
  22. package/lib/utils/msgfmt-merge.d.ts +35 -0
  23. package/lib/utils/msgfmt-merge.js +102 -0
  24. package/lib/utils/npm-otp.d.ts +27 -2
  25. package/lib/utils/npm-otp.js +82 -7
  26. package/lib/utils/onboard-discovery.d.ts +68 -0
  27. package/lib/utils/onboard-discovery.js +178 -0
  28. package/lib/utils/onboard-probe.d.ts +118 -1
  29. package/lib/utils/onboard-probe.js +153 -4
  30. package/lib/utils/prompt-output.d.ts +12 -0
  31. package/lib/utils/prompt-output.js +54 -0
  32. package/lib/utils/prompt.js +22 -2
  33. package/lib/utils/publishable-packages.d.ts +1 -1
  34. package/lib/utils/publishable-packages.js +1 -0
  35. package/lib/utils/resolve-npm-package.d.ts +56 -0
  36. package/lib/utils/resolve-npm-package.js +69 -0
  37. package/lib/utils/ship/app-runtime.d.ts +214 -0
  38. package/lib/utils/ship/app-runtime.js +429 -0
  39. package/lib/utils/ship/depends.d.ts +54 -7
  40. package/lib/utils/ship/depends.js +120 -6
  41. package/lib/utils/ship/discover.js +18 -1
  42. package/lib/utils/ship/dmg.d.ts +71 -0
  43. package/lib/utils/ship/dmg.js +181 -0
  44. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  45. package/lib/utils/ship/flatpak-config.js +132 -0
  46. package/lib/utils/ship/flatpak.d.ts +45 -0
  47. package/lib/utils/ship/flatpak.js +148 -0
  48. package/lib/utils/ship/formats.d.ts +116 -3
  49. package/lib/utils/ship/formats.js +869 -11
  50. package/lib/utils/ship/launcher.d.ts +13 -3
  51. package/lib/utils/ship/launcher.js +362 -17
  52. package/lib/utils/ship/layout.d.ts +268 -0
  53. package/lib/utils/ship/layout.js +351 -0
  54. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  55. package/lib/utils/ship/localize-metadata.js +126 -0
  56. package/lib/utils/ship/mime.js +1 -9
  57. package/lib/utils/ship/msi.d.ts +126 -0
  58. package/lib/utils/ship/msi.js +417 -0
  59. package/lib/utils/ship/node-runtime.d.ts +71 -0
  60. package/lib/utils/ship/node-runtime.js +122 -0
  61. package/lib/utils/ship/payload.d.ts +156 -5
  62. package/lib/utils/ship/payload.js +580 -14
  63. package/lib/utils/ship/plan.d.ts +21 -0
  64. package/lib/utils/ship/plan.js +20 -7
  65. package/lib/utils/ship/plist.d.ts +58 -0
  66. package/lib/utils/ship/plist.js +198 -0
  67. package/lib/utils/ship/rpm.js +9 -3
  68. package/lib/utils/ship/schemas.d.ts +61 -0
  69. package/lib/utils/ship/schemas.js +118 -0
  70. package/lib/utils/ship/scripts.js +7 -3
  71. package/lib/utils/ship/settings.d.ts +19 -1
  72. package/lib/utils/ship/settings.js +19 -1
  73. package/lib/utils/ship/share-dirs.d.ts +23 -0
  74. package/lib/utils/ship/share-dirs.js +46 -0
  75. package/lib/utils/ship/signing.d.ts +195 -0
  76. package/lib/utils/ship/signing.js +317 -0
  77. package/lib/utils/ship/stage-manifest.d.ts +50 -13
  78. package/lib/utils/ship/stage-manifest.js +137 -14
  79. package/lib/utils/ship/stage-writer.d.ts +48 -0
  80. package/lib/utils/ship/stage-writer.js +71 -1
  81. package/lib/utils/ship/types.d.ts +264 -16
  82. package/lib/utils/ship/xml.d.ts +7 -0
  83. package/lib/utils/ship/xml.js +25 -0
  84. package/lib/utils/ship/zip.d.ts +69 -0
  85. package/lib/utils/ship/zip.js +245 -0
  86. package/lib/utils/trust-registry.js +5 -0
  87. package/package.json +19 -19
@@ -1,16 +1,18 @@
1
- // `gjsify onboard [--dry-run] [--otp <code>] [--registry <url>] [--json] [--yes]`
1
+ // `gjsify onboard [--packages <glob>] [--include/--exclude <glob>] [--dry-run] …`
2
2
  //
3
- // One command that ensures EVERY publishable `@gjsify/*` workspace is both
4
- // PUBLISHED on npm AND has a Trusted Publisher configured for this repo's
5
- // `release.yml`. It does the minimum work — publishing/trusting only what's
3
+ // One command that ensures EVERY publishable package in a monorepo is both
4
+ // PUBLISHED on npm AND has a Trusted Publisher configured for that repo's
5
+ // release workflow. It does the minimum work — publishing/trusting only what's
6
6
  // missing — and folds the whole manual bootstrap sequence documented in
7
- // AGENTS.md ("New @gjsify/* package: first-publish + Trusted Publisher
8
- // bootstrap") into a single idempotent sweep:
7
+ // docs/publishing.md into a single idempotent sweep:
9
8
  //
10
9
  // 1. Auth gate — `whoami` first. If the token is live, proceed without asking
11
10
  // for credentials; only when it is dead/missing do we run the `gjsify
12
11
  // login` flow (unless `--yes` on a non-TTY, which fails clearly).
13
- // 2. Enumerate publishable workspaces (non-private, excluding `@girs/*`).
12
+ // 2. Enumerate the publishable packages (non-private), from the root
13
+ // manifest's `workspaces` globs and/or `--packages` directory globs, then
14
+ // filter by `--include`/`--exclude`. Every source is reported with its
15
+ // count — see utils/onboard-discovery.ts for why that is load-bearing.
14
16
  // 3. Determine each package's state (bounded concurrency) by reading npm's
15
17
  // Trusted-Publisher config through the SAME requester path `gjsify trust`
16
18
  // uses: unpublished (404) / published-but-untrusted / published-and-trusted.
@@ -22,25 +24,38 @@
22
24
  // code is tried before prompting, so a whole sweep of N packages typically
23
25
  // needs the user to type an OTP only once (see utils/npm-otp.ts).
24
26
  //
27
+ // NOTHING here is specific to this repo. `--packages '*'` over a root that has
28
+ // no package.json at all is the shape `gjsify/types` needs (703 `@girs/*`
29
+ // directories, no workspace manifest), and it is the same code path that sweeps
30
+ // this monorepo's workspaces.
31
+ //
25
32
  // Re-running when everything is already published + trusted does nothing and
26
33
  // exits 0. `--dry-run` reports the plan and changes nothing.
27
34
  import { spawnSync } from 'node:child_process';
28
35
  import { DEFAULT_REGISTRY, whoami } from '@gjsify/npm-registry';
29
- import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
30
- import { mergePublishables } from '../utils/publishable-packages.js';
36
+ import { filterWorkspaces } from '@gjsify/workspace';
31
37
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
32
38
  import { OtpProvider } from '../utils/npm-otp.js';
39
+ import { writeAroundPrompt } from '../utils/prompt-output.js';
33
40
  import { publishWorkspace } from './publish.js';
34
41
  import { createTrustRequester } from './trust.js';
35
42
  import { runLogin, LoginError } from './login.js';
36
43
  import { detectPackageManager } from './workspace.js';
37
44
  import { spawnToCompletion } from '../utils/spawn.js';
38
- import { findWorkspaceRoot } from '../utils/workspace-root.js';
45
+ import { assertEveryPatternMatches, assertRepositoryAgreement, collectOnboardPackages, describeSources, resolveRepoRoot, } from '../utils/onboard-discovery.js';
39
46
  import { githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, validateRepository, } from '../utils/trust-registry.js';
40
- import { DEFAULT_PROBE_CONCURRENCY, probeAllTrustStates } from '../utils/onboard-probe.js';
47
+ import { DEFAULT_PROBE_CONCURRENCY, mapWithConcurrency, probeAllTrustStates, RateLimitGate, requestWaitingOutRateLimit, } from '../utils/onboard-probe.js';
48
+ /**
49
+ * How many Trusted-Publisher writes run in parallel by default.
50
+ *
51
+ * Deliberately the same small number as the read probe: one token, one registry,
52
+ * and a 429 costs a backoff. The win is not really throughput — it is that four
53
+ * writes per npm 2FA window means a quarter as many codes typed by hand.
54
+ */
55
+ const DEFAULT_WRITE_CONCURRENCY = 4;
41
56
  export const onboardCommand = {
42
57
  command: 'onboard',
43
- description: 'Ensure every publishable @gjsify/* workspace is both published on npm and has a Trusted Publisher configured — publishing/trusting only what is missing, one shared OTP across the whole sweep. Idempotent.',
58
+ description: 'Ensure every publishable package in a monorepo is both published on npm and has a Trusted Publisher configured for its release workflow — publishing/trusting only what is missing, one shared OTP across the whole sweep. Works on npm/yarn workspaces and, via --packages, on a monorepo with no workspace manifest at all. Idempotent.',
44
59
  builder: (yargs) => yargs
45
60
  .option('repository', {
46
61
  description: 'GitHub repo as `owner/repo`. Default: inferred from the `origin` git remote.',
@@ -62,11 +77,48 @@ export const onboardCommand = {
62
77
  .option('otp', {
63
78
  description: 'npm 2FA one-time code, used as the initial shared code. Prompted on demand (once) if omitted and a challenge occurs.',
64
79
  type: 'string',
80
+ })
81
+ .option('packages', {
82
+ description: 'Directory glob naming package folders, resolved against the repo root (repeatable). For a monorepo that is not an npm/yarn workspace — `--packages "*"` in a repo whose 703 package directories sit at the top level. Merged with the root manifest\'s own `workspaces` globs when it has any. A pattern matching NO directory is a hard error.',
83
+ type: 'string',
84
+ array: true,
85
+ })
86
+ .option('include', {
87
+ description: 'Glob pattern to include packages by NAME (repeatable). Applied after discovery.',
88
+ type: 'string',
89
+ array: true,
90
+ })
91
+ .option('exclude', {
92
+ description: 'Glob pattern to exclude packages by NAME (repeatable). Applied after discovery.',
93
+ type: 'string',
94
+ array: true,
95
+ })
96
+ .option('access', {
97
+ description: 'npm access for a FIRST publish — `public` or `restricted`. Only ever applied to a package this sweep publishes; an already-published package keeps whatever access it has.',
98
+ type: 'string',
99
+ choices: ['public', 'restricted'],
100
+ default: 'public',
101
+ })
102
+ .option('build', {
103
+ description: "Run each to-be-published package's `build` script first. `--no-build` skips it, for a repo whose packages are generated artifacts with nothing to build.",
104
+ type: 'boolean',
105
+ default: true,
106
+ })
107
+ .option('verbose', {
108
+ description: 'List every package in the plan table, including the ones already published + trusted (by default only the rows needing work are listed; the counts always cover all of them).',
109
+ type: 'boolean',
110
+ alias: 'v',
111
+ default: false,
65
112
  })
66
113
  .option('concurrency', {
67
114
  description: 'How many packages to probe (read state) in parallel (kept small so a single token does not burst npm; the first read is always serial to prompt for the shared OTP once).',
68
115
  type: 'number',
69
116
  default: DEFAULT_PROBE_CONCURRENCY,
117
+ })
118
+ .option('write-concurrency', {
119
+ description: "How many Trusted-Publisher writes to issue in parallel. It buys FEWER 2FA PROMPTS more than it buys speed: an npm code lives about 30 seconds, so a serial sweep crosses a code boundary every ~38 packages. Raising it does not beat npm's rate limit — a 429 anywhere now pauses the whole sweep, so it self-paces down to whatever the registry will serve. Publishes are unaffected: they stay serial, because publish ORDER is a correctness property.",
120
+ type: 'number',
121
+ default: DEFAULT_WRITE_CONCURRENCY,
70
122
  })
71
123
  .option('dry-run', {
72
124
  description: 'Report the plan (what would be published / trusted) without changing anything.',
@@ -93,8 +145,11 @@ export const onboardCommand = {
93
145
  // route progress to stderr.
94
146
  if (asJson)
95
147
  process.stderr.write(`${msg}\n`);
148
+ // Through the prompt arbiter: a concurrent worker's notice must not
149
+ // land inside the OTP the user is typing. Held, not dropped — those
150
+ // notices are what explain why the sweep is slow.
96
151
  else
97
- process.stdout.write(`${msg}\n`);
152
+ writeAroundPrompt(`${msg}\n`);
98
153
  };
99
154
  let npmrc = await loadNpmrc(cwd);
100
155
  const defaultRegistry = args.registry ?? process.env.npm_config_registry ?? npmrc.registry ?? DEFAULT_REGISTRY;
@@ -140,23 +195,66 @@ export const onboardCommand = {
140
195
  }
141
196
  const workflow = normalizeWorkflowFile(args.workflow);
142
197
  const environment = args.environment;
143
- // 2. Enumerate publishable workspaces (non-private, exclude @girs/*).
144
- const root = findWorkspaceRoot(cwd) ?? cwd;
145
- // Non-workspace publishables (packages/{node-gi,napi}/*) publish through
146
- // release.yml but `discoverWorkspaces` cannot see them — without this
147
- // merge the sweep reports "all done" while a package that was never
148
- // published is simply absent from the list.
149
- const all = mergePublishables(discoverWorkspaces(root, { includeRoot: true }), cwd);
150
- const selected = filterWorkspaces(all, { noPrivate: true, exclude: ['@girs/*'] }).filter((ws) => ws.name);
198
+ // 2. Enumerate the publishable packages (non-private), from every source
199
+ // that applies to this repo, then filter by name.
200
+ const root = resolveRepoRoot(cwd);
201
+ let all;
202
+ let sources;
203
+ try {
204
+ if (args.packages && args.packages.length > 0)
205
+ assertEveryPatternMatches(root, args.packages);
206
+ const found = collectOnboardPackages(root, cwd, { patterns: args.packages });
207
+ all = found.packages;
208
+ sources = describeSources(found.sources);
209
+ }
210
+ catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ if (asJson)
213
+ process.stdout.write(`${JSON.stringify({ ok: false, error: 'discovery', message: msg })}\n`);
214
+ else
215
+ console.error(msg);
216
+ return process.exit(1);
217
+ }
218
+ const selected = filterWorkspaces(all, {
219
+ noPrivate: true,
220
+ include: args.include,
221
+ exclude: args.exclude,
222
+ }).filter((ws) => ws.name);
151
223
  if (selected.length === 0) {
152
- const msg = 'gjsify onboard: no publishable workspace packages found.';
224
+ const msg = `gjsify onboard: no publishable packages found under ${root} ` +
225
+ `(discovered ${all.length}${sources ? ` — ${sources}` : ''}). ` +
226
+ (all.length > 0
227
+ ? 'Every discovered package is private or was filtered out by --include/--exclude.'
228
+ : 'Pass --packages with a directory glob if this repo has no workspace manifest.');
153
229
  if (asJson)
154
230
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'no-packages', message: msg })}\n`);
155
231
  else
156
232
  console.error(msg);
157
233
  return process.exit(1);
158
234
  }
159
- log(`gjsify onboard: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
235
+ // Every selected package must AGREE that it lives here. A workspace of
236
+ // this repo is not the same claim as a package published from it —
237
+ // `gjsify/ts-for-gir` has ~703 generated `@girs/*` workspaces that
238
+ // publish from `gjsify/types`, and trusting those for the wrong
239
+ // workflow breaks exactly the release this sweep exists to protect.
240
+ try {
241
+ assertRepositoryAgreement(selected, repository);
242
+ }
243
+ catch (err) {
244
+ const msg = err instanceof Error ? err.message : String(err);
245
+ if (asJson) {
246
+ process.stdout.write(`${JSON.stringify({ ok: false, error: 'foreign-repository', message: msg })}\n`);
247
+ }
248
+ else {
249
+ console.error(msg);
250
+ }
251
+ return process.exit(1);
252
+ }
253
+ // The blast radius of a sweep that writes to npm is exactly this list,
254
+ // so say where it came from — a total alone cannot distinguish the right
255
+ // set from a plausible wrong one.
256
+ log(`gjsify onboard: root=${root} | ${sources}`);
257
+ log(`gjsify onboard: ${selected.length} of ${all.length} package(s) selected | repo=${repository} workflow=${workflow}`);
160
258
  if (dryRun)
161
259
  log('(dry-run — nothing will be published or configured)');
162
260
  log();
@@ -169,6 +267,25 @@ export const onboardCommand = {
169
267
  registry: defaultRegistry,
170
268
  });
171
269
  const trustRequest = createTrustRequester({ npmrc, otpProvider });
270
+ // ONE cool-down for the whole sweep: a 429 on any request holds back
271
+ // every other one. Retrying a throttled request in isolation is not
272
+ // enough — the rest of the sweep keeps provoking the very limit the
273
+ // retry is waiting out, which is how a 703-package run spent its retry
274
+ // budget and reported `trust failed (HTTP 429)`.
275
+ const gate = new RateLimitGate();
276
+ let rateLimitReported = false;
277
+ const onRateLimit = (info) => {
278
+ if (rateLimitReported)
279
+ return;
280
+ rateLimitReported = true;
281
+ // Report the FIRST one only. It answers the question that matters —
282
+ // does npm tell us how fast we may go? — and repeating it per request
283
+ // would bury the sweep's own output.
284
+ log(` npm rate limit hit. ${info.headers}. Waiting ${Math.round(info.waitMs / 1000)}s ` +
285
+ `(${info.fromRetryAfter ? "npm's Retry-After" : 'our backoff — npm advised nothing'}), ` +
286
+ 'and holding the whole sweep back while it lasts.');
287
+ };
288
+ const pacing = { gate, onRateLimit };
172
289
  // 3. Determine per-package state. Reads run through the SAME
173
290
  // `TrustRequester` path `gjsify trust` uses (identical endpoint + auth +
174
291
  // OTP handling) — so a 2FA-gated trust-state read is answered with the
@@ -176,21 +293,69 @@ export const onboardCommand = {
176
293
  // (task #60). The first read is serial (prompts for the shared OTP once,
177
294
  // before the burst); the rest run at a SMALL concurrency cap so a single
178
295
  // token never faces a 127-wide parallel read.
296
+ // Progress, because this phase is otherwise SILENT for minutes. It is
297
+ // paced by count rather than by time so the interval is reproducible
298
+ // between runs, and it carries the running TALLY rather than a bare
299
+ // counter: "600/703 — 590 to trust" says both that the sweep is alive
300
+ // and what it is about to do, which is what a reader is deciding on.
301
+ const progressEvery = Math.max(1, Math.min(50, Math.ceil(selected.length / 10)));
302
+ let toTrustSoFar = 0;
303
+ let doneSoFar = 0;
304
+ let blockedSoFar = 0;
305
+ if (selected.length > progressEvery)
306
+ log(`Reading trust state for ${selected.length} package(s)…`);
179
307
  const plans = await probeAllTrustStates(trustRequest, selected, Math.max(1, args.concurrency), {
180
308
  registryOverride: args.registry,
181
309
  npmrc,
182
310
  repository,
183
311
  workflow,
312
+ }, {
313
+ ...pacing,
314
+ onProgress: (n, total, plan) => {
315
+ if (plan.action === 'trust' || plan.action === 'publish-and-trust')
316
+ toTrustSoFar++;
317
+ else if (plan.action === 'skip')
318
+ doneSoFar++;
319
+ else
320
+ blockedSoFar++;
321
+ // Always report the LAST one, so the phase visibly ends.
322
+ if (n % progressEvery !== 0 && n !== total)
323
+ return;
324
+ if (total <= progressEvery)
325
+ return;
326
+ const parts = [`${toTrustSoFar} to do`, `${doneSoFar} already done`];
327
+ if (blockedSoFar > 0)
328
+ parts.push(`${blockedSoFar} unreadable`);
329
+ log(` read ${n}/${total} — ${parts.join(', ')}`);
330
+ },
184
331
  });
185
332
  // Summary table (published✓ / trusted✓ vs work to do).
186
333
  const toPublish = plans.filter((p) => p.action === 'publish-and-trust');
187
334
  const toTrust = plans.filter((p) => p.action === 'trust');
188
335
  const done = plans.filter((p) => p.action === 'skip');
189
336
  const blocked = plans.filter((p) => p.action === 'blocked');
190
- for (const p of plans)
337
+ // A 703-package sweep prints 703 rows of "nothing to do" and buries the
338
+ // handful that matter. Rows needing WORK are always listed; the finished
339
+ // ones only under --verbose. The counts below cover all of them either
340
+ // way — a row can be omitted from the listing, never from the tally.
341
+ for (const p of plans) {
342
+ if (p.action === 'skip' && args.verbose !== true)
343
+ continue;
191
344
  log(` ${statusCell(p)} ${p.ws.name}`);
345
+ }
346
+ if (!args.verbose && done.length > 0)
347
+ log(` (${done.length} already published + trusted — pass -v to list)`);
192
348
  log();
193
349
  log(`Plan: ${done.length} already done, ${toPublish.length} to publish+trust, ${toTrust.length} to trust, ${blocked.length} unreadable.`);
350
+ // Name the cause when the unreadable ones are throttling rather than a
351
+ // property of those packages. It lands on the TAIL of an alphabetical
352
+ // sweep, which is exactly where it looks like "these packages are
353
+ // special" instead of "we asked too fast".
354
+ const throttled = blocked.filter((p) => p.httpStatus === 429).length;
355
+ if (throttled > 0) {
356
+ log(` ${throttled} of those are npm rate limits (HTTP 429) that outlasted the backoff — ` +
357
+ 'lower --concurrency and re-run; the sweep is idempotent and picks up where it left off.');
358
+ }
194
359
  const results = [];
195
360
  if (dryRun) {
196
361
  for (const p of plans) {
@@ -201,99 +366,178 @@ export const onboardCommand = {
201
366
  ? 'would publish + trust'
202
367
  : p.action === 'trust'
203
368
  ? 'would trust'
204
- : undefined,
369
+ : p.action === 'blocked'
370
+ ? // Same shape the act phase reports, so a
371
+ // `failed` row in a dry-run summary says WHY
372
+ // rather than only that it did not succeed.
373
+ `state HTTP ${p.httpStatus}`
374
+ : undefined,
205
375
  });
206
376
  }
207
- finish(results, plans, { asJson, dryRun });
377
+ // A dry-run whose whole job is to REPORT THE PLAN must not exit 0
378
+ // having failed to compute it. Every unreadable package is counted
379
+ // as a failure here: run without a token, every trust read is a 401
380
+ // and the summary is `0 to publish+trust, 0 to trust` — a plan that
381
+ // was never determined, printed in the shape of a plan that found
382
+ // nothing to do. Measured on `gjsify/types`: 703 packages, 703
383
+ // unreadable, exit 0.
384
+ finish(results, plans, {
385
+ asJson,
386
+ dryRun,
387
+ failed: blocked.length,
388
+ root,
389
+ sources,
390
+ discovered: all.length,
391
+ });
208
392
  return;
209
393
  }
210
- // 4. Act on the gaps, minimally. Publishes + trust POSTs run SERIALLY so
211
- // the shared OTP is prompted at most once (and reused everywhere) —
212
- // reusing the `otpProvider` + `trustRequest` built for the probe phase.
394
+ // 4. Act on the gaps, minimally.
395
+ //
396
+ // The two kinds of work are paced DIFFERENTLY, on purpose.
397
+ //
398
+ // A trust POST is independent of every other trust POST, so they run at
399
+ // `--write-concurrency`. They used to be serial for one reason — "so the
400
+ // shared OTP is prompted at most once" — and that reason is gone now
401
+ // that `OtpProvider.refresh()` is single-flight: concurrent writers share
402
+ // one prompt instead of stacking N of them.
403
+ //
404
+ // What this buys is PROMPTS, not throughput, and the distinction is
405
+ // measured rather than assumed: the real `gjsify/types` run was already
406
+ // being 429'd at SERIAL pace, so npm — not the loop — sets the ceiling.
407
+ // What serial cost was 2FA codes: one lives ~30 s, so the sweep crossed a
408
+ // code boundary every ~38 packages, ~18 codes typed by hand for 703
409
+ // packages. Fewer wall-clock seconds per code means fewer codes.
410
+ // The shared `RateLimitGate` is what makes raising this safe rather than
411
+ // merely faster-to-fail.
412
+ //
413
+ // A publish stays SERIAL. Publish order is a correctness property
414
+ // (docs/publishing.md: an aborted parallel sweep can leave a live bridge
415
+ // pinning a platform child that does not exist), and nothing about OTP
416
+ // pacing is worth trading that for.
213
417
  const trustBody = githubTrustBody({ repository, workflow, environment });
418
+ const writeConcurrency = Math.max(1, args['write-concurrency']);
419
+ const writeTotal = toPublish.length + toTrust.length;
420
+ // Results are written by INDEX rather than pushed, so a concurrent phase
421
+ // still reports in plan order — a summary whose order depends on which
422
+ // request happened to answer first is harder to diff between runs.
423
+ const indexed = Array.from({ length: plans.length });
214
424
  let failed = 0;
215
- for (const p of plans) {
216
- if (p.action === 'skip') {
217
- results.push({ name: p.ws.name, result: 'already-done' });
218
- continue;
219
- }
220
- if (p.action === 'blocked') {
221
- log(`→ ${p.ws.name}: cannot read trust state (HTTP ${p.httpStatus}) — skipped`);
222
- results.push({ name: p.ws.name, result: 'failed', detail: `state HTTP ${p.httpStatus}` });
223
- failed++;
224
- continue;
225
- }
226
- let publishedNow = false;
227
- if (p.action === 'publish-and-trust') {
228
- log(`→ ${p.ws.name}: building + publishing…`);
229
- try {
230
- await buildIfPresent(p.ws, log);
231
- }
232
- catch (err) {
233
- const msg = err instanceof Error ? err.message : String(err);
234
- log(` build failed: ${msg}`);
235
- results.push({ name: p.ws.name, result: 'failed', detail: `build: ${msg}` });
236
- failed++;
237
- continue;
238
- }
239
- const pub = await publishWorkspace({
240
- wsDir: p.ws.location,
241
- tag: 'latest',
242
- access: 'public',
243
- provenance: false,
244
- tolerate: true, // a concurrent/racey publish is harmless
245
- tolerateUntrustedNew: false,
246
- trustedFlag: undefined,
247
- registry: p.registry,
248
- verbose: false,
249
- json: false,
250
- otpProvider,
251
- seedOtpFirst: false,
252
- });
253
- if (!pub.ok) {
254
- const detail = describePublishFailure(pub);
255
- log(` publish failed: ${detail}`);
256
- results.push({ name: p.ws.name, result: 'failed', detail: `publish: ${detail}` });
257
- failed++;
258
- continue;
259
- }
260
- publishedNow = pub.action !== 'skipped-untrusted-new';
261
- log(` published ${pub.name}@${'version' in pub ? pub.version : ''}`);
262
- }
263
- // Configure the Trusted Publisher.
264
- const created = await trustRequest('POST', p.url, trustBody);
425
+ /** POST the Trusted Publisher config for one package and record the outcome. */
426
+ let written = 0;
427
+ let throttledWrites = 0;
428
+ const configureTrust = async (p, i, publishedNow) => {
429
+ // Through the rate-limit-aware wrapper: a 703-package sweep that gets
430
+ // throttled on its READS gets throttled on its WRITES too, and a bare
431
+ // 429 here would be recorded as `trust failed` — a failure attributed
432
+ // to the package rather than to the pacing.
433
+ const created = await requestWaitingOutRateLimit(trustRequest, 'POST', p.url, trustBody, pacing);
434
+ // `n/total` on every line, because the pace of a sweep is the thing a
435
+ // reader is judging while it runs — and the OTP prompt that
436
+ // interrupts it needs a nearby answer to "did the last code get
437
+ // anywhere?".
438
+ const at = `${++written}/${writeTotal}`;
265
439
  if (created.status >= 200 && created.status < 300) {
266
- log(` trusted ${p.ws.name}`);
267
- results.push({
268
- name: p.ws.name,
269
- result: publishedNow ? 'published+trusted' : 'trusted',
270
- });
440
+ log(` [${at}] trusted ${p.ws.name}`);
441
+ indexed[i] = { name: p.ws.name, result: publishedNow ? 'published+trusted' : 'trusted' };
271
442
  }
272
443
  else if (created.status === 409) {
273
- results.push({ name: p.ws.name, result: publishedNow ? 'published+trusted' : 'trusted' });
444
+ indexed[i] = { name: p.ws.name, result: publishedNow ? 'published+trusted' : 'trusted' };
274
445
  }
275
446
  else if (created.status === 404) {
276
447
  // Freshly-published package not yet provisioned for trust config.
277
- log(` ${p.ws.name}: published but not yet trust-configurable (re-run onboard)`);
278
- results.push({
448
+ log(` [${at}] ${p.ws.name}: published but not yet trust-configurable (re-run onboard)`);
449
+ indexed[i] = {
279
450
  name: p.ws.name,
280
451
  result: publishedNow ? 'published' : 'failed',
281
452
  detail: 'trust config deferred (404)',
282
- });
453
+ };
283
454
  if (!publishedNow)
284
455
  failed++;
285
456
  }
286
457
  else {
287
- log(` trust failed (HTTP ${created.status})`);
288
- results.push({
289
- name: p.ws.name,
290
- result: 'failed',
291
- detail: `trust HTTP ${created.status}`,
292
- });
458
+ log(` [${at}] trust failed (HTTP ${created.status}) — ${p.ws.name}`);
459
+ indexed[i] = { name: p.ws.name, result: 'failed', detail: `trust HTTP ${created.status}` };
460
+ if (created.status === 429)
461
+ throttledWrites++;
462
+ failed++;
463
+ }
464
+ };
465
+ // 4a. Bookkeeping-only rows, and the serial publish path.
466
+ const trustOnly = [];
467
+ for (let i = 0; i < plans.length; i++) {
468
+ const p = plans[i];
469
+ if (p.action === 'skip') {
470
+ indexed[i] = { name: p.ws.name, result: 'already-done' };
471
+ continue;
472
+ }
473
+ if (p.action === 'blocked') {
474
+ log(`→ ${p.ws.name}: cannot read trust state (HTTP ${p.httpStatus}) — skipped`);
475
+ indexed[i] = { name: p.ws.name, result: 'failed', detail: `state HTTP ${p.httpStatus}` };
476
+ failed++;
477
+ continue;
478
+ }
479
+ if (p.action === 'trust') {
480
+ trustOnly.push({ p, i });
481
+ continue;
482
+ }
483
+ // publish-and-trust — serial, see above.
484
+ log(`→ ${p.ws.name}: building + publishing…`);
485
+ try {
486
+ if (args.build !== false)
487
+ await buildIfPresent(p.ws, log);
488
+ }
489
+ catch (err) {
490
+ const msg = err instanceof Error ? err.message : String(err);
491
+ log(` build failed: ${msg}`);
492
+ indexed[i] = { name: p.ws.name, result: 'failed', detail: `build: ${msg}` };
293
493
  failed++;
494
+ continue;
294
495
  }
496
+ const pub = await publishWorkspace({
497
+ wsDir: p.ws.location,
498
+ tag: 'latest',
499
+ access: args.access,
500
+ provenance: false,
501
+ tolerate: true, // a concurrent/racey publish is harmless
502
+ tolerateUntrustedNew: false,
503
+ trustedFlag: undefined,
504
+ registry: p.registry,
505
+ verbose: false,
506
+ json: false,
507
+ otpProvider,
508
+ seedOtpFirst: false,
509
+ });
510
+ if (!pub.ok) {
511
+ const detail = describePublishFailure(pub);
512
+ log(` publish failed: ${detail}`);
513
+ indexed[i] = { name: p.ws.name, result: 'failed', detail: `publish: ${detail}` };
514
+ failed++;
515
+ continue;
516
+ }
517
+ log(` published ${pub.name}@${'version' in pub ? pub.version : ''}`);
518
+ await configureTrust(p, i, pub.action !== 'skipped-untrusted-new');
519
+ }
520
+ // 4b. The trust-only packages, concurrently.
521
+ if (trustOnly.length > 0) {
522
+ if (trustOnly.length > 1) {
523
+ log(`→ configuring ${trustOnly.length} Trusted Publisher(s), ${writeConcurrency} at a time…`);
524
+ }
525
+ await mapWithConcurrency(trustOnly, writeConcurrency, async ({ p, i }) => configureTrust(p, i, false));
526
+ }
527
+ for (const r of indexed)
528
+ if (r)
529
+ results.push(r);
530
+ // Name the cause at the END too, not only in the plan. A write is
531
+ // throttled minutes after the plan was printed and scrolled away, and
532
+ // "73 failed" on its own reads as 73 broken packages.
533
+ if (throttledWrites > 0) {
534
+ log();
535
+ log(`${throttledWrites} of the failures are npm rate limits (HTTP 429) that outlasted the wait, ` +
536
+ 'not a problem with those packages.');
537
+ log(' Re-run to pick them up — the sweep is idempotent and skips everything already configured. ' +
538
+ `A lower --write-concurrency (currently ${writeConcurrency}) provokes the limit less.`);
295
539
  }
296
- finish(results, plans, { asJson, dryRun, failed });
540
+ finish(results, plans, { asJson, dryRun, failed, root, sources, discovered: all.length });
297
541
  },
298
542
  };
299
543
  /** Print the summary + exit with the right code. */
@@ -303,6 +547,12 @@ function finish(results, plans, opts) {
303
547
  process.stdout.write(`${JSON.stringify({
304
548
  ok: failed === 0,
305
549
  dryRun: opts.dryRun,
550
+ // `root`/`sources`/`discovered` travel with the summary so a
551
+ // caller can check WHICH tree was swept, not just that a
552
+ // sweep reported itself finished.
553
+ root: opts.root,
554
+ sources: opts.sources,
555
+ discovered: opts.discovered,
306
556
  total: plans.length,
307
557
  published: results.filter((r) => r.result === 'published' || r.result === 'published+trusted')
308
558
  .length,
@@ -314,7 +564,10 @@ function finish(results, plans, opts) {
314
564
  }
315
565
  else {
316
566
  const line = opts.dryRun
317
- ? 'Dry-run complete — nothing changed.'
567
+ ? failed > 0
568
+ ? `Dry-run INCOMPLETE — nothing changed, but ${failed} package(s) had an unreadable state, ` +
569
+ 'so the plan above does not cover them. Authenticate (`gjsify login`) and re-run.'
570
+ : 'Dry-run complete — nothing changed.'
318
571
  : `Done: ${results.filter((r) => r.result.includes('published')).length} published, ` +
319
572
  `${results.filter((r) => r.result === 'trusted' || r.result === 'published+trusted').length} trusted, ` +
320
573
  `${results.filter((r) => r.result === 'already-done').length} already done, ${failed} failed.`;
@@ -1,5 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  interface ShipOptions {
3
+ os?: string;
3
4
  target?: string[];
4
5
  out?: string;
5
6
  stage: boolean;
@@ -7,6 +8,8 @@ interface ShipOptions {
7
8
  'expect-target'?: string;
8
9
  'skip-build': boolean;
9
10
  arch?: string;
11
+ sign?: string;
12
+ notarize?: string;
10
13
  verbose: boolean;
11
14
  }
12
15
  export declare const shipCommand: Command<unknown, ShipOptions>;