@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/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/lib/commands/logout.js
CHANGED
|
@@ -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`);
|
package/lib/commands/onboard.js
CHANGED
|
@@ -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
|
|
15
|
-
// Trusted-Publisher config
|
|
16
|
-
// published-and-trusted.
|
|
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
|
|
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,
|
|
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 {
|
|
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:
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/lib/commands/trust.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/utils/npm-otp.d.ts
CHANGED
|
@@ -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`,
|
|
13
|
-
* user typed). `refresh()` prompts for a new
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
31
|
-
* `''` when nothing was entered — the caller
|
|
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
|
/**
|