@gjsify/cli 0.15.1 → 0.16.1
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 +114 -116
- package/lib/actions/build.js +24 -1
- package/lib/cli-app.js +2 -1
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/login.d.ts +29 -0
- package/lib/commands/login.js +122 -96
- package/lib/commands/onboard.d.ts +14 -0
- package/lib/commands/onboard.js +453 -0
- package/lib/commands/pack.js +25 -11
- package/lib/commands/publish.d.ts +92 -0
- package/lib/commands/publish.js +270 -295
- package/lib/commands/trust.d.ts +21 -0
- package/lib/commands/trust.js +40 -70
- package/lib/commands/workspace.d.ts +1 -0
- package/lib/commands/workspace.js +30 -9
- package/lib/utils/library-output.d.ts +30 -0
- package/lib/utils/library-output.js +92 -0
- package/lib/utils/npm-otp.d.ts +67 -0
- package/lib/utils/npm-otp.js +117 -0
- package/lib/utils/suggest.d.ts +13 -0
- package/lib/utils/suggest.js +51 -0
- package/package.json +18 -18
package/lib/commands/trust.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type NpmrcConfig } from '@gjsify/npm-registry';
|
|
1
2
|
import type { Command } from '../types/index.js';
|
|
3
|
+
import { OtpProvider } from '../utils/npm-otp.js';
|
|
2
4
|
import { type TrustEntry } from '../utils/trust-registry.js';
|
|
3
5
|
interface TrustOptions {
|
|
4
6
|
packages?: string[];
|
|
@@ -13,4 +15,23 @@ interface TrustOptions {
|
|
|
13
15
|
'no-private'?: boolean;
|
|
14
16
|
}
|
|
15
17
|
export declare const trustCommand: Command<unknown, TrustOptions>;
|
|
18
|
+
/** Parsed result of a trust-registry request. */
|
|
19
|
+
export interface TrustRequestResult {
|
|
20
|
+
status: number;
|
|
21
|
+
json: unknown;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
/** A `(method, url, body?) => TrustRequestResult` function bound to a registry auth + OTP context. */
|
|
25
|
+
export type TrustRequester = (method: string, url: string, body?: unknown) => Promise<TrustRequestResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Build a trust-registry requester bound to an npmrc + a shared
|
|
28
|
+
* {@link OtpProvider}. Each request runs through `withOtpRetry` so a 2FA
|
|
29
|
+
* challenge reuses the cached code before prompting — the same instance passed
|
|
30
|
+
* to `createTrustRequester` and to the publish flow shares ONE OTP across a
|
|
31
|
+
* whole `gjsify onboard` sweep.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createTrustRequester(ctx: {
|
|
34
|
+
npmrc: NpmrcConfig;
|
|
35
|
+
otpProvider: OtpProvider;
|
|
36
|
+
}): TrustRequester;
|
|
16
37
|
export type { TrustEntry };
|
package/lib/commands/trust.js
CHANGED
|
@@ -21,7 +21,7 @@ import { 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
|
-
import {
|
|
24
|
+
import { OtpProvider, withOtpRetry } from '../utils/npm-otp.js';
|
|
25
25
|
import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
|
|
26
26
|
export const trustCommand = {
|
|
27
27
|
command: 'trust [packages..]',
|
|
@@ -148,63 +148,13 @@ export const trustCommand = {
|
|
|
148
148
|
if (dryRun)
|
|
149
149
|
console.log('(dry-run — nothing will be written)');
|
|
150
150
|
console.log();
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
let pendingOtp = args.otp;
|
|
159
|
-
const request = async (method, url, body) => {
|
|
160
|
-
const doFetch = async (code) => {
|
|
161
|
-
const headers = buildHeaders(url, { npmrc });
|
|
162
|
-
headers['accept'] = 'application/json';
|
|
163
|
-
if (body !== undefined)
|
|
164
|
-
headers['content-type'] = 'application/json';
|
|
165
|
-
if (code)
|
|
166
|
-
headers['npm-otp'] = code;
|
|
167
|
-
return fetch(url, {
|
|
168
|
-
method,
|
|
169
|
-
headers,
|
|
170
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
171
|
-
});
|
|
172
|
-
};
|
|
173
|
-
// 1) Try without an OTP — succeeds outright (read) or via an active
|
|
174
|
-
// 2FA-skip window.
|
|
175
|
-
let res = await doFetch();
|
|
176
|
-
// 2) On a challenge, use the --otp (once) or prompt, and retry.
|
|
177
|
-
if (await isOtpChallenge(res)) {
|
|
178
|
-
let code = pendingOtp;
|
|
179
|
-
pendingOtp = undefined; // one-shot — never reuse a stale code
|
|
180
|
-
if (!code) {
|
|
181
|
-
code = await promptLine('This operation requires a one-time password.\nEnter OTP: ');
|
|
182
|
-
}
|
|
183
|
-
if (!code) {
|
|
184
|
-
console.error('gjsify trust: no OTP entered.');
|
|
185
|
-
process.exit(1);
|
|
186
|
-
}
|
|
187
|
-
res = await doFetch(code);
|
|
188
|
-
// 3) Still challenged → the code was stale/expired. Prompt fresh once.
|
|
189
|
-
if (await isOtpChallenge(res)) {
|
|
190
|
-
const fresh = await promptLine('OTP rejected (expired?) — enter a fresh code: ');
|
|
191
|
-
if (!fresh) {
|
|
192
|
-
console.error('gjsify trust: no OTP entered.');
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
res = await doFetch(fresh);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
const text = await res.text().catch(() => '');
|
|
199
|
-
let json = undefined;
|
|
200
|
-
try {
|
|
201
|
-
json = text ? JSON.parse(text) : undefined;
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
/* non-JSON body — leave json undefined */
|
|
205
|
-
}
|
|
206
|
-
return { status: res.status, json, text };
|
|
207
|
-
};
|
|
151
|
+
// Cache-first OTP across the whole sweep (shared helper). Each request
|
|
152
|
+
// tries WITHOUT an OTP first (npm's ~5-min 2FA-skip window covers the
|
|
153
|
+
// packages after the first success) and only sends a code when actually
|
|
154
|
+
// challenged: the cached `--otp` / last-typed code first, then a fresh
|
|
155
|
+
// prompt when npm rejects it. See utils/npm-otp.ts.
|
|
156
|
+
const otpProvider = new OtpProvider(args.otp);
|
|
157
|
+
const request = createTrustRequester({ npmrc, otpProvider });
|
|
208
158
|
let ok = 0;
|
|
209
159
|
let skipped = 0;
|
|
210
160
|
let unpublished = 0;
|
|
@@ -288,18 +238,38 @@ export const trustCommand = {
|
|
|
288
238
|
}
|
|
289
239
|
},
|
|
290
240
|
};
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Build a trust-registry requester bound to an npmrc + a shared
|
|
243
|
+
* {@link OtpProvider}. Each request runs through `withOtpRetry` so a 2FA
|
|
244
|
+
* challenge reuses the cached code before prompting — the same instance passed
|
|
245
|
+
* to `createTrustRequester` and to the publish flow shares ONE OTP across a
|
|
246
|
+
* whole `gjsify onboard` sweep.
|
|
247
|
+
*/
|
|
248
|
+
export function createTrustRequester(ctx) {
|
|
249
|
+
return async (method, url, body) => {
|
|
250
|
+
const doFetch = (code) => {
|
|
251
|
+
const headers = buildHeaders(url, { npmrc: ctx.npmrc });
|
|
252
|
+
headers['accept'] = 'application/json';
|
|
253
|
+
if (body !== undefined)
|
|
254
|
+
headers['content-type'] = 'application/json';
|
|
255
|
+
if (code)
|
|
256
|
+
headers['npm-otp'] = code;
|
|
257
|
+
const init = { method, headers };
|
|
258
|
+
if (body !== undefined)
|
|
259
|
+
init.body = JSON.stringify(body);
|
|
260
|
+
return fetch(url, init);
|
|
261
|
+
};
|
|
262
|
+
const res = await withOtpRetry(doFetch, ctx.otpProvider);
|
|
263
|
+
const text = await res.text().catch(() => '');
|
|
264
|
+
let json = undefined;
|
|
265
|
+
try {
|
|
266
|
+
json = text ? JSON.parse(text) : undefined;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* non-JSON body — leave json undefined */
|
|
270
|
+
}
|
|
271
|
+
return { status: res.status, json, text };
|
|
272
|
+
};
|
|
303
273
|
}
|
|
304
274
|
function firstLine(s) {
|
|
305
275
|
return s.split('\n')[0].slice(0, 200);
|
|
@@ -19,6 +19,7 @@ import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsi
|
|
|
19
19
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
20
20
|
import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
|
|
21
21
|
import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
22
|
+
import { suggestClosest } from '../utils/suggest.js';
|
|
22
23
|
export const workspaceCommand = {
|
|
23
24
|
command: 'workspace <name> <script> [args..]',
|
|
24
25
|
description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
|
|
@@ -76,8 +77,21 @@ export const workspaceCommand = {
|
|
|
76
77
|
const allWorkspaces = discoverWorkspaces(root);
|
|
77
78
|
const target = allWorkspaces.find((w) => w.name === args.name);
|
|
78
79
|
if (!target) {
|
|
79
|
-
|
|
80
|
-
process.exit(
|
|
80
|
+
// IMPORTANT: `return process.exit(N)`, not a bare `process.exit(N)`.
|
|
81
|
+
// Under GJS `process.exit` cannot terminate synchronously (a parked
|
|
82
|
+
// GLib.MainLoop owns the thread) — it SCHEDULES the exit on a
|
|
83
|
+
// GLib.idle source and returns a forever-pending Promise. A bare
|
|
84
|
+
// call therefore falls through to the next statements, dereferences
|
|
85
|
+
// `target.manifest` on `undefined`, and the resulting throw races
|
|
86
|
+
// the already-scheduled exit into a SECOND `system.exit` →
|
|
87
|
+
// `m_should_exit assertion failed` → core dump. `return`-ing halts
|
|
88
|
+
// the handler so exactly one exit is scheduled. (No-op under Node,
|
|
89
|
+
// where `process.exit` never returns.)
|
|
90
|
+
const suggestion = suggestClosest(args.name, allWorkspaces.map((w) => w.name));
|
|
91
|
+
const hint = suggestion ? ` — did you mean "${suggestion}"?` : '';
|
|
92
|
+
console.error(`gjsify workspace: no workspace named "${args.name}"${hint} ` +
|
|
93
|
+
`(discovered ${allWorkspaces.length} workspace(s) under ${root})`);
|
|
94
|
+
return process.exit(1);
|
|
81
95
|
}
|
|
82
96
|
const runner = detectPackageManager();
|
|
83
97
|
const verbose = args.verbose === true;
|
|
@@ -85,8 +99,12 @@ export const workspaceCommand = {
|
|
|
85
99
|
const continueOnError = args['continue-on-error'] === true;
|
|
86
100
|
const targetScripts = target.manifest.scripts ?? {};
|
|
87
101
|
if (typeof targetScripts[args.script] !== 'string') {
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
const scriptHint = suggestClosest(args.script, Object.keys(targetScripts));
|
|
103
|
+
console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"` +
|
|
104
|
+
(scriptHint ? ` — did you mean "${scriptHint}"?` : ''));
|
|
105
|
+
// `return process.exit` — see the not-found branch above for why a
|
|
106
|
+
// bare `process.exit` is unsafe under GJS.
|
|
107
|
+
return process.exit(1);
|
|
90
108
|
}
|
|
91
109
|
// Build the run-list: either just the target, or its transitive
|
|
92
110
|
// workspace-dep closure in topological order followed by the
|
|
@@ -136,19 +154,22 @@ export const workspaceCommand = {
|
|
|
136
154
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
137
155
|
console.error(`[${ws.name}] ${e.message}`);
|
|
138
156
|
if (!continueOnError) {
|
|
139
|
-
process.exit
|
|
157
|
+
// `return` — a bare `process.exit` would not halt under GJS
|
|
158
|
+
// and the loop would keep running the remaining deps.
|
|
159
|
+
return process.exit(1);
|
|
140
160
|
}
|
|
141
161
|
failures.push({ workspace: ws.name, error: e });
|
|
142
162
|
}
|
|
143
163
|
}
|
|
144
164
|
if (failures.length > 0) {
|
|
145
165
|
console.error(`gjsify workspace: ${failures.length} workspace(s) failed: ${failures.map((f) => f.workspace).join(', ')}`);
|
|
146
|
-
process.exit(1);
|
|
166
|
+
return process.exit(1);
|
|
147
167
|
}
|
|
148
168
|
// ensureMainLoop() (called inside spawn) keeps GJS alive after the
|
|
149
169
|
// child exits — without an explicit process.exit() the success path
|
|
150
|
-
// would park the loop forever.
|
|
151
|
-
|
|
170
|
+
// would park the loop forever. `return` for symmetry with the error
|
|
171
|
+
// paths above (see the not-found branch for the GJS rationale).
|
|
172
|
+
return process.exit(0);
|
|
152
173
|
},
|
|
153
174
|
};
|
|
154
175
|
/**
|
|
@@ -232,7 +253,7 @@ async function runOne(ws, script, extraArgs, runner, verbose, cache) {
|
|
|
232
253
|
if (cache && before)
|
|
233
254
|
cache.storeAfterSuccess(ws, before);
|
|
234
255
|
}
|
|
235
|
-
function detectPackageManager() {
|
|
256
|
+
export function detectPackageManager() {
|
|
236
257
|
// Under GJS there is no Node, so neither `npm` nor `yarn` can run scripts —
|
|
237
258
|
// the only option is `gjsify` itself, resolved via the GJS shim on PATH
|
|
238
259
|
// (see `ensureGjsifyShimOnPath`). This is what makes node-free multi-package
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The literal (non-glob) base directories the build's input source files live
|
|
3
|
+
* in, derived from the bundler `input` patterns. A file entry maps to its
|
|
4
|
+
* containing dir, a glob is reduced to the literal prefix before the first
|
|
5
|
+
* wildcard:
|
|
6
|
+
* - `src/index.ts` → `<cwd>/src`
|
|
7
|
+
* - `src/**` + `/*.ts` → `<cwd>/src`
|
|
8
|
+
* - `src` → `<cwd>/src`
|
|
9
|
+
*
|
|
10
|
+
* The project root itself is intentionally excluded — a flat library that
|
|
11
|
+
* keeps its source at the project root and emits there is an explicit layout,
|
|
12
|
+
* not the `src/`-subdir footgun this guard targets.
|
|
13
|
+
*/
|
|
14
|
+
export declare function inputSourceDirs(input: unknown, cwd: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* `true` when writing library output to `outdir` would land build artifacts
|
|
17
|
+
* inside (or directly on top of) the input source tree. Unsafe iff `outdir`
|
|
18
|
+
* equals, or is nested under, any input source directory.
|
|
19
|
+
*
|
|
20
|
+
* isOutdirInsideSource('src', ['<cwd>/src']) → true (leak)
|
|
21
|
+
* isOutdirInsideSource('src/nest', ['<cwd>/src']) → true (leak)
|
|
22
|
+
* isOutdirInsideSource('dist/esm', ['<cwd>/src']) → false (safe)
|
|
23
|
+
* isOutdirInsideSource('lib', ['<cwd>/src']) → false (safe)
|
|
24
|
+
*/
|
|
25
|
+
export declare function isOutdirInsideSource(outdir: string, sourceDirs: readonly string[], cwd: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The error thrown when a `--library` build would emit into its own source
|
|
28
|
+
* tree. Names both remedies so the fix is actionable at the point of failure.
|
|
29
|
+
*/
|
|
30
|
+
export declare function libraryOutputLeakError(outdir: string): Error;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Guard against the recurring "library build leaks into src/" footgun.
|
|
2
|
+
//
|
|
3
|
+
// `BuildAction.buildLibrary` derives its output directory from
|
|
4
|
+
// `dirname(package.json "module" ?? "main")`. When a package points `main`
|
|
5
|
+
// at its runtime SOURCE entry (`src/index.ts` — the intended entry for
|
|
6
|
+
// Vite/gjsify-compiled consumers, e.g. `@gjsify/adwaita-web`), that derived
|
|
7
|
+
// outdir is `src/`, so `gjsify build --library` writes the compiled `.js`
|
|
8
|
+
// tree straight next to the sources: `src/*.js` / `*2.js` duplicates, a
|
|
9
|
+
// `src/_virtual/` helper dir, and a nested `src/<pkg>/src/**` preserve-modules
|
|
10
|
+
// tree. These pure helpers let the build action refuse that up front instead
|
|
11
|
+
// of silently corrupting the source tree.
|
|
12
|
+
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* The literal (non-glob) base directories the build's input source files live
|
|
15
|
+
* in, derived from the bundler `input` patterns. A file entry maps to its
|
|
16
|
+
* containing dir, a glob is reduced to the literal prefix before the first
|
|
17
|
+
* wildcard:
|
|
18
|
+
* - `src/index.ts` → `<cwd>/src`
|
|
19
|
+
* - `src/**` + `/*.ts` → `<cwd>/src`
|
|
20
|
+
* - `src` → `<cwd>/src`
|
|
21
|
+
*
|
|
22
|
+
* The project root itself is intentionally excluded — a flat library that
|
|
23
|
+
* keeps its source at the project root and emits there is an explicit layout,
|
|
24
|
+
* not the `src/`-subdir footgun this guard targets.
|
|
25
|
+
*/
|
|
26
|
+
export function inputSourceDirs(input, cwd) {
|
|
27
|
+
const patterns = [];
|
|
28
|
+
const push = (v) => {
|
|
29
|
+
if (typeof v === 'string' && v.length > 0)
|
|
30
|
+
patterns.push(v);
|
|
31
|
+
};
|
|
32
|
+
if (typeof input === 'string')
|
|
33
|
+
push(input);
|
|
34
|
+
else if (Array.isArray(input))
|
|
35
|
+
input.forEach(push);
|
|
36
|
+
else if (input && typeof input === 'object')
|
|
37
|
+
Object.values(input).forEach(push);
|
|
38
|
+
const root = resolve(cwd);
|
|
39
|
+
const dirs = new Set();
|
|
40
|
+
for (const raw of patterns) {
|
|
41
|
+
const norm = raw.replace(/\\/g, '/');
|
|
42
|
+
// Cut the pattern at the first glob metacharacter — everything before
|
|
43
|
+
// it is a literal path prefix.
|
|
44
|
+
const globIdx = norm.search(/[*?{}[\]()!+@]/);
|
|
45
|
+
let base = globIdx === -1 ? norm : norm.slice(0, globIdx);
|
|
46
|
+
if (globIdx === -1) {
|
|
47
|
+
// Whole pattern is literal: a file path collapses to its dirname,
|
|
48
|
+
// a bare directory stays as-is.
|
|
49
|
+
base = extname(base) ? dirname(base) : base;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// The glob cut mid-segment (`src/`, `src/fo*`) — drop the trailing
|
|
53
|
+
// partial segment so we keep only the complete literal directory.
|
|
54
|
+
base = base.replace(/\/[^/]*$/, '');
|
|
55
|
+
}
|
|
56
|
+
const abs = resolve(root, base || '.');
|
|
57
|
+
if (abs !== root)
|
|
58
|
+
dirs.add(abs);
|
|
59
|
+
}
|
|
60
|
+
return [...dirs];
|
|
61
|
+
}
|
|
62
|
+
/** `true` when `child` is `parent` itself or nested somewhere under it. */
|
|
63
|
+
function isInsideOrEqual(child, parent) {
|
|
64
|
+
const rel = relative(parent, child);
|
|
65
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `true` when writing library output to `outdir` would land build artifacts
|
|
69
|
+
* inside (or directly on top of) the input source tree. Unsafe iff `outdir`
|
|
70
|
+
* equals, or is nested under, any input source directory.
|
|
71
|
+
*
|
|
72
|
+
* isOutdirInsideSource('src', ['<cwd>/src']) → true (leak)
|
|
73
|
+
* isOutdirInsideSource('src/nest', ['<cwd>/src']) → true (leak)
|
|
74
|
+
* isOutdirInsideSource('dist/esm', ['<cwd>/src']) → false (safe)
|
|
75
|
+
* isOutdirInsideSource('lib', ['<cwd>/src']) → false (safe)
|
|
76
|
+
*/
|
|
77
|
+
export function isOutdirInsideSource(outdir, sourceDirs, cwd) {
|
|
78
|
+
const absOut = resolve(cwd, outdir);
|
|
79
|
+
return sourceDirs.some((dir) => isInsideOrEqual(absOut, dir));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The error thrown when a `--library` build would emit into its own source
|
|
83
|
+
* tree. Names both remedies so the fix is actionable at the point of failure.
|
|
84
|
+
*/
|
|
85
|
+
export function libraryOutputLeakError(outdir) {
|
|
86
|
+
return new Error(`gjsify build --library: refusing to write output into "${outdir}", which holds the ` +
|
|
87
|
+
`input source — a library build there produces src/*.js duplicates, *2.js, a ` +
|
|
88
|
+
`_virtual/ helper dir, and a nested preserve-modules tree, silently corrupting the ` +
|
|
89
|
+
`sources. This output dir was derived from package.json "main"/"module" pointing at ` +
|
|
90
|
+
`a source path. Fix it by pointing "main"/"module" at the built library (e.g. ` +
|
|
91
|
+
`lib/index.js) or by passing an explicit --outdir <dir> outside the source tree.`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** A function that prompts the user for one line (an OTP code). */
|
|
2
|
+
export type OtpPromptFn = (question: string) => Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
|
|
5
|
+
* header naming `otp`, or a body mentioning a one-time password / `EOTP`).
|
|
6
|
+
* Unifies the detection logic previously inlined in both publish.ts and trust.ts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isOtpChallenge(res: Response): Promise<boolean>;
|
|
9
|
+
/**
|
|
10
|
+
* Holds ONE OTP code across a run and knows how to prompt for a fresh one.
|
|
11
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
export declare class OtpProvider {
|
|
18
|
+
private cached;
|
|
19
|
+
private readonly promptFn;
|
|
20
|
+
/**
|
|
21
|
+
* @param seed Initial cached code (from `--otp`), or undefined.
|
|
22
|
+
* @param promptFn How to read a fresh code. Defaults to the TTY-aware
|
|
23
|
+
* `promptLine`. Non-interactive callers pass a function that
|
|
24
|
+
* returns `''` so a challenge surfaces instead of hanging.
|
|
25
|
+
*/
|
|
26
|
+
constructor(seed?: string, promptFn?: OtpPromptFn);
|
|
27
|
+
/** The currently cached OTP code, if any. */
|
|
28
|
+
current(): string | undefined;
|
|
29
|
+
/**
|
|
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").
|
|
32
|
+
*/
|
|
33
|
+
refresh(question?: string): Promise<string>;
|
|
34
|
+
}
|
|
35
|
+
export interface OtpRetryOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Send the cached code on the FIRST attempt instead of trying without an OTP
|
|
38
|
+
* first. `gjsify publish --otp <code>` uses this so the PUT carries the
|
|
39
|
+
* `npm-otp` header immediately (matching npm's `--otp` behaviour). The
|
|
40
|
+
* cache-first flows (`trust`, `onboard`) leave it false so the first attempt
|
|
41
|
+
* exploits npm's ~5-min 2FA-skip window and only sends a code when actually
|
|
42
|
+
* challenged.
|
|
43
|
+
*/
|
|
44
|
+
seedFirstAttempt?: boolean;
|
|
45
|
+
/** Max fresh prompts after the cached code is exhausted. Default 2. */
|
|
46
|
+
maxPrompts?: number;
|
|
47
|
+
/** Prompt text. Default: the standard npm one-time-password question. */
|
|
48
|
+
question?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}.
|
|
52
|
+
*
|
|
53
|
+
* Flow:
|
|
54
|
+
* 1. First attempt — without an OTP (or WITH the cached code when
|
|
55
|
+
* `seedFirstAttempt` is set).
|
|
56
|
+
* 2. On a 2FA challenge, retry with the currently-cached code (unless step 1
|
|
57
|
+
* already sent it).
|
|
58
|
+
* 3. If still challenged (cache empty/rejected/expired), prompt for a fresh
|
|
59
|
+
* code — caching it on the provider so the NEXT operation tries it first —
|
|
60
|
+
* and retry, up to `maxPrompts` times.
|
|
61
|
+
* 4. Return the last response (a challenge the caller must handle) when the
|
|
62
|
+
* user declines to enter a code.
|
|
63
|
+
*
|
|
64
|
+
* The caller owns the actual fetch + all response interpretation; this only
|
|
65
|
+
* owns the OTP dance.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withOtpRetry(doFetch: (otp?: string) => Promise<Response>, provider: OtpProvider, opts?: OtpRetryOptions): Promise<Response>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Shared npm 2FA / OTP handling for the auth-aware CLI commands
|
|
2
|
+
// (`gjsify publish`, `gjsify trust`, `gjsify onboard`).
|
|
3
|
+
//
|
|
4
|
+
// npm signals "OTP required" via HTTP 401 + a `www-authenticate` header
|
|
5
|
+
// containing `otp` (or, for malformed responses, a body mentioning
|
|
6
|
+
// "one-time pass" / `EOTP`) — see
|
|
7
|
+
// refs/npm-cli/node_modules/npm-registry-fetch/lib/check-response.js.
|
|
8
|
+
//
|
|
9
|
+
// Two pieces were previously duplicated in `publish.ts` and `trust.ts`:
|
|
10
|
+
// 1. detecting the challenge (`isOtpChallenge`);
|
|
11
|
+
// 2. prompting for a code, then retrying the request with it.
|
|
12
|
+
//
|
|
13
|
+
// They are centralised here so a single `OtpProvider` can be injected across
|
|
14
|
+
// MANY operations in one run. `gjsify onboard` sweeps ~110 packages and would
|
|
15
|
+
// otherwise prompt once per package; with one shared provider the user types
|
|
16
|
+
// an OTP once — the cached code is tried first on every subsequent challenge,
|
|
17
|
+
// and a fresh prompt only happens when npm actually rejects/expires it.
|
|
18
|
+
//
|
|
19
|
+
// Standalone `publish`/`trust` keep their existing single-command UX: each
|
|
20
|
+
// still constructs its own provider and prompts on demand.
|
|
21
|
+
import { promptLine } from './prompt.js';
|
|
22
|
+
const DEFAULT_QUESTION = 'This operation requires a one-time password.\nEnter OTP: ';
|
|
23
|
+
/**
|
|
24
|
+
* True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
|
|
25
|
+
* header naming `otp`, or a body mentioning a one-time password / `EOTP`).
|
|
26
|
+
* Unifies the detection logic previously inlined in both publish.ts and trust.ts.
|
|
27
|
+
*/
|
|
28
|
+
export async function isOtpChallenge(res) {
|
|
29
|
+
if (res.status !== 401)
|
|
30
|
+
return false;
|
|
31
|
+
const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
|
|
32
|
+
if (wwwAuth.includes('otp'))
|
|
33
|
+
return true;
|
|
34
|
+
const text = await res
|
|
35
|
+
.clone()
|
|
36
|
+
.text()
|
|
37
|
+
.catch(() => '');
|
|
38
|
+
return /one-time pass/i.test(text) || /\bEOTP\b/.test(text);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Holds ONE OTP code across a run and knows how to prompt for a fresh one.
|
|
42
|
+
*
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export class OtpProvider {
|
|
49
|
+
cached;
|
|
50
|
+
promptFn;
|
|
51
|
+
/**
|
|
52
|
+
* @param seed Initial cached code (from `--otp`), or undefined.
|
|
53
|
+
* @param promptFn How to read a fresh code. Defaults to the TTY-aware
|
|
54
|
+
* `promptLine`. Non-interactive callers pass a function that
|
|
55
|
+
* returns `''` so a challenge surfaces instead of hanging.
|
|
56
|
+
*/
|
|
57
|
+
constructor(seed, promptFn = promptLine) {
|
|
58
|
+
this.cached = seed && seed.length > 0 ? seed : undefined;
|
|
59
|
+
this.promptFn = promptFn;
|
|
60
|
+
}
|
|
61
|
+
/** The currently cached OTP code, if any. */
|
|
62
|
+
current() {
|
|
63
|
+
return this.cached;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
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").
|
|
68
|
+
*/
|
|
69
|
+
async refresh(question = DEFAULT_QUESTION) {
|
|
70
|
+
const code = (await this.promptFn(question)).trim();
|
|
71
|
+
if (code)
|
|
72
|
+
this.cached = code;
|
|
73
|
+
return code;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}.
|
|
78
|
+
*
|
|
79
|
+
* Flow:
|
|
80
|
+
* 1. First attempt — without an OTP (or WITH the cached code when
|
|
81
|
+
* `seedFirstAttempt` is set).
|
|
82
|
+
* 2. On a 2FA challenge, retry with the currently-cached code (unless step 1
|
|
83
|
+
* already sent it).
|
|
84
|
+
* 3. If still challenged (cache empty/rejected/expired), prompt for a fresh
|
|
85
|
+
* code — caching it on the provider so the NEXT operation tries it first —
|
|
86
|
+
* and retry, up to `maxPrompts` times.
|
|
87
|
+
* 4. Return the last response (a challenge the caller must handle) when the
|
|
88
|
+
* user declines to enter a code.
|
|
89
|
+
*
|
|
90
|
+
* The caller owns the actual fetch + all response interpretation; this only
|
|
91
|
+
* owns the OTP dance.
|
|
92
|
+
*/
|
|
93
|
+
export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
94
|
+
const maxPrompts = opts.maxPrompts ?? 2;
|
|
95
|
+
const question = opts.question ?? DEFAULT_QUESTION;
|
|
96
|
+
const cached = provider.current();
|
|
97
|
+
const seededFirst = opts.seedFirstAttempt === true && cached !== undefined;
|
|
98
|
+
let res = await doFetch(seededFirst ? cached : undefined);
|
|
99
|
+
if (!(await isOtpChallenge(res)))
|
|
100
|
+
return res;
|
|
101
|
+
// Try the cached code if we haven't already sent it.
|
|
102
|
+
if (!seededFirst && provider.current() !== undefined) {
|
|
103
|
+
res = await doFetch(provider.current());
|
|
104
|
+
if (!(await isOtpChallenge(res)))
|
|
105
|
+
return res;
|
|
106
|
+
}
|
|
107
|
+
// Cache empty, rejected, or expired → prompt for a fresh code and retry.
|
|
108
|
+
for (let i = 0; i < maxPrompts; i++) {
|
|
109
|
+
const fresh = await provider.refresh(question);
|
|
110
|
+
if (!fresh)
|
|
111
|
+
return res; // no input — hand the challenge back to the caller
|
|
112
|
+
res = await doFetch(fresh);
|
|
113
|
+
if (!(await isOtpChallenge(res)))
|
|
114
|
+
return res;
|
|
115
|
+
}
|
|
116
|
+
return res;
|
|
117
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Levenshtein edit distance between two strings (classic DP, single-row
|
|
3
|
+
* rolling buffer). Case-sensitive; callers lower-case first if they want a
|
|
4
|
+
* case-insensitive match.
|
|
5
|
+
*/
|
|
6
|
+
export declare function editDistance(a: string, b: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* The closest candidate to `target` by edit distance, or `undefined` when no
|
|
9
|
+
* candidate is close enough. "Close enough" scales with the target length so
|
|
10
|
+
* a short name doesn't match everything and a long one tolerates a couple of
|
|
11
|
+
* typos: `max(2, floor(len/3))`. Comparison is case-insensitive.
|
|
12
|
+
*/
|
|
13
|
+
export declare function suggestClosest(target: string, candidates: readonly string[]): string | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Small "did you mean …?" helper for CLI name typos (unknown workspace,
|
|
2
|
+
// unknown script, …). Pure — no platform imports — so it is unit-testable
|
|
3
|
+
// off any runtime and safe to pull into a GJS-bundled command.
|
|
4
|
+
/**
|
|
5
|
+
* Levenshtein edit distance between two strings (classic DP, single-row
|
|
6
|
+
* rolling buffer). Case-sensitive; callers lower-case first if they want a
|
|
7
|
+
* case-insensitive match.
|
|
8
|
+
*/
|
|
9
|
+
export function editDistance(a, b) {
|
|
10
|
+
if (a === b)
|
|
11
|
+
return 0;
|
|
12
|
+
if (a.length === 0)
|
|
13
|
+
return b.length;
|
|
14
|
+
if (b.length === 0)
|
|
15
|
+
return a.length;
|
|
16
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
17
|
+
let curr = Array.from({ length: b.length + 1 }, () => 0);
|
|
18
|
+
for (let i = 1; i <= a.length; i++) {
|
|
19
|
+
curr[0] = i;
|
|
20
|
+
for (let j = 1; j <= b.length; j++) {
|
|
21
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
22
|
+
curr[j] = Math.min(prev[j] + 1, // deletion
|
|
23
|
+
curr[j - 1] + 1, // insertion
|
|
24
|
+
prev[j - 1] + cost);
|
|
25
|
+
}
|
|
26
|
+
[prev, curr] = [curr, prev];
|
|
27
|
+
}
|
|
28
|
+
return prev[b.length];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The closest candidate to `target` by edit distance, or `undefined` when no
|
|
32
|
+
* candidate is close enough. "Close enough" scales with the target length so
|
|
33
|
+
* a short name doesn't match everything and a long one tolerates a couple of
|
|
34
|
+
* typos: `max(2, floor(len/3))`. Comparison is case-insensitive.
|
|
35
|
+
*/
|
|
36
|
+
export function suggestClosest(target, candidates) {
|
|
37
|
+
if (candidates.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const t = target.toLowerCase();
|
|
40
|
+
const threshold = Math.max(2, Math.floor(target.length / 3));
|
|
41
|
+
let best;
|
|
42
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
const d = editDistance(t, candidate.toLowerCase());
|
|
45
|
+
if (d < bestDistance) {
|
|
46
|
+
bestDistance = d;
|
|
47
|
+
best = candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return best !== undefined && bestDistance <= threshold ? best : undefined;
|
|
51
|
+
}
|