@gjsify/cli 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -14,6 +14,15 @@
14
14
  import { promptLine } from './prompt.js';
15
15
  import { clearCachedOtp, readCachedOtp, writeCachedOtp } from './npm-otp-cache.js';
16
16
  const DEFAULT_QUESTION = 'This operation requires a one-time password.\nEnter OTP: ';
17
+ /**
18
+ * The question for every prompt AFTER the first one in a run.
19
+ *
20
+ * A long sweep outlives a TOTP window (npm codes last ~30 s), so it legitimately
21
+ * asks again — but repeating the first-time wording makes that read as "it did
22
+ * not take my code" rather than "that code has aged out". The user then retypes
23
+ * the SAME digits, which is the one answer guaranteed not to work.
24
+ */
25
+ const RENEW_QUESTION = 'The previous one-time password is no longer valid (npm codes last about 30 seconds).\nEnter a NEW OTP: ';
17
26
  /**
18
27
  * True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
19
28
  * header naming `otp`, or a body mentioning a one-time password / `EOTP`).
@@ -44,6 +53,23 @@ export class OtpProvider {
44
53
  cached;
45
54
  promptFn;
46
55
  registry;
56
+ /**
57
+ * Bumped every time a NEW code becomes current. Concurrent callers compare the
58
+ * epoch they last saw against this one to tell "my code was rejected" from
59
+ * "somebody already replaced it while I was waiting" — the second needs no
60
+ * prompt, it needs the other one's code.
61
+ */
62
+ generation = 0;
63
+ /**
64
+ * The prompt currently on screen, if any. THE fix for a sweep that probes with
65
+ * concurrency > 1: `promptLine` puts the terminal in RAW mode and echoes each
66
+ * keystroke by hand (utils/prompt.ts), so two live prompts mean two `data`
67
+ * listeners on the same stdin and every digit is echoed TWICE. Measured on a
68
+ * real 703-package sweep at the default concurrency of 4: five prompts stacked
69
+ * up and the typed code came back as `666644440000999944444444` — four echoes
70
+ * per keystroke. Whoever arrives second awaits this promise instead.
71
+ */
72
+ pending;
47
73
  /**
48
74
  * @param seed Initial cached code (from `--otp`), or undefined.
49
75
  * @param promptFn How to read a fresh code. Defaults to the TTY-aware
@@ -71,14 +97,46 @@ export class OtpProvider {
71
97
  current() {
72
98
  return this.cached;
73
99
  }
100
+ /**
101
+ * How many times a new code has become current. Capture it before an attempt
102
+ * and hand it back to {@link refresh} / {@link invalidate} so a concurrent
103
+ * caller neither prompts for a code somebody already typed nor throws that
104
+ * code away on its own stale rejection.
105
+ */
106
+ epoch() {
107
+ return this.generation;
108
+ }
74
109
  /**
75
110
  * Prompt for a fresh code, cache it (in-process + file cache), and return the
76
111
  * trimmed input — `''` when nothing was entered, which callers read as "give up".
77
112
  */
78
- async refresh(question = DEFAULT_QUESTION) {
113
+ async refresh(question, sinceEpoch) {
114
+ // Somebody supplied a newer code than this caller last saw. Its own attempt
115
+ // failed against the OLD code, so the answer is that code, not a prompt.
116
+ if (sinceEpoch !== undefined && this.generation > sinceEpoch && this.cached) {
117
+ return this.cached;
118
+ }
119
+ // Single-flight: one prompt at a time, ever. Two raw-mode prompts on one
120
+ // stdin double every echoed keystroke, and the user is being asked twice
121
+ // for a code that is shared anyway.
122
+ if (this.pending)
123
+ return this.pending;
124
+ // The provider is the only thing that knows whether a code has already
125
+ // been supplied in this run, so it owns the wording. An explicit
126
+ // `question` from the caller always wins.
127
+ this.pending = this.promptOnce(question ?? (this.generation > 0 ? RENEW_QUESTION : DEFAULT_QUESTION));
128
+ try {
129
+ return await this.pending;
130
+ }
131
+ finally {
132
+ this.pending = undefined;
133
+ }
134
+ }
135
+ async promptOnce(question) {
79
136
  const code = (await this.promptFn(question)).trim();
80
137
  if (code) {
81
138
  this.cached = code;
139
+ this.generation++;
82
140
  if (this.registry)
83
141
  writeCachedOtp(this.registry, code);
84
142
  }
@@ -89,7 +147,13 @@ export class OtpProvider {
89
147
  * rejects it, so the NEXT command re-prompts instead of replaying a single-use
90
148
  * code the registry already burned.
91
149
  */
92
- invalidate() {
150
+ invalidate(sinceEpoch) {
151
+ // Only the caller whose own code was rejected may clear it. Without this
152
+ // guard the first rejection in a concurrent burst wipes a code another
153
+ // worker just typed, and every remaining worker prompts again — the same
154
+ // stacked-prompt pile-up from the other direction.
155
+ if (sinceEpoch !== undefined && this.generation !== sinceEpoch)
156
+ return;
93
157
  this.cached = undefined;
94
158
  if (this.registry)
95
159
  clearCachedOtp(this.registry);
@@ -107,34 +171,45 @@ export class OtpProvider {
107
171
  */
108
172
  export async function withOtpRetry(doFetch, provider, opts = {}) {
109
173
  const maxPrompts = opts.maxPrompts ?? 2;
110
- const question = opts.question ?? DEFAULT_QUESTION;
174
+ // Deliberately NOT defaulted here: `undefined` is what lets the provider pick
175
+ // between the first-time wording and the "your previous code aged out" one.
176
+ const question = opts.question;
111
177
  const cached = provider.current();
112
178
  const seededFirst = opts.seedFirstAttempt === true && cached !== undefined;
179
+ // Every invalidate/refresh below is scoped to the epoch of the code THIS call
180
+ // actually tried. Concurrent callers share the provider, so an unscoped
181
+ // `invalidate()` discards a code a sibling typed a millisecond ago and sends
182
+ // everyone back to the prompt.
183
+ let epoch = provider.epoch();
113
184
  let res = await doFetch(seededFirst ? cached : undefined);
114
185
  if (!(await isOtpChallenge(res)))
115
186
  return res;
116
187
  // The seeded code was rejected/consumed — drop it so it is not replayed and the
117
188
  // cross-invocation cache entry is cleared.
118
189
  if (seededFirst)
119
- provider.invalidate();
190
+ provider.invalidate(epoch);
120
191
  if (!seededFirst && provider.current() !== undefined) {
192
+ epoch = provider.epoch();
121
193
  res = await doFetch(provider.current());
122
194
  if (!(await isOtpChallenge(res)))
123
195
  return res;
124
196
  // Stale seed, or a sibling command already burned the file-cached single-use
125
197
  // code.
126
- provider.invalidate();
198
+ provider.invalidate(epoch);
127
199
  }
128
200
  for (let i = 0; i < maxPrompts; i++) {
129
- const fresh = await provider.refresh(question);
201
+ // `refresh` with the epoch: if another concurrent caller has already typed a
202
+ // newer code, this returns THAT code and shows no prompt.
203
+ const fresh = await provider.refresh(question, epoch);
130
204
  if (!fresh)
131
205
  return res; // no input — hand the challenge back to the caller
206
+ epoch = provider.epoch();
132
207
  res = await doFetch(fresh);
133
208
  if (!(await isOtpChallenge(res)))
134
209
  return res;
135
210
  // Clear a rejected fresh code before re-prompting, so a wedged sequence never
136
211
  // leaves a burned code in the file cache.
137
- provider.invalidate();
212
+ provider.invalidate(epoch);
138
213
  }
139
214
  return res;
140
215
  }
@@ -0,0 +1,68 @@
1
+ import { type Workspace } from '@gjsify/workspace';
2
+ /** One enumeration source and how much it contributed. */
3
+ export interface DiscoverySource {
4
+ /** `workspaces` (root manifest globs) | `packages` (--packages globs) | `scanned-dirs`. */
5
+ kind: 'workspaces' | 'packages' | 'scanned-dirs';
6
+ /** The globs this source used, when it had any. */
7
+ patterns?: readonly string[];
8
+ /** How many packages this source contributed AFTER dedupe against earlier sources. */
9
+ count: number;
10
+ }
11
+ export interface PackageSet {
12
+ packages: Workspace[];
13
+ sources: DiscoverySource[];
14
+ }
15
+ /**
16
+ * The directory the globs and the workspace manifest are resolved against.
17
+ *
18
+ * A workspace root wins, because that is what `discoverWorkspaces` needs. When
19
+ * there is none, the GIT top level is the answer and the cwd is only the last
20
+ * resort: `--packages '*'` run from anywhere inside a non-workspace monorepo
21
+ * must mean the same set every time, and resolving it against the cwd would
22
+ * quietly mean "the packages below wherever I happen to stand".
23
+ */
24
+ export declare function resolveRepoRoot(cwd: string): string;
25
+ /**
26
+ * Every `--packages` glob must match at least one directory holding a
27
+ * `package.json` with a name. Throws with the unmatched patterns otherwise.
28
+ *
29
+ * Mirrors `gjsify foreach`'s `--include` rule, and matters more here: foreach's
30
+ * empty selection runs no scripts, while onboard's empty selection is a sweep
31
+ * that reports itself complete having examined nothing.
32
+ */
33
+ export declare function assertEveryPatternMatches(root: string, patterns: readonly string[]): void;
34
+ /**
35
+ * Collect the publishable package set from every source that applies.
36
+ *
37
+ * Order is precedence: a real workspace beats a `--packages` hit beats a scanned
38
+ * directory, deduped by package NAME, so the manifest a build would use is the
39
+ * manifest the sweep publishes.
40
+ */
41
+ export declare function collectOnboardPackages(root: string, cwd: string, options?: {
42
+ patterns?: readonly string[];
43
+ }): PackageSet;
44
+ /** One-line rendering of where the package set came from. */
45
+ export declare function describeSources(sources: readonly DiscoverySource[]): string;
46
+ /** The `owner/repo` a package's own manifest claims, or null if it claims none. */
47
+ export declare function declaredRepository(ws: Workspace): string | null;
48
+ /**
49
+ * Refuse to configure a Trusted Publisher for a package whose OWN manifest says
50
+ * it lives in a different repository.
51
+ *
52
+ * This replaces a hardcoded `exclude: ['@girs/*']`, and it is worth more than
53
+ * the thing it replaces. Measured: `gjsify onboard` in `gjsify/ts-for-gir`
54
+ * selects 716 packages, ~703 of them the generated `@girs/*` under
55
+ * `types-dev/*` — which are workspaces there, but are PUBLISHED FROM
56
+ * `gjsify/types`. Trusting them for `gjsify/ts-for-gir`'s `release.yml` would
57
+ * point their OIDC exchange at a workflow that never publishes them, i.e. break
58
+ * the release the sweep exists to protect.
59
+ *
60
+ * A name pattern only ever covered the one case somebody had already been bitten
61
+ * by. The manifest's `repository` is the package's own claim about where it
62
+ * comes from, so this covers every monorepo that has another project's packages
63
+ * inside it — vendored, submoduled, or generated.
64
+ *
65
+ * Silence is the only safe default in the other direction: a package that
66
+ * declares NO repository is not evidence of a mismatch, so it passes.
67
+ */
68
+ export declare function assertRepositoryAgreement(selected: readonly Workspace[], repository: string): void;
@@ -0,0 +1,178 @@
1
+ // Package-set discovery for `gjsify onboard`, and the ONE thing that decides
2
+ // whether the command is a gjsify-repo chore or a monorepo tool.
3
+ //
4
+ // Onboard used to enumerate exactly one way: `discoverWorkspaces` over the root
5
+ // manifest's `workspaces` globs, plus this repo's two carved-out publishable
6
+ // directories. That set is correct HERE and empty in the repo the sweep is most
7
+ // needed for — `gjsify/types` publishes 703 `@girs/*` packages from a root whose
8
+ // only tracked file is `.gitignore`. There is no manifest, so there are no
9
+ // globs, so `discoverWorkspaces` throws before anything is enumerated.
10
+ //
11
+ // So the package set is now built from up to three SOURCES, and every one of
12
+ // them is REPORTED with its count. That reporting is not decoration: a sweep
13
+ // that writes to npm derives its whole blast radius from this list, and the two
14
+ // ways it goes wrong — enumerating nothing, and enumerating the wrong tree —
15
+ // look identical in a summary that only prints a total. `--packages` matching
16
+ // no directory is a HARD ERROR for the same reason `gjsify foreach --include`
17
+ // is: a filter that selects nothing is a typo or a shell-quoting bug, never an
18
+ // intent (and onboard's own history has the sharper version of this — it once
19
+ // reported "127 already done" while `@gjsify/napi` was simply absent from the
20
+ // list it had built).
21
+ import { existsSync } from 'node:fs';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { discoverWorkspaces } from '@gjsify/workspace';
24
+ import { join } from 'node:path';
25
+ import { mergePublishables } from './publishable-packages.js';
26
+ import { parseRepoFromGitRemote } from './trust-registry.js';
27
+ import { findWorkspaceRoot } from './workspace-root.js';
28
+ /**
29
+ * The directory the globs and the workspace manifest are resolved against.
30
+ *
31
+ * A workspace root wins, because that is what `discoverWorkspaces` needs. When
32
+ * there is none, the GIT top level is the answer and the cwd is only the last
33
+ * resort: `--packages '*'` run from anywhere inside a non-workspace monorepo
34
+ * must mean the same set every time, and resolving it against the cwd would
35
+ * quietly mean "the packages below wherever I happen to stand".
36
+ */
37
+ export function resolveRepoRoot(cwd) {
38
+ const wsRoot = findWorkspaceRoot(cwd);
39
+ if (wsRoot)
40
+ return wsRoot;
41
+ const git = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
42
+ if (git.status === 0) {
43
+ const top = git.stdout.trim();
44
+ if (top && existsSync(top))
45
+ return top;
46
+ }
47
+ return cwd;
48
+ }
49
+ /**
50
+ * Every `--packages` glob must match at least one directory holding a
51
+ * `package.json` with a name. Throws with the unmatched patterns otherwise.
52
+ *
53
+ * Mirrors `gjsify foreach`'s `--include` rule, and matters more here: foreach's
54
+ * empty selection runs no scripts, while onboard's empty selection is a sweep
55
+ * that reports itself complete having examined nothing.
56
+ */
57
+ export function assertEveryPatternMatches(root, patterns) {
58
+ const unmatched = patterns.filter((pattern) => {
59
+ try {
60
+ return discoverWorkspaces(root, { patterns: [pattern] }).length === 0;
61
+ }
62
+ catch {
63
+ return true;
64
+ }
65
+ });
66
+ if (unmatched.length === 0)
67
+ return;
68
+ const lines = [
69
+ `gjsify onboard: --packages matched no package directory (${unmatched.length} of ${patterns.length} pattern(s)) under ${root}:`,
70
+ ];
71
+ for (const pattern of unmatched)
72
+ lines.push(` ${JSON.stringify(pattern)}`);
73
+ lines.push(' Patterns are DIRECTORY globs resolved against the repo root, and only `*` is supported', ' (one path segment). A matched directory needs a package.json with a "name".');
74
+ throw new Error(lines.join('\n'));
75
+ }
76
+ /**
77
+ * Collect the publishable package set from every source that applies.
78
+ *
79
+ * Order is precedence: a real workspace beats a `--packages` hit beats a scanned
80
+ * directory, deduped by package NAME, so the manifest a build would use is the
81
+ * manifest the sweep publishes.
82
+ */
83
+ export function collectOnboardPackages(root, cwd, options = {}) {
84
+ const sources = [];
85
+ const packages = [];
86
+ const seen = new Set();
87
+ const take = (kind, found, patterns) => {
88
+ let added = 0;
89
+ for (const ws of found) {
90
+ if (!ws.name || seen.has(ws.name))
91
+ continue;
92
+ seen.add(ws.name);
93
+ packages.push(ws);
94
+ added++;
95
+ }
96
+ sources.push({ kind, patterns, count: added });
97
+ };
98
+ // 1. The root manifest's own `workspaces` globs, when the repo has them.
99
+ if (existsSync(join(root, 'package.json'))) {
100
+ try {
101
+ take('workspaces', discoverWorkspaces(root, { includeRoot: true }));
102
+ }
103
+ catch {
104
+ // A root package.json without `workspaces` is not an error here —
105
+ // `--packages` or the scanned dirs may still carry the whole set.
106
+ }
107
+ }
108
+ // 2. Explicit `--packages` globs — the path for a monorepo that is not an
109
+ // npm/yarn workspace. Callers run `assertEveryPatternMatches` first, so
110
+ // an empty result here can only mean "everything was already covered".
111
+ if (options.patterns && options.patterns.length > 0) {
112
+ take('packages', discoverWorkspaces(root, { patterns: [...options.patterns] }), options.patterns);
113
+ }
114
+ // 3. This repo's publishable-but-not-workspace directories. A no-op in any
115
+ // repo that does not have them; see publishable-packages.ts for the
116
+ // incident that put it here.
117
+ take('scanned-dirs', mergePublishables([], cwd));
118
+ return { packages, sources };
119
+ }
120
+ /** One-line rendering of where the package set came from. */
121
+ export function describeSources(sources) {
122
+ return sources
123
+ .filter((s) => s.count > 0)
124
+ .map((s) => (s.patterns ? `${s.kind}(${s.patterns.join(',')})=${s.count}` : `${s.kind}=${s.count}`))
125
+ .join(' + ');
126
+ }
127
+ /** The `owner/repo` a package's own manifest claims, or null if it claims none. */
128
+ export function declaredRepository(ws) {
129
+ const repo = ws.manifest.repository;
130
+ const url = typeof repo === 'string' ? repo : repo?.url;
131
+ if (typeof url !== 'string' || url.length === 0)
132
+ return null;
133
+ return parseRepoFromGitRemote(url);
134
+ }
135
+ /**
136
+ * Refuse to configure a Trusted Publisher for a package whose OWN manifest says
137
+ * it lives in a different repository.
138
+ *
139
+ * This replaces a hardcoded `exclude: ['@girs/*']`, and it is worth more than
140
+ * the thing it replaces. Measured: `gjsify onboard` in `gjsify/ts-for-gir`
141
+ * selects 716 packages, ~703 of them the generated `@girs/*` under
142
+ * `types-dev/*` — which are workspaces there, but are PUBLISHED FROM
143
+ * `gjsify/types`. Trusting them for `gjsify/ts-for-gir`'s `release.yml` would
144
+ * point their OIDC exchange at a workflow that never publishes them, i.e. break
145
+ * the release the sweep exists to protect.
146
+ *
147
+ * A name pattern only ever covered the one case somebody had already been bitten
148
+ * by. The manifest's `repository` is the package's own claim about where it
149
+ * comes from, so this covers every monorepo that has another project's packages
150
+ * inside it — vendored, submoduled, or generated.
151
+ *
152
+ * Silence is the only safe default in the other direction: a package that
153
+ * declares NO repository is not evidence of a mismatch, so it passes.
154
+ */
155
+ export function assertRepositoryAgreement(selected, repository) {
156
+ const target = repository.toLowerCase();
157
+ const foreign = selected
158
+ .map((ws) => ({ ws, declared: declaredRepository(ws) }))
159
+ .filter((e) => e.declared !== null && e.declared.toLowerCase() !== target);
160
+ if (foreign.length === 0)
161
+ return;
162
+ const byRepo = new Map();
163
+ for (const { ws, declared } of foreign) {
164
+ const list = byRepo.get(declared) ?? [];
165
+ list.push(ws.name);
166
+ byRepo.set(declared, list);
167
+ }
168
+ const lines = [
169
+ `gjsify onboard: ${foreign.length} selected package(s) declare a DIFFERENT repository than ${repository}:`,
170
+ ];
171
+ for (const [repo, names] of byRepo) {
172
+ const shown = names.slice(0, 5).join(', ');
173
+ const more = names.length > 5 ? `, … (+${names.length - 5} more)` : '';
174
+ lines.push(` ${repo}: ${names.length} package(s) — ${shown}${more}`);
175
+ }
176
+ lines.push(" A Trusted Publisher scoped to the wrong repository points that package's OIDC exchange", ' at a workflow that does not publish it, which breaks the release this sweep protects.', ' Either exclude them (--exclude), select only what belongs here (--include), or point', ' --repository at the repo that actually publishes them.');
177
+ throw new Error(lines.join('\n'));
178
+ }
@@ -1,6 +1,6 @@
1
1
  import { type NpmrcConfig } from '@gjsify/npm-registry';
2
2
  import type { Workspace } from '@gjsify/workspace';
3
- import type { TrustRequester } from '../commands/trust.js';
3
+ import type { TrustRequester, TrustRequestResult } from '../commands/trust.js';
4
4
  import { type TrustState } from './trust-registry.js';
5
5
  /** What a package needs, computed from its published + trust state. */
6
6
  export type PkgAction = 'skip' | 'trust' | 'publish-and-trust' | 'blocked';
@@ -24,6 +24,38 @@ export interface ProbeOptions {
24
24
  retryOn401?: boolean;
25
25
  /** Backoff before the single 401 retry. Default {@link PROBE_RETRY_DELAY_MS}. */
26
26
  retryDelayMs?: number;
27
+ /** Total time to spend waiting out HTTP 429s. Default {@link RATE_LIMIT_MAX_WAIT_MS}. */
28
+ maxRateLimitWaitMs?: number;
29
+ /** First backoff for a 429; doubles per attempt. Default {@link RATE_LIMIT_BASE_DELAY_MS}. */
30
+ rateLimitBaseDelayMs?: number;
31
+ /**
32
+ * Cool-down shared across the sweep. Pass ONE instance to every call so a
33
+ * 429 anywhere holds back everywhere; omit for an isolated request.
34
+ */
35
+ gate?: RateLimitGate;
36
+ /**
37
+ * Called on every 429 with what npm actually sent and how long we will wait.
38
+ * The caller decides how loud to be; reporting the FIRST one is enough to
39
+ * turn "we got throttled" into "npm asked for N seconds" (or into "npm said
40
+ * nothing, so this delay is ours").
41
+ */
42
+ onRateLimit?: (info: {
43
+ url: string;
44
+ headers: string;
45
+ waitMs: number;
46
+ fromRetryAfter: boolean;
47
+ }) => void;
48
+ /**
49
+ * Called after each state read with what is known SO FAR.
50
+ *
51
+ * The probe phase is the silent half of a sweep: 703 packages take minutes
52
+ * and, until this existed, printed nothing at all between the header and the
53
+ * plan. The only output in that window was the occasional 2FA prompt, which
54
+ * makes a working sweep and a wedged one look identical — and the prompt is
55
+ * the moment a user most needs to know the previous code accomplished
56
+ * something.
57
+ */
58
+ onProgress?: (done: number, total: number, plan: PkgPlan) => void;
27
59
  /** Injected delay (tests pass a no-op). Default: real `setTimeout`. */
28
60
  sleep?: (ms: number) => Promise<void>;
29
61
  /**
@@ -33,6 +65,50 @@ export interface ProbeOptions {
33
65
  exists?: (registry: string, name: string) => Promise<boolean | null>;
34
66
  }
35
67
  export declare const PROBE_RETRY_DELAY_MS = 400;
68
+ /**
69
+ * A 429 is npm asking to be asked later — never an answer about the package.
70
+ *
71
+ * Measured on the real 703-package sweep of `gjsify/types` at the default
72
+ * concurrency: the first 662 reads answered and the LAST 41 came back `429`, so
73
+ * the plan said `41 unreadable` for packages whose state nobody had actually
74
+ * failed to determine. The tail of an alphabetical list is exactly where a
75
+ * cumulative rate limit lands, which makes the damage look like a property of
76
+ * those packages.
77
+ *
78
+ * Waiting is the whole fix, and it has to be bounded: an unbounded wait turns a
79
+ * throttled sweep into a hang with no output.
80
+ */
81
+ /**
82
+ * A TIME budget, not an attempt count — and generous, because npm tells us
83
+ * nothing.
84
+ *
85
+ * The attempt count was the wrong unit and the real sweep proved it: 4 attempts
86
+ * with a doubling 2s base is 30 seconds of patience, npm's window is longer than
87
+ * that, so every in-flight request spent its budget inside one cooldown and
88
+ * `Done: 502 trusted … 73 failed` — all 73 at the tail, all 429, none of them a
89
+ * fact about those packages.
90
+ *
91
+ * With no `Retry-After` and no rate-limit headers to read (measured), waiting is
92
+ * the only instrument there is. Five minutes per request is worth it: the
93
+ * alternative is failing packages that then need another sweep, which is itself
94
+ * throttled. Bounded all the same, so a throttled sweep still ENDS.
95
+ */
96
+ export declare const RATE_LIMIT_MAX_WAIT_MS = 300000;
97
+ export declare const RATE_LIMIT_BASE_DELAY_MS = 2000;
98
+ /** Ceiling for a single backoff step, so escalation stays legible. */
99
+ export declare const RATE_LIMIT_MAX_STEP_MS = 60000;
100
+ /**
101
+ * What npm told us about pacing on a 429 — verbatim, so the answer to "how fast
102
+ * may we go" comes from the registry rather than from a guess.
103
+ *
104
+ * Measured 2026-08-28: npm serves NO `X-RateLimit-*` headers on ordinary
105
+ * responses, so there is no budget to read ahead of time. `Retry-After` on the
106
+ * 429 itself is the only channel, and whether npm populates it on the trust
107
+ * endpoint is exactly what this reports the first time it happens.
108
+ */
109
+ export declare function describeRateLimitHeaders(headers: Headers | undefined): string;
110
+ /** Seconds to wait per npm's `Retry-After`, or null when it said nothing usable. */
111
+ export declare function retryAfterMs(headers: Headers | undefined): number | null;
36
112
  /**
37
113
  * Does the registry serve a packument for this name?
38
114
  *
@@ -55,6 +131,47 @@ export declare function packumentExists(registry: string, name: string): Promise
55
131
  export declare const DEFAULT_PROBE_CONCURRENCY = 4;
56
132
  /** Map a classified trust state to the action the onboard sweep should take. */
57
133
  export declare function actionForState(state: TrustState): PkgAction;
134
+ /**
135
+ * A cool-down shared by every in-flight request of a sweep.
136
+ *
137
+ * Retrying ONE throttled request in isolation is not enough, and the measured
138
+ * run shows why: at serial pace `gjsify/types` was already being 429'd, so every
139
+ * other worker kept hammering the registry through the very window the retry was
140
+ * waiting out — and the retry budget then expired against a limit nobody had
141
+ * stopped provoking. `trust failed (HTTP 429)` was the result.
142
+ *
143
+ * A 429 anywhere therefore pauses EVERYWHERE. That also makes concurrency safe
144
+ * to raise: the sweep self-paces down to whatever npm is willing to serve
145
+ * instead of the operator guessing a number.
146
+ */
147
+ export declare class RateLimitGate {
148
+ private until;
149
+ private readonly now;
150
+ private readonly sleeper;
151
+ constructor(opts?: {
152
+ now?: () => number;
153
+ sleep?: (ms: number) => Promise<void>;
154
+ });
155
+ /** Block while a cool-down is in effect. */
156
+ wait(): Promise<void>;
157
+ /** Hold every request back for at least `ms` from now. */
158
+ penalize(ms: number): void;
159
+ /** Milliseconds still owed, for tests and for reporting. */
160
+ remainingMs(): number;
161
+ }
162
+ /**
163
+ * Issue a request, waiting out an HTTP 429 rather than reporting it as a state.
164
+ *
165
+ * Used by BOTH the probe GET and the act-phase trust POST, because a sweep long
166
+ * enough to be throttled on reads is long enough to be throttled on writes, and
167
+ * a throttled POST would otherwise be recorded as `trust failed (HTTP 429)` —
168
+ * a failure attributed to the package instead of to the pacing.
169
+ *
170
+ * Bounded on purpose: it returns the last 429 once the budget is spent, so the
171
+ * caller reports "unreadable" rather than hanging. `Retry-After` wins over the
172
+ * exponential backoff whenever npm sends a usable one.
173
+ */
174
+ export declare function requestWaitingOutRateLimit(request: TrustRequester, method: string, url: string, body?: unknown, opts?: ProbeOptions): Promise<TrustRequestResult>;
58
175
  /**
59
176
  * Read one package's published + trust state through the SHARED trust requester.
60
177
  * A surviving `auth-required` means the 401 was not an answerable OTP challenge —