@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.
- package/dist/cli.gjs.mjs +453 -425
- package/lib/actions/build.js +2 -1
- package/lib/commands/affected-classify.js +7 -5
- package/lib/commands/build.js +6 -0
- package/lib/commands/flatpak/ci.js +10 -2
- package/lib/commands/flatpak/init.js +15 -16
- package/lib/commands/flatpak/utils.d.ts +0 -26
- package/lib/commands/flatpak/utils.js +4 -29
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +911 -70
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/config.js +25 -0
- package/lib/types/cli-build-options.d.ts +11 -0
- package/lib/types/config-data.d.ts +137 -3
- package/lib/utils/flatpak-runtime.d.ts +41 -0
- package/lib/utils/flatpak-runtime.js +54 -0
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +54 -7
- package/lib/utils/ship/depends.js +120 -6
- package/lib/utils/ship/discover.js +18 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak-config.d.ts +66 -0
- package/lib/utils/ship/flatpak-config.js +132 -0
- package/lib/utils/ship/flatpak.d.ts +45 -0
- package/lib/utils/ship/flatpak.js +148 -0
- package/lib/utils/ship/formats.d.ts +116 -3
- package/lib/utils/ship/formats.js +869 -11
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +362 -17
- package/lib/utils/ship/layout.d.ts +268 -0
- package/lib/utils/ship/layout.js +351 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +417 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +580 -14
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +20 -7
- package/lib/utils/ship/plist.d.ts +58 -0
- package/lib/utils/ship/plist.js +198 -0
- package/lib/utils/ship/rpm.js +9 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +19 -1
- package/lib/utils/ship/settings.js +19 -1
- package/lib/utils/ship/share-dirs.d.ts +23 -0
- package/lib/utils/ship/share-dirs.js +46 -0
- package/lib/utils/ship/signing.d.ts +195 -0
- package/lib/utils/ship/signing.js +317 -0
- package/lib/utils/ship/stage-manifest.d.ts +50 -13
- package/lib/utils/ship/stage-manifest.js +137 -14
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +71 -1
- package/lib/utils/ship/types.d.ts +264 -16
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/trust-registry.js +5 -0
- package/package.json +19 -19
package/lib/utils/npm-otp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|