@gjsify/cli 0.19.0 → 0.21.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 +19 -19
- package/lib/commands/login.js +15 -2
- package/lib/commands/logout.js +4 -0
- package/lib/commands/onboard.js +32 -75
- package/lib/commands/publish.js +17 -1
- package/lib/commands/trust.js +68 -4
- package/lib/utils/npm-otp-cache.d.ts +31 -0
- package/lib/utils/npm-otp-cache.js +142 -0
- package/lib/utils/npm-otp.d.ts +31 -7
- package/lib/utils/npm-otp.js +51 -9
- package/lib/utils/onboard-probe.d.ts +55 -0
- package/lib/utils/onboard-probe.js +90 -0
- package/package.json +17 -17
package/lib/utils/npm-otp.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// Standalone `publish`/`trust` keep their existing single-command UX: each
|
|
20
20
|
// still constructs its own provider and prompts on demand.
|
|
21
21
|
import { promptLine } from './prompt.js';
|
|
22
|
+
import { clearCachedOtp, readCachedOtp, writeCachedOtp } from './npm-otp-cache.js';
|
|
22
23
|
const DEFAULT_QUESTION = 'This operation requires a one-time password.\nEnter OTP: ';
|
|
23
24
|
/**
|
|
24
25
|
* True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
|
|
@@ -40,38 +41,69 @@ export async function isOtpChallenge(res) {
|
|
|
40
41
|
/**
|
|
41
42
|
* Holds ONE OTP code across a run and knows how to prompt for a fresh one.
|
|
42
43
|
*
|
|
43
|
-
* `current()` returns the cached code (seeded from `--otp`,
|
|
44
|
-
* user typed). `refresh()` prompts for a new
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* `current()` returns the cached code (seeded from `--otp`, the cross-invocation
|
|
45
|
+
* file cache, or the last one the user typed). `refresh()` prompts for a new
|
|
46
|
+
* code and caches it (in-process AND — when a `registry` is set — in the
|
|
47
|
+
* short-lived file cache). A single provider instance shared across many
|
|
48
|
+
* publish + trust operations is what makes `gjsify onboard` need only one OTP
|
|
49
|
+
* entry for the whole sweep; the file cache extends that reuse across SEPARATE
|
|
50
|
+
* `gjsify` invocations (`login → onboard → publish → trust`).
|
|
47
51
|
*/
|
|
48
52
|
export class OtpProvider {
|
|
49
53
|
cached;
|
|
50
54
|
promptFn;
|
|
55
|
+
registry;
|
|
51
56
|
/**
|
|
52
57
|
* @param seed Initial cached code (from `--otp`), or undefined.
|
|
53
58
|
* @param promptFn How to read a fresh code. Defaults to the TTY-aware
|
|
54
59
|
* `promptLine`. Non-interactive callers pass a function that
|
|
55
60
|
* returns `''` so a challenge surfaces instead of hanging.
|
|
61
|
+
* @param options Optional cross-invocation file-cache scoping (`registry`).
|
|
56
62
|
*/
|
|
57
|
-
constructor(seed, promptFn = promptLine) {
|
|
58
|
-
this.cached = seed && seed.length > 0 ? seed : undefined;
|
|
63
|
+
constructor(seed, promptFn = promptLine, options = {}) {
|
|
59
64
|
this.promptFn = promptFn;
|
|
65
|
+
this.registry = options.registry;
|
|
66
|
+
const explicit = seed && seed.length > 0 ? seed : undefined;
|
|
67
|
+
if (explicit) {
|
|
68
|
+
this.cached = explicit;
|
|
69
|
+
// An explicit --otp seeds the cross-invocation cache too, so a
|
|
70
|
+
// sibling command in the same window reuses it without a prompt.
|
|
71
|
+
if (this.registry)
|
|
72
|
+
writeCachedOtp(this.registry, explicit);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// No explicit code — fall back to the short-lived file cache.
|
|
76
|
+
this.cached = this.registry ? readCachedOtp(this.registry) : undefined;
|
|
77
|
+
}
|
|
60
78
|
}
|
|
61
79
|
/** The currently cached OTP code, if any. */
|
|
62
80
|
current() {
|
|
63
81
|
return this.cached;
|
|
64
82
|
}
|
|
65
83
|
/**
|
|
66
|
-
* Prompt for a fresh code, cache it
|
|
67
|
-
* `''` when nothing was entered — the caller
|
|
84
|
+
* Prompt for a fresh code, cache it (in-process + file cache), and return
|
|
85
|
+
* it (the trimmed input, or `''` when nothing was entered — the caller
|
|
86
|
+
* treats empty as "give up").
|
|
68
87
|
*/
|
|
69
88
|
async refresh(question = DEFAULT_QUESTION) {
|
|
70
89
|
const code = (await this.promptFn(question)).trim();
|
|
71
|
-
if (code)
|
|
90
|
+
if (code) {
|
|
72
91
|
this.cached = code;
|
|
92
|
+
if (this.registry)
|
|
93
|
+
writeCachedOtp(this.registry, code);
|
|
94
|
+
}
|
|
73
95
|
return code;
|
|
74
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Drop the cached code (in-process AND the cross-invocation file entry)
|
|
99
|
+
* after npm rejects it as invalid/consumed — so the NEXT command re-prompts
|
|
100
|
+
* instead of replaying a single-use code that the registry already burned.
|
|
101
|
+
*/
|
|
102
|
+
invalidate() {
|
|
103
|
+
this.cached = undefined;
|
|
104
|
+
if (this.registry)
|
|
105
|
+
clearCachedOtp(this.registry);
|
|
106
|
+
}
|
|
75
107
|
}
|
|
76
108
|
/**
|
|
77
109
|
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}.
|
|
@@ -98,11 +130,18 @@ export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
|
98
130
|
let res = await doFetch(seededFirst ? cached : undefined);
|
|
99
131
|
if (!(await isOtpChallenge(res)))
|
|
100
132
|
return res;
|
|
133
|
+
// The seeded (`--otp`/file-cached) code was rejected/consumed — drop it so
|
|
134
|
+
// it is not replayed and the cross-invocation cache entry is cleared.
|
|
135
|
+
if (seededFirst)
|
|
136
|
+
provider.invalidate();
|
|
101
137
|
// Try the cached code if we haven't already sent it.
|
|
102
138
|
if (!seededFirst && provider.current() !== undefined) {
|
|
103
139
|
res = await doFetch(provider.current());
|
|
104
140
|
if (!(await isOtpChallenge(res)))
|
|
105
141
|
return res;
|
|
142
|
+
// Cached code rejected/consumed (stale seed, or a sibling command
|
|
143
|
+
// already burned the file-cached single-use code) → invalidate.
|
|
144
|
+
provider.invalidate();
|
|
106
145
|
}
|
|
107
146
|
// Cache empty, rejected, or expired → prompt for a fresh code and retry.
|
|
108
147
|
for (let i = 0; i < maxPrompts; i++) {
|
|
@@ -112,6 +151,9 @@ export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
|
112
151
|
res = await doFetch(fresh);
|
|
113
152
|
if (!(await isOtpChallenge(res)))
|
|
114
153
|
return res;
|
|
154
|
+
// The freshly-typed code was also rejected — clear it before re-prompting
|
|
155
|
+
// so a wedged sequence never leaves a burned code in the file cache.
|
|
156
|
+
provider.invalidate();
|
|
115
157
|
}
|
|
116
158
|
return res;
|
|
117
159
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type NpmrcConfig } from '@gjsify/npm-registry';
|
|
2
|
+
import type { Workspace } from '@gjsify/workspace';
|
|
3
|
+
import type { TrustRequester } from '../commands/trust.js';
|
|
4
|
+
import { type TrustState } from './trust-registry.js';
|
|
5
|
+
/** What a package needs, computed from its published + trust state. */
|
|
6
|
+
export type PkgAction = 'skip' | 'trust' | 'publish-and-trust' | 'blocked';
|
|
7
|
+
/** A per-package plan produced by the probe phase. */
|
|
8
|
+
export interface PkgPlan {
|
|
9
|
+
ws: Workspace;
|
|
10
|
+
registry: string;
|
|
11
|
+
url: string;
|
|
12
|
+
state: TrustState;
|
|
13
|
+
httpStatus: number;
|
|
14
|
+
action: PkgAction;
|
|
15
|
+
}
|
|
16
|
+
/** Everything a probe needs beyond the requester + workspace. */
|
|
17
|
+
export interface ProbeContext {
|
|
18
|
+
registryOverride?: string;
|
|
19
|
+
npmrc: NpmrcConfig;
|
|
20
|
+
repository: string;
|
|
21
|
+
workflow: string;
|
|
22
|
+
}
|
|
23
|
+
/** Tunables (also the test seams) for a single probe read. */
|
|
24
|
+
export interface ProbeOptions {
|
|
25
|
+
/** Retry once on a transient `auth-required` (401). Default true. */
|
|
26
|
+
retryOn401?: boolean;
|
|
27
|
+
/** Backoff before the single 401 retry. Default {@link PROBE_RETRY_DELAY_MS}. */
|
|
28
|
+
retryDelayMs?: number;
|
|
29
|
+
/** Injected delay (tests pass a no-op). Default: real `setTimeout`. */
|
|
30
|
+
sleep?: (ms: number) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** Small backoff before the one-shot retry of a transient 401 read. */
|
|
33
|
+
export declare const PROBE_RETRY_DELAY_MS = 400;
|
|
34
|
+
/** Default probe concurrency — kept SMALL so a single token never bursts npm. */
|
|
35
|
+
export declare const DEFAULT_PROBE_CONCURRENCY = 4;
|
|
36
|
+
/** Map a classified trust state to the action the onboard sweep should take. */
|
|
37
|
+
export declare function actionForState(state: TrustState): PkgAction;
|
|
38
|
+
/**
|
|
39
|
+
* Read one package's published + trust state via the SHARED trust requester —
|
|
40
|
+
* the exact `trustUrl` + `buildHeaders` auth + `withOtpRetry` path `gjsify
|
|
41
|
+
* trust` uses. On a transient `auth-required` (401 that was not an answerable
|
|
42
|
+
* OTP challenge — e.g. a parallel-burst rejection or a lapsed 2FA-skip window),
|
|
43
|
+
* it retries ONCE after a short backoff.
|
|
44
|
+
*/
|
|
45
|
+
export declare function probeTrustState(request: TrustRequester, ws: Workspace, ctx: ProbeContext, opts?: ProbeOptions): Promise<PkgPlan>;
|
|
46
|
+
/**
|
|
47
|
+
* Probe every selected workspace. The FIRST package is read SERIALLY so a
|
|
48
|
+
* 2FA-gated read triggers the single shared-OTP prompt (typed once) before the
|
|
49
|
+
* concurrent burst — the rest reuse the now-cached code — and so a single token
|
|
50
|
+
* never faces a 127-wide parallel read. The remaining packages are read with a
|
|
51
|
+
* bounded (small) concurrency cap.
|
|
52
|
+
*/
|
|
53
|
+
export declare function probeAllTrustStates(request: TrustRequester, selected: readonly Workspace[], concurrency: number, ctx: ProbeContext, opts?: ProbeOptions): Promise<PkgPlan[]>;
|
|
54
|
+
/** Map with bounded concurrency, preserving input order in the result. */
|
|
55
|
+
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Trusted-Publisher STATE-READ path for `gjsify onboard`, extracted so it can
|
|
2
|
+
// be unit-tested against a mocked requester (no live npm).
|
|
3
|
+
//
|
|
4
|
+
// The bug this fixes (task #60): `gjsify onboard` read all ~127 packages'
|
|
5
|
+
// trust state with a PLAIN authenticated `fetch` (no OTP handling) run in a
|
|
6
|
+
// wide concurrent burst — every read came back `401 → auth-required →
|
|
7
|
+
// unreadable`, so the sweep planned `0 to publish+trust, 0 to trust, 127
|
|
8
|
+
// unreadable`. Meanwhile a targeted `gjsify trust <pkg> --otp <code>` READ the
|
|
9
|
+
// same state fine with the SAME token, because its read runs through the shared
|
|
10
|
+
// `TrustRequester` (→ `withOtpRetry`): a 401 that is an OTP challenge is
|
|
11
|
+
// answered with the shared code, and non-OTP transient 401s are tolerated.
|
|
12
|
+
//
|
|
13
|
+
// Fix: onboard now reads through the EXACT same `TrustRequester` path as
|
|
14
|
+
// `gjsify trust` (identical `trustUrl` + `buildHeaders` auth + OTP handling),
|
|
15
|
+
// PLUS a one-shot retry on a transient 401, PLUS a first-serial-then-bounded
|
|
16
|
+
// probe order so the ONE shared OTP is prompted at most once and npm is never
|
|
17
|
+
// hit with a 127-wide parallel burst from a single token.
|
|
18
|
+
import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
|
|
19
|
+
import { classifyTrustList, trustUrl } from './trust-registry.js';
|
|
20
|
+
/** Small backoff before the one-shot retry of a transient 401 read. */
|
|
21
|
+
export const PROBE_RETRY_DELAY_MS = 400;
|
|
22
|
+
/** Default probe concurrency — kept SMALL so a single token never bursts npm. */
|
|
23
|
+
export const DEFAULT_PROBE_CONCURRENCY = 4;
|
|
24
|
+
/** Map a classified trust state to the action the onboard sweep should take. */
|
|
25
|
+
export function actionForState(state) {
|
|
26
|
+
switch (state) {
|
|
27
|
+
case 'trusted':
|
|
28
|
+
return 'skip';
|
|
29
|
+
case 'untrusted':
|
|
30
|
+
return 'trust';
|
|
31
|
+
case 'unpublished':
|
|
32
|
+
return 'publish-and-trust';
|
|
33
|
+
default:
|
|
34
|
+
// auth-required / error — can't determine, so don't act blindly.
|
|
35
|
+
return 'blocked';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function defaultSleep(ms) {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read one package's published + trust state via the SHARED trust requester —
|
|
43
|
+
* the exact `trustUrl` + `buildHeaders` auth + `withOtpRetry` path `gjsify
|
|
44
|
+
* trust` uses. On a transient `auth-required` (401 that was not an answerable
|
|
45
|
+
* OTP challenge — e.g. a parallel-burst rejection or a lapsed 2FA-skip window),
|
|
46
|
+
* it retries ONCE after a short backoff.
|
|
47
|
+
*/
|
|
48
|
+
export async function probeTrustState(request, ws, ctx, opts = {}) {
|
|
49
|
+
const registry = ctx.registryOverride ?? registryFor(ws.name, ctx.npmrc) ?? DEFAULT_REGISTRY;
|
|
50
|
+
const url = trustUrl(registry, ws.name);
|
|
51
|
+
const classifyOpts = { repository: ctx.repository, workflow: ctx.workflow };
|
|
52
|
+
let res = await request('GET', url);
|
|
53
|
+
let state = classifyTrustList(res.status, res.json, classifyOpts);
|
|
54
|
+
if (state === 'auth-required' && opts.retryOn401 !== false) {
|
|
55
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
56
|
+
await sleep(opts.retryDelayMs ?? PROBE_RETRY_DELAY_MS);
|
|
57
|
+
res = await request('GET', url);
|
|
58
|
+
state = classifyTrustList(res.status, res.json, classifyOpts);
|
|
59
|
+
}
|
|
60
|
+
return { ws, registry, url, state, httpStatus: res.status, action: actionForState(state) };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Probe every selected workspace. The FIRST package is read SERIALLY so a
|
|
64
|
+
* 2FA-gated read triggers the single shared-OTP prompt (typed once) before the
|
|
65
|
+
* concurrent burst — the rest reuse the now-cached code — and so a single token
|
|
66
|
+
* never faces a 127-wide parallel read. The remaining packages are read with a
|
|
67
|
+
* bounded (small) concurrency cap.
|
|
68
|
+
*/
|
|
69
|
+
export async function probeAllTrustStates(request, selected, concurrency, ctx, opts = {}) {
|
|
70
|
+
if (selected.length === 0)
|
|
71
|
+
return [];
|
|
72
|
+
const first = await probeTrustState(request, selected[0], ctx, opts);
|
|
73
|
+
const rest = await mapWithConcurrency(selected.slice(1), Math.max(1, concurrency), (ws) => probeTrustState(request, ws, ctx, opts));
|
|
74
|
+
return [first, ...rest];
|
|
75
|
+
}
|
|
76
|
+
/** Map with bounded concurrency, preserving input order in the result. */
|
|
77
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
78
|
+
const results = Array.from({ length: items.length });
|
|
79
|
+
let next = 0;
|
|
80
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
81
|
+
while (true) {
|
|
82
|
+
const i = next++;
|
|
83
|
+
if (i >= items.length)
|
|
84
|
+
return;
|
|
85
|
+
results[i] = await fn(items[i]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
await Promise.all(workers);
|
|
89
|
+
return results;
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "CLI for Gjsify",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -125,19 +125,19 @@
|
|
|
125
125
|
"cli"
|
|
126
126
|
],
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@gjsify/buffer": "^0.
|
|
129
|
-
"@gjsify/create-app": "^0.
|
|
130
|
-
"@gjsify/node-globals": "^0.
|
|
131
|
-
"@gjsify/node-polyfills": "^0.
|
|
132
|
-
"@gjsify/npm-registry": "^0.
|
|
133
|
-
"@gjsify/resolve-npm": "^0.
|
|
134
|
-
"@gjsify/rolldown-plugin-gjsify": "^0.
|
|
135
|
-
"@gjsify/rolldown-plugin-pnp": "^0.
|
|
136
|
-
"@gjsify/semver": "^0.
|
|
137
|
-
"@gjsify/tar": "^0.
|
|
138
|
-
"@gjsify/tsc": "^0.
|
|
139
|
-
"@gjsify/web-polyfills": "^0.
|
|
140
|
-
"@gjsify/workspace": "^0.
|
|
128
|
+
"@gjsify/buffer": "^0.21.0",
|
|
129
|
+
"@gjsify/create-app": "^0.21.0",
|
|
130
|
+
"@gjsify/node-globals": "^0.21.0",
|
|
131
|
+
"@gjsify/node-polyfills": "^0.21.0",
|
|
132
|
+
"@gjsify/npm-registry": "^0.21.0",
|
|
133
|
+
"@gjsify/resolve-npm": "^0.21.0",
|
|
134
|
+
"@gjsify/rolldown-plugin-gjsify": "^0.21.0",
|
|
135
|
+
"@gjsify/rolldown-plugin-pnp": "^0.21.0",
|
|
136
|
+
"@gjsify/semver": "^0.21.0",
|
|
137
|
+
"@gjsify/tar": "^0.21.0",
|
|
138
|
+
"@gjsify/tsc": "^0.21.0",
|
|
139
|
+
"@gjsify/web-polyfills": "^0.21.0",
|
|
140
|
+
"@gjsify/workspace": "^0.21.0",
|
|
141
141
|
"cosmiconfig": "^9.0.2",
|
|
142
142
|
"get-tsconfig": "^4.14.0",
|
|
143
143
|
"pkg-types": "^2.3.1",
|
|
@@ -145,13 +145,13 @@
|
|
|
145
145
|
"yargs": "^18.0.0"
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
|
-
"@gjsify/unit": "^0.
|
|
148
|
+
"@gjsify/unit": "^0.21.0",
|
|
149
149
|
"@types/yargs": "^17.0.35",
|
|
150
150
|
"typescript": "^6.0.3"
|
|
151
151
|
},
|
|
152
152
|
"peerDependencies": {
|
|
153
|
-
"@gjsify/oxfmt-native": "^0.
|
|
154
|
-
"@gjsify/rolldown-native": "^0.
|
|
153
|
+
"@gjsify/oxfmt-native": "^0.21.0",
|
|
154
|
+
"@gjsify/rolldown-native": "^0.21.0"
|
|
155
155
|
},
|
|
156
156
|
"peerDependenciesMeta": {
|
|
157
157
|
"@gjsify/oxfmt-native": {
|