@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.
@@ -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 };
@@ -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 { promptLine } from '../utils/prompt.js';
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
- // A one-shot OTP supplied via --otp, used on the FIRST challenge then
152
- // cleared (a TOTP code is single-use within its ~30s window). After the
153
- // first successful OTP the registry opens a ~5-min skip window, so we
154
- // do NOT cache + re-send the code — that would re-challenge once it
155
- // expires. Instead each request tries WITHOUT an OTP first (the window
156
- // covers it) and only prompts when actually challenged. Mirrors npm's
157
- // `otplease`.
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
- /** True when a response is an npm OTP / 2FA challenge. */
292
- async function isOtpChallenge(res) {
293
- if (res.status !== 401)
294
- return false;
295
- const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
296
- if (wwwAuth.includes('otp'))
297
- return true;
298
- const text = await res
299
- .clone()
300
- .text()
301
- .catch(() => '');
302
- return /one-time pass/i.test(text) || /\bEOTP\b/.test(text);
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);
@@ -10,4 +10,5 @@ interface WorkspaceCmdOptions {
10
10
  cached?: boolean;
11
11
  }
12
12
  export declare const workspaceCommand: Command<unknown, WorkspaceCmdOptions>;
13
+ export declare function detectPackageManager(): 'yarn' | 'npm' | 'gjsify';
13
14
  export {};
@@ -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
- console.error(`gjsify workspace: no workspace named "${args.name}" — discovered ${allWorkspaces.length} workspace(s)`);
80
- process.exit(1);
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
- console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"`);
89
- process.exit(1);
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(1);
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
- process.exit(0);
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
+ }