@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.
@@ -22,6 +22,7 @@ import { DEFAULT_REGISTRY, registryFor, whoami } from '@gjsify/npm-registry';
22
22
  import { loadNpmrc } from '../utils/load-npmrc.js';
23
23
  import { writeAuthToken } from '../utils/auth-npmrc.js';
24
24
  import { promptCredentials, promptLine } from '../utils/prompt.js';
25
+ import { clearCachedOtp, readCachedOtp, writeCachedOtp } from '../utils/npm-otp-cache.js';
25
26
  /** Thrown by {@link runLogin} on any recoverable login failure (bad creds, no token, …). */
26
27
  export class LoginError extends Error {
27
28
  constructor(message) {
@@ -117,19 +118,26 @@ export async function runLogin(opts) {
117
118
  async function putUser(path, otp) {
118
119
  return fetch(`${couchUrl}${path}`, { method: 'PUT', headers: authHeaders(otp), body: JSON.stringify(body) });
119
120
  }
120
- let otp = opts.otp;
121
+ // Seed the OTP from --otp, else the short-lived cross-invocation cache (a
122
+ // sibling `gjsify` command's still-valid code in the ~30 s window).
123
+ const otpFromFlag = typeof opts.otp === 'string' && opts.otp.length > 0;
124
+ let otp = opts.otp ?? readCachedOtp(registryClean);
121
125
  let res = await putUser('', otp);
122
126
  // 2FA: 401 + www-authenticate "otp" (or a one-time-pass body) → prompt + retry.
123
- if (res.status === 401 && !otp) {
127
+ // Re-prompt when the code was missing OR a stale CACHED code (not an
128
+ // explicitly-passed --otp, which the caller owns) was rejected.
129
+ if (res.status === 401 && !otpFromFlag) {
124
130
  const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
125
131
  const text = await res
126
132
  .clone()
127
133
  .text()
128
134
  .catch(() => '');
129
135
  if (wwwAuth.includes('otp') || /one-time pass/i.test(text)) {
136
+ clearCachedOtp(registryClean); // the seeded/cached code was stale or absent
130
137
  otp = await promptLine(`This operation requires a one-time password.\nEnter OTP: `);
131
138
  if (!otp)
132
139
  throw new LoginError('gjsify login: no OTP entered.');
140
+ writeCachedOtp(registryClean, otp);
133
141
  res = await putUser('', otp);
134
142
  }
135
143
  }
@@ -160,6 +168,11 @@ export async function runLogin(opts) {
160
168
  `Your registry may require the web-OAuth flow — use \`npm login\`.`);
161
169
  }
162
170
  const npmrcPath = writeAuthToken(registryClean, data.token);
171
+ // Login succeeded with an OTP → keep it in the short-lived cache (refresh
172
+ // its TTL) so the immediately-following `onboard`/`publish`/`trust` reuses
173
+ // the one typed code instead of prompting again.
174
+ if (otp)
175
+ writeCachedOtp(registryClean, otp);
163
176
  // Confirm the freshly-written token authenticates.
164
177
  const verifyNpmrc = await loadNpmrc(opts.cwd ?? process.cwd());
165
178
  const who = await whoami(registryClean, verifyNpmrc);
@@ -10,6 +10,7 @@
10
10
  import { DEFAULT_REGISTRY, registryFor, resolveAuthForUrl } from '@gjsify/npm-registry';
11
11
  import { loadNpmrc } from '../utils/load-npmrc.js';
12
12
  import { removeAuthToken } from '../utils/auth-npmrc.js';
13
+ import { clearCachedOtp } from '../utils/npm-otp-cache.js';
13
14
  export const logoutCommand = {
14
15
  command: 'logout',
15
16
  description: 'Log out of an npm registry (Node-free `npm logout`). Revokes the token on the registry (best-effort) and removes it from ~/.npmrc.',
@@ -49,6 +50,9 @@ export const logoutCommand = {
49
50
  /* offline / registry down — fall through to local removal */
50
51
  }
51
52
  }
53
+ // Drop any short-lived cached OTP for this registry — the token it would
54
+ // have accompanied is now revoked.
55
+ clearCachedOtp(registryClean);
52
56
  const { path, removed } = removeAuthToken(registryClean);
53
57
  if (args.json) {
54
58
  process.stdout.write(`${JSON.stringify({ registry: registryClean, revoked, removed })}\n`);
@@ -11,19 +11,21 @@
11
11
  // for credentials; only when it is dead/missing do we run the `gjsify
12
12
  // login` flow (unless `--yes` on a non-TTY, which fails clearly).
13
13
  // 2. Enumerate publishable workspaces (non-private, excluding `@girs/*`).
14
- // 3. Determine each package's state CONCURRENTLY (bounded) by reading npm's
15
- // Trusted-Publisher config: unpublished (404) / published-but-untrusted /
16
- // published-and-trusted. Reads never prompt for 2FA.
14
+ // 3. Determine each package's state (bounded concurrency) by reading npm's
15
+ // Trusted-Publisher config through the SAME requester path `gjsify trust`
16
+ // uses: unpublished (404) / published-but-untrusted / published-and-trusted.
17
+ // A 2FA-gated read is answered with the shared OTP (the first read is
18
+ // serial so the prompt happens once); a transient 401 is retried once.
17
19
  // 4. Act on the gaps, minimally: build+publish+trust an unpublished package;
18
20
  // trust a published-but-untrusted one; skip an already-done one.
19
- // 5. Share ONE OTP across every publish + trust operation — the cached code
20
- // is tried before prompting, so a whole sweep of N packages typically
21
+ // 5. Share ONE OTP across every read + publish + trust operation — the cached
22
+ // code is tried before prompting, so a whole sweep of N packages typically
21
23
  // needs the user to type an OTP only once (see utils/npm-otp.ts).
22
24
  //
23
25
  // Re-running when everything is already published + trusted does nothing and
24
26
  // exits 0. `--dry-run` reports the plan and changes nothing.
25
27
  import { spawn, spawnSync } from 'node:child_process';
26
- import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
28
+ import { DEFAULT_REGISTRY, whoami } from '@gjsify/npm-registry';
27
29
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
28
30
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
29
31
  import { OtpProvider } from '../utils/npm-otp.js';
@@ -32,7 +34,8 @@ import { createTrustRequester } from './trust.js';
32
34
  import { runLogin, LoginError } from './login.js';
33
35
  import { detectPackageManager } from './workspace.js';
34
36
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
35
- import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
37
+ import { githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, validateRepository, } from '../utils/trust-registry.js';
38
+ import { DEFAULT_PROBE_CONCURRENCY, probeAllTrustStates } from '../utils/onboard-probe.js';
36
39
  export const onboardCommand = {
37
40
  command: 'onboard',
38
41
  description: 'Ensure every publishable @gjsify/* workspace is both published on npm and has a Trusted Publisher configured — publishing/trusting only what is missing, one shared OTP across the whole sweep. Idempotent.',
@@ -59,9 +62,9 @@ export const onboardCommand = {
59
62
  type: 'string',
60
63
  })
61
64
  .option('concurrency', {
62
- description: 'How many packages to probe (read state) in parallel.',
65
+ description: 'How many packages to probe (read state) in parallel (kept small so a single token does not burst npm; the first read is always serial to prompt for the shared OTP once).',
63
66
  type: 'number',
64
- default: 8,
67
+ default: DEFAULT_PROBE_CONCURRENCY,
65
68
  })
66
69
  .option('dry-run', {
67
70
  description: 'Report the plan (what would be published / trusted) without changing anything.',
@@ -154,14 +157,28 @@ export const onboardCommand = {
154
157
  if (dryRun)
155
158
  log('(dry-run — nothing will be published or configured)');
156
159
  log();
157
- // 3. Determine per-package state CONCURRENTLY. Reads use a plain
158
- // authenticated GET (no OTP prompting — a 401 becomes `auth-required`).
159
- const plans = await mapWithConcurrency(selected, Math.max(1, args.concurrency), (ws) => probeTrustState(ws, {
160
+ // Shared OTP provider + trust requester, built ONCE up front so the
161
+ // state-READ probes AND the act-phase writes share ONE OTP (typed once
162
+ // for the whole sweep, cache-first — the file cache in utils/npm-otp.ts
163
+ // extends that reuse across sibling `gjsify` invocations). The provider
164
+ // is scoped to the default registry so the file cache keys correctly.
165
+ const otpProvider = new OtpProvider(args.otp, nonInteractive ? async () => '' : undefined, {
166
+ registry: defaultRegistry,
167
+ });
168
+ const trustRequest = createTrustRequester({ npmrc, otpProvider });
169
+ // 3. Determine per-package state. Reads run through the SAME
170
+ // `TrustRequester` path `gjsify trust` uses (identical endpoint + auth +
171
+ // OTP handling) — so a 2FA-gated trust-state read is answered with the
172
+ // shared code instead of the plain-fetch 401→`unreadable` cascade
173
+ // (task #60). The first read is serial (prompts for the shared OTP once,
174
+ // before the burst); the rest run at a SMALL concurrency cap so a single
175
+ // token never faces a 127-wide parallel read.
176
+ const plans = await probeAllTrustStates(trustRequest, selected, Math.max(1, args.concurrency), {
160
177
  registryOverride: args.registry,
161
178
  npmrc,
162
179
  repository,
163
180
  workflow,
164
- }));
181
+ });
165
182
  // Summary table (published✓ / trusted✓ vs work to do).
166
183
  const toPublish = plans.filter((p) => p.action === 'publish-and-trust');
167
184
  const toTrust = plans.filter((p) => p.action === 'trust');
@@ -188,9 +205,8 @@ export const onboardCommand = {
188
205
  return;
189
206
  }
190
207
  // 4. Act on the gaps, minimally. Publishes + trust POSTs run SERIALLY so
191
- // the shared OTP is prompted at most once (and reused everywhere).
192
- const otpProvider = new OtpProvider(args.otp, nonInteractive ? async () => '' : undefined);
193
- const trustRequest = createTrustRequester({ npmrc, otpProvider });
208
+ // the shared OTP is prompted at most once (and reused everywhere) —
209
+ // reusing the `otpProvider` + `trustRequest` built for the probe phase.
194
210
  const trustBody = githubTrustBody({ repository, workflow, environment });
195
211
  let failed = 0;
196
212
  for (const p of plans) {
@@ -353,50 +369,6 @@ async function ensureAuthenticated(ctx) {
353
369
  throw err;
354
370
  }
355
371
  }
356
- /**
357
- * Read a package's current published + trust state via a plain authenticated
358
- * GET on its trust config (no OTP prompting). 404 → unpublished; a matching
359
- * github entry → trusted; otherwise → published-but-untrusted.
360
- */
361
- async function probeTrustState(ws, ctx) {
362
- const registry = ctx.registryOverride ?? registryFor(ws.name, ctx.npmrc) ?? DEFAULT_REGISTRY;
363
- const url = trustUrl(registry, ws.name);
364
- let state = 'error';
365
- let httpStatus = 0;
366
- try {
367
- const headers = buildHeaders(url, { npmrc: ctx.npmrc });
368
- headers['accept'] = 'application/json';
369
- const res = await fetch(url, { method: 'GET', headers });
370
- httpStatus = res.status;
371
- const text = await res.text().catch(() => '');
372
- let json = undefined;
373
- try {
374
- json = text ? JSON.parse(text) : undefined;
375
- }
376
- catch {
377
- /* non-JSON body */
378
- }
379
- state = classifyTrustList(res.status, json, { repository: ctx.repository, workflow: ctx.workflow });
380
- }
381
- catch {
382
- state = 'error';
383
- }
384
- let action;
385
- switch (state) {
386
- case 'trusted':
387
- action = 'skip';
388
- break;
389
- case 'untrusted':
390
- action = 'trust';
391
- break;
392
- case 'unpublished':
393
- action = 'publish-and-trust';
394
- break;
395
- default:
396
- action = 'blocked'; // auth-required / error — can't determine
397
- }
398
- return { ws, registry, url, state, httpStatus, action };
399
- }
400
372
  /** Run the package's `build` script (if it declares one) before publishing. */
401
373
  async function buildIfPresent(ws, log) {
402
374
  const scripts = ws.manifest.scripts ?? {};
@@ -436,18 +408,3 @@ function describePublishFailure(pub) {
436
408
  return 'unknown';
437
409
  }
438
410
  }
439
- /** Map with bounded concurrency, preserving input order in the result. */
440
- async function mapWithConcurrency(items, limit, fn) {
441
- const results = Array.from({ length: items.length });
442
- let next = 0;
443
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
444
- while (true) {
445
- const i = next++;
446
- if (i >= items.length)
447
- return;
448
- results[i] = await fn(items[i]);
449
- }
450
- });
451
- await Promise.all(workers);
452
- return results;
453
- }
@@ -187,7 +187,23 @@ export const publishCommand = {
187
187
  // silent hang reading a non-existent terminal. `--otp` seeds the code so
188
188
  // it rides the first PUT (matching npm's `--otp` behaviour).
189
189
  const interactiveOtp = Boolean(process.stdin.isTTY && process.stdout.isTTY);
190
- const otpProvider = new OtpProvider(otp, interactiveOtp ? promptLine : async () => '');
190
+ // Scope the shared OTP file cache to this package's registry so a typed
191
+ // code carries over to a sibling `gjsify trust`/`publish` in the window.
192
+ const otpNpmrc = await loadNpmrc(wsDir);
193
+ let otpPkgName;
194
+ try {
195
+ otpPkgName = JSON.parse(readFileSync(join(wsDir, 'package.json'), 'utf-8')).name;
196
+ }
197
+ catch {
198
+ /* no readable manifest — fall back to the default registry below */
199
+ }
200
+ const otpRegistry = process.env.npm_config_registry ??
201
+ (otpPkgName ? registryFor(otpPkgName, otpNpmrc) : undefined) ??
202
+ otpNpmrc.registry ??
203
+ DEFAULT_REGISTRY;
204
+ const otpProvider = new OtpProvider(otp, interactiveOtp ? promptLine : async () => '', {
205
+ registry: otpRegistry,
206
+ });
191
207
  const outcome = await publishWorkspace({
192
208
  wsDir,
193
209
  tag,
@@ -16,13 +16,70 @@
16
16
  //
17
17
  // Reference: refs/npm-cli/lib/trust-cmd.js + lib/commands/trust/github.js.
18
18
  import { spawnSync } from 'node:child_process';
19
- import { existsSync } from 'node:fs';
20
- import { join } from 'node:path';
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
21
  import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
22
22
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
23
23
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
24
24
  import { OtpProvider, withOtpRetry } from '../utils/npm-otp.js';
25
25
  import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
26
+ /**
27
+ * Publishable packages under `packages/node-gi/*` that are NOT root workspaces
28
+ * (the workspaces glob excludes `packages/node-gi/*`) yet publish via release.yml —
29
+ * `@gjsify/node-gi` + the `@gjsify/gtk-runtime-*` bundles. `discoverWorkspaces`
30
+ * misses them, so `gjsify trust` would never configure their Trusted Publisher.
31
+ * Enumerate them here as Workspace-shaped entries so the sweep covers them too.
32
+ * Best-effort: a missing dir / unreadable manifest is simply skipped.
33
+ */
34
+ function discoverNodeGiPublishables(cwd) {
35
+ // Walk up from cwd to the repo root — the dir that holds `packages/node-gi` —
36
+ // so the command works from the root or any subdirectory.
37
+ let root = cwd;
38
+ for (let i = 0; i < 8; i++) {
39
+ if (existsSync(join(root, 'packages', 'node-gi')))
40
+ break;
41
+ const parent = dirname(root);
42
+ if (parent === root)
43
+ return [];
44
+ root = parent;
45
+ }
46
+ const base = join(root, 'packages', 'node-gi');
47
+ if (!existsSync(base))
48
+ return [];
49
+ const out = [];
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(base, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ for (const entry of entries) {
58
+ if (!entry.isDirectory())
59
+ continue;
60
+ const location = join(base, entry.name);
61
+ const pkgPath = join(location, 'package.json');
62
+ if (!existsSync(pkgPath))
63
+ continue;
64
+ try {
65
+ const manifest = JSON.parse(readFileSync(pkgPath, 'utf8'));
66
+ if (!manifest || typeof manifest.name !== 'string' || manifest.name.length === 0)
67
+ continue;
68
+ out.push({
69
+ location,
70
+ relativeLocation: join('packages', 'node-gi', entry.name),
71
+ name: manifest.name,
72
+ version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
73
+ manifest,
74
+ private: manifest.private === true,
75
+ });
76
+ }
77
+ catch {
78
+ /* unreadable / malformed — skip */
79
+ }
80
+ }
81
+ return out;
82
+ }
26
83
  export const trustCommand = {
27
84
  command: 'trust [packages..]',
28
85
  description: 'Configure npm Trusted Publishers (OIDC/GitHub Actions) for publishable workspace packages so release.yml can publish without a token. Native — no `npm` needed. Incremental: skips already-configured packages.',
@@ -136,12 +193,19 @@ export const trustCommand = {
136
193
  }
137
194
  // Publishable workspaces: non-private by default, optionally name-filtered.
138
195
  const all = discoverWorkspaces(cwd, { includeRoot: true });
139
- const selected = filterWorkspaces(all, {
196
+ // PLUS the publishable packages/node-gi/* packages (node-gi + gtk-runtime-*),
197
+ // which publish via release.yml but are NOT root workspaces, so
198
+ // discoverWorkspaces misses them and their Trusted Publisher would never get
199
+ // configured. Dedup by name so a real workspace always wins.
200
+ const workspaceNames = new Set(all.map((ws) => ws.name));
201
+ const extra = discoverNodeGiPublishables(cwd).filter((ws) => !workspaceNames.has(ws.name));
202
+ const merged = [...all, ...extra];
203
+ const selected = filterWorkspaces(merged, {
140
204
  noPrivate: args.private !== true,
141
205
  include: args.packages && args.packages.length > 0 ? args.packages : undefined,
142
206
  }).filter((ws) => ws.name);
143
207
  if (selected.length === 0) {
144
- console.error('gjsify trust: no publishable workspace packages matched.');
208
+ console.error('gjsify trust: no publishable packages matched.');
145
209
  process.exit(1);
146
210
  }
147
211
  console.log(`gjsify trust: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
@@ -0,0 +1,31 @@
1
+ /** TTL for a cached OTP — one TOTP window. Short by design (never raise past ~60 s). */
2
+ export declare const OTP_CACHE_TTL_MS = 30000;
3
+ /** Env var that disables the cross-invocation OTP cache entirely. */
4
+ export declare const OTP_CACHE_OPT_OUT_ENV = "GJSIFY_NO_OTP_CACHE";
5
+ /** Injectable knobs so the unit tests stay hermetic (real callers pass nothing). */
6
+ export interface OtpCacheOptions {
7
+ /** Override the cache base dir. Defaults to `$XDG_RUNTIME_DIR` → OS temp dir. */
8
+ dir?: string;
9
+ /** Injected clock (epoch-ms). Defaults to `Date.now()`. */
10
+ now?: number;
11
+ /** Override the TTL for a write. Defaults to {@link OTP_CACHE_TTL_MS}. */
12
+ ttlMs?: number;
13
+ }
14
+ /** `true` unless `GJSIFY_NO_OTP_CACHE` is set to a truthy value. */
15
+ export declare function isOtpCacheEnabled(): boolean;
16
+ /**
17
+ * Read the cached code for `registry`, or `undefined` on a miss / expiry /
18
+ * opt-out. An EXPIRED entry is deleted as a side effect.
19
+ */
20
+ export declare function readCachedOtp(registry: string, opts?: OtpCacheOptions): string | undefined;
21
+ /**
22
+ * Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out
23
+ * or empty code. Best-effort — a write failure never throws.
24
+ */
25
+ export declare function writeCachedOtp(registry: string, otp: string, opts?: OtpCacheOptions): void;
26
+ /** Drop the cached code for a single registry (invalid/consumed OTP, or logout). */
27
+ export declare function clearCachedOtp(registry: string, opts?: OtpCacheOptions): void;
28
+ /** Drop the entire cache dir (e.g. a broad logout). Best-effort. */
29
+ export declare function clearAllCachedOtp(opts?: OtpCacheOptions): void;
30
+ /** POSIX file-mode bits of the cache file (test helper); `undefined` if absent. */
31
+ export declare function cachedOtpFileMode(registry: string, opts?: OtpCacheOptions): number | undefined;
@@ -0,0 +1,142 @@
1
+ // Short-lived, CROSS-INVOCATION cache for the npm 2FA one-time code.
2
+ //
3
+ // A TOTP stays valid for its ~30 s window, so a `login → onboard → publish →
4
+ // trust` sequence of SEPARATE `gjsify` processes should reuse ONE typed code
5
+ // instead of prompting per command. The in-process `OtpProvider` cache
6
+ // (utils/npm-otp.ts) only survives a single command; this file is the layer
7
+ // under it that survives across invocations.
8
+ //
9
+ // Design:
10
+ // - Stored in a temp file (under `$XDG_RUNTIME_DIR`, else the OS temp dir —
11
+ // NEVER the repo), perms 0600, with a stamped absolute `expiresAt`.
12
+ // - TTL = one TOTP window (30 s), deliberately short. A read past the expiry
13
+ // ignores AND deletes the file.
14
+ // - Keyed by the REGISTRY URL, so a code for one registry is never replayed
15
+ // against another.
16
+ // - Best-effort: a cache miss/error NEVER breaks the command — a stale or
17
+ // unreadable entry degrades to "no cached code" (re-prompt).
18
+ // - Opt-out: `GJSIFY_NO_OTP_CACHE=1` disables it entirely; `gjsify logout`
19
+ // clears it.
20
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ /** TTL for a cached OTP — one TOTP window. Short by design (never raise past ~60 s). */
24
+ export const OTP_CACHE_TTL_MS = 30_000;
25
+ /** Env var that disables the cross-invocation OTP cache entirely. */
26
+ export const OTP_CACHE_OPT_OUT_ENV = 'GJSIFY_NO_OTP_CACHE';
27
+ const CACHE_DIR_NAME = 'gjsify-otp';
28
+ /** `true` unless `GJSIFY_NO_OTP_CACHE` is set to a truthy value. */
29
+ export function isOtpCacheEnabled() {
30
+ const v = process.env[OTP_CACHE_OPT_OUT_ENV];
31
+ return !(v === '1' || v === 'true' || v === 'yes');
32
+ }
33
+ /** Trailing-slash-insensitive registry key so `…/` and `…` map to one entry. */
34
+ function normalizeRegistry(registry) {
35
+ return registry.endsWith('/') ? registry.slice(0, -1) : registry;
36
+ }
37
+ function cacheBaseDir(opts) {
38
+ if (opts?.dir)
39
+ return opts.dir;
40
+ const xdg = process.env.XDG_RUNTIME_DIR;
41
+ const base = xdg && xdg.length > 0 ? xdg : tmpdir();
42
+ return join(base, CACHE_DIR_NAME);
43
+ }
44
+ /** Registry-scoped cache file path (filename derived from the registry). */
45
+ function cacheFileFor(registry, opts) {
46
+ const safe = normalizeRegistry(registry)
47
+ .replace(/[^a-zA-Z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 120);
50
+ return join(cacheBaseDir(opts), `otp-${safe || 'default'}.json`);
51
+ }
52
+ /**
53
+ * Read the cached code for `registry`, or `undefined` on a miss / expiry /
54
+ * opt-out. An EXPIRED entry is deleted as a side effect.
55
+ */
56
+ export function readCachedOtp(registry, opts) {
57
+ if (!isOtpCacheEnabled())
58
+ return undefined;
59
+ const now = opts?.now ?? Date.now();
60
+ const file = cacheFileFor(registry, opts);
61
+ try {
62
+ if (!existsSync(file))
63
+ return undefined;
64
+ const entry = JSON.parse(readFileSync(file, 'utf8'));
65
+ if (!entry || typeof entry.otp !== 'string' || typeof entry.expiresAt !== 'number') {
66
+ deleteCacheFile(file);
67
+ return undefined;
68
+ }
69
+ // Registry mismatch (sanitized filename collision) → miss, don't delete.
70
+ if (entry.registry !== normalizeRegistry(registry))
71
+ return undefined;
72
+ if (now >= entry.expiresAt) {
73
+ deleteCacheFile(file); // expired → ignore + delete
74
+ return undefined;
75
+ }
76
+ return entry.otp.length > 0 ? entry.otp : undefined;
77
+ }
78
+ catch {
79
+ // Corrupt / unreadable — treat as a miss, best-effort remove.
80
+ deleteCacheFile(file);
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out
86
+ * or empty code. Best-effort — a write failure never throws.
87
+ */
88
+ export function writeCachedOtp(registry, otp, opts) {
89
+ if (!isOtpCacheEnabled())
90
+ return;
91
+ if (!otp || otp.length === 0)
92
+ return;
93
+ const now = opts?.now ?? Date.now();
94
+ const ttl = opts?.ttlMs ?? OTP_CACHE_TTL_MS;
95
+ try {
96
+ const dir = cacheBaseDir(opts);
97
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
98
+ const file = cacheFileFor(registry, opts);
99
+ const entry = { otp, expiresAt: now + ttl, registry: normalizeRegistry(registry) };
100
+ writeFileSync(file, JSON.stringify(entry), { mode: 0o600 });
101
+ // Enforce 0600 even when the file pre-existed with looser perms (writeFile
102
+ // only applies `mode` on create) — the code is a live secret.
103
+ chmodSync(file, 0o600);
104
+ }
105
+ catch {
106
+ /* best-effort — a cache write must never break the command */
107
+ }
108
+ }
109
+ /** Drop the cached code for a single registry (invalid/consumed OTP, or logout). */
110
+ export function clearCachedOtp(registry, opts) {
111
+ deleteCacheFile(cacheFileFor(registry, opts));
112
+ }
113
+ /** Drop the entire cache dir (e.g. a broad logout). Best-effort. */
114
+ export function clearAllCachedOtp(opts) {
115
+ try {
116
+ rmSync(cacheBaseDir(opts), { recursive: true, force: true });
117
+ }
118
+ catch {
119
+ /* ignore */
120
+ }
121
+ }
122
+ /** POSIX file-mode bits of the cache file (test helper); `undefined` if absent. */
123
+ export function cachedOtpFileMode(registry, opts) {
124
+ const file = cacheFileFor(registry, opts);
125
+ try {
126
+ if (!existsSync(file))
127
+ return undefined;
128
+ return statSync(file).mode & 0o777;
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ function deleteCacheFile(file) {
135
+ try {
136
+ if (existsSync(file))
137
+ rmSync(file, { force: true });
138
+ }
139
+ catch {
140
+ /* ignore */
141
+ }
142
+ }
@@ -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
  /**