@gjsify/cli 0.20.0 → 0.22.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.
@@ -6,31 +6,55 @@ export type OtpPromptFn = (question: string) => Promise<string>;
6
6
  * Unifies the detection logic previously inlined in both publish.ts and trust.ts.
7
7
  */
8
8
  export declare function isOtpChallenge(res: Response): Promise<boolean>;
9
+ /** Options controlling the cross-invocation file cache an {@link OtpProvider} rides on top of. */
10
+ export interface OtpProviderOptions {
11
+ /**
12
+ * Registry URL scoping the SHORT-LIVED cross-invocation OTP cache
13
+ * (`utils/npm-otp-cache.ts`). When set, a provider constructed WITHOUT an
14
+ * explicit seed reads the file-cached code first (so a sibling `gjsify`
15
+ * command's typed OTP is reused within its ~30 s TOTP window), and every
16
+ * newly typed/seeded code is written back for the next command. Omit → the
17
+ * provider is in-process-only (the historical behaviour).
18
+ */
19
+ registry?: string;
20
+ }
9
21
  /**
10
22
  * Holds ONE OTP code across a run and knows how to prompt for a fresh one.
11
23
  *
12
- * `current()` returns the cached code (seeded from `--otp`, or the last one the
13
- * user typed). `refresh()` prompts for a new code and caches it. A single
14
- * provider instance shared across many publish + trust operations is what makes
15
- * `gjsify onboard` need only one OTP entry for the whole sweep.
24
+ * `current()` returns the cached code (seeded from `--otp`, the cross-invocation
25
+ * file cache, or the last one the user typed). `refresh()` prompts for a new
26
+ * code and caches it (in-process AND — when a `registry` is set — in the
27
+ * short-lived file cache). A single provider instance shared across many
28
+ * publish + trust operations is what makes `gjsify onboard` need only one OTP
29
+ * entry for the whole sweep; the file cache extends that reuse across SEPARATE
30
+ * `gjsify` invocations (`login → onboard → publish → trust`).
16
31
  */
17
32
  export declare class OtpProvider {
18
33
  private cached;
19
34
  private readonly promptFn;
35
+ private readonly registry;
20
36
  /**
21
37
  * @param seed Initial cached code (from `--otp`), or undefined.
22
38
  * @param promptFn How to read a fresh code. Defaults to the TTY-aware
23
39
  * `promptLine`. Non-interactive callers pass a function that
24
40
  * returns `''` so a challenge surfaces instead of hanging.
41
+ * @param options Optional cross-invocation file-cache scoping (`registry`).
25
42
  */
26
- constructor(seed?: string, promptFn?: OtpPromptFn);
43
+ constructor(seed?: string, promptFn?: OtpPromptFn, options?: OtpProviderOptions);
27
44
  /** The currently cached OTP code, if any. */
28
45
  current(): string | undefined;
29
46
  /**
30
- * Prompt for a fresh code, cache it, and return it (the trimmed input, or
31
- * `''` when nothing was entered — the caller treats empty as "give up").
47
+ * Prompt for a fresh code, cache it (in-process + file cache), and return
48
+ * it (the trimmed input, or `''` when nothing was entered — the caller
49
+ * treats empty as "give up").
32
50
  */
33
51
  refresh(question?: string): Promise<string>;
52
+ /**
53
+ * Drop the cached code (in-process AND the cross-invocation file entry)
54
+ * after npm rejects it as invalid/consumed — so the NEXT command re-prompts
55
+ * instead of replaying a single-use code that the registry already burned.
56
+ */
57
+ invalidate(): void;
34
58
  }
35
59
  export interface OtpRetryOptions {
36
60
  /**
@@ -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`, or the last one the
44
- * user typed). `refresh()` prompts for a new code and caches it. A single
45
- * provider instance shared across many publish + trust operations is what makes
46
- * `gjsify onboard` need only one OTP entry for the whole sweep.
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, and return it (the trimmed input, or
67
- * `''` when nothing was entered — the caller treats empty as "give up").
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.20.0",
3
+ "version": "0.22.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.20.0",
129
- "@gjsify/create-app": "^0.20.0",
130
- "@gjsify/node-globals": "^0.20.0",
131
- "@gjsify/node-polyfills": "^0.20.0",
132
- "@gjsify/npm-registry": "^0.20.0",
133
- "@gjsify/resolve-npm": "^0.20.0",
134
- "@gjsify/rolldown-plugin-gjsify": "^0.20.0",
135
- "@gjsify/rolldown-plugin-pnp": "^0.20.0",
136
- "@gjsify/semver": "^0.20.0",
137
- "@gjsify/tar": "^0.20.0",
138
- "@gjsify/tsc": "^0.20.0",
139
- "@gjsify/web-polyfills": "^0.20.0",
140
- "@gjsify/workspace": "^0.20.0",
128
+ "@gjsify/buffer": "^0.22.0",
129
+ "@gjsify/create-app": "^0.22.0",
130
+ "@gjsify/node-globals": "^0.22.0",
131
+ "@gjsify/node-polyfills": "^0.22.0",
132
+ "@gjsify/npm-registry": "^0.22.0",
133
+ "@gjsify/resolve-npm": "^0.22.0",
134
+ "@gjsify/rolldown-plugin-gjsify": "^0.22.0",
135
+ "@gjsify/rolldown-plugin-pnp": "^0.22.0",
136
+ "@gjsify/semver": "^0.22.0",
137
+ "@gjsify/tar": "^0.22.0",
138
+ "@gjsify/tsc": "^0.22.0",
139
+ "@gjsify/web-polyfills": "^0.22.0",
140
+ "@gjsify/workspace": "^0.22.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.20.0",
148
+ "@gjsify/unit": "^0.22.0",
149
149
  "@types/yargs": "^17.0.35",
150
150
  "typescript": "^6.0.3"
151
151
  },
152
152
  "peerDependencies": {
153
- "@gjsify/oxfmt-native": "^0.20.0",
154
- "@gjsify/rolldown-native": "^0.20.0"
153
+ "@gjsify/oxfmt-native": "^0.22.0",
154
+ "@gjsify/rolldown-native": "^0.22.0"
155
155
  },
156
156
  "peerDependenciesMeta": {
157
157
  "@gjsify/oxfmt-native": {