@gjsify/cli 0.14.0 → 0.16.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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -95,7 +95,8 @@
95
95
  ]
96
96
  }
97
97
  ]
98
- }
98
+ },
99
+ "tier": 1
99
100
  },
100
101
  "files": [
101
102
  "lib",
@@ -121,19 +122,19 @@
121
122
  "cli"
122
123
  ],
123
124
  "dependencies": {
124
- "@gjsify/buffer": "^0.14.0",
125
- "@gjsify/create-app": "^0.14.0",
126
- "@gjsify/node-globals": "^0.14.0",
127
- "@gjsify/node-polyfills": "^0.14.0",
128
- "@gjsify/npm-registry": "^0.14.0",
129
- "@gjsify/resolve-npm": "^0.14.0",
130
- "@gjsify/rolldown-plugin-gjsify": "^0.14.0",
131
- "@gjsify/rolldown-plugin-pnp": "^0.14.0",
132
- "@gjsify/semver": "^0.14.0",
133
- "@gjsify/tar": "^0.14.0",
134
- "@gjsify/tsc": "^0.14.0",
135
- "@gjsify/web-polyfills": "^0.14.0",
136
- "@gjsify/workspace": "^0.14.0",
125
+ "@gjsify/buffer": "^0.16.0",
126
+ "@gjsify/create-app": "^0.16.0",
127
+ "@gjsify/node-globals": "^0.16.0",
128
+ "@gjsify/node-polyfills": "^0.16.0",
129
+ "@gjsify/npm-registry": "^0.16.0",
130
+ "@gjsify/resolve-npm": "^0.16.0",
131
+ "@gjsify/rolldown-plugin-gjsify": "^0.16.0",
132
+ "@gjsify/rolldown-plugin-pnp": "^0.16.0",
133
+ "@gjsify/semver": "^0.16.0",
134
+ "@gjsify/tar": "^0.16.0",
135
+ "@gjsify/tsc": "^0.16.0",
136
+ "@gjsify/web-polyfills": "^0.16.0",
137
+ "@gjsify/workspace": "^0.16.0",
137
138
  "cosmiconfig": "^9.0.2",
138
139
  "get-tsconfig": "^4.14.0",
139
140
  "pkg-types": "^2.3.1",
@@ -141,13 +142,13 @@
141
142
  "yargs": "^18.0.0"
142
143
  },
143
144
  "devDependencies": {
144
- "@gjsify/unit": "^0.14.0",
145
+ "@gjsify/unit": "^0.16.0",
145
146
  "@types/yargs": "^17.0.35",
146
147
  "typescript": "^6.0.3"
147
148
  },
148
149
  "peerDependencies": {
149
- "@gjsify/oxfmt-native": "^0.14.0",
150
- "@gjsify/rolldown-native": "^0.14.0"
150
+ "@gjsify/oxfmt-native": "^0.16.0",
151
+ "@gjsify/rolldown-native": "^0.16.0"
151
152
  },
152
153
  "peerDependenciesMeta": {
153
154
  "@gjsify/oxfmt-native": {