@octanejs/cli 0.0.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/package.json +50 -0
  4. package/src/bin/octane.js +4 -0
  5. package/src/commands/add.js +138 -0
  6. package/src/commands/analyze.js +271 -0
  7. package/src/commands/bindings.js +55 -0
  8. package/src/commands/doctor/check.js +43 -0
  9. package/src/commands/doctor/checks/bundler.js +104 -0
  10. package/src/commands/doctor/checks/config.js +184 -0
  11. package/src/commands/doctor/checks/dependencies.js +120 -0
  12. package/src/commands/doctor/checks/environment.js +38 -0
  13. package/src/commands/doctor/checks/source.js +108 -0
  14. package/src/commands/doctor/checks/typescript.js +183 -0
  15. package/src/commands/doctor/index.js +118 -0
  16. package/src/commands/doctor/registry.js +32 -0
  17. package/src/commands/doctor/report.js +158 -0
  18. package/src/commands/explain.js +95 -0
  19. package/src/commands/info.js +54 -0
  20. package/src/commands/init/index.js +277 -0
  21. package/src/commands/init/templates.js +124 -0
  22. package/src/commands/mcp/add.js +241 -0
  23. package/src/commands/mcp/clients.js +281 -0
  24. package/src/commands/mcp/detect.js +58 -0
  25. package/src/commands/mcp/index.js +23 -0
  26. package/src/commands/mcp/remove.js +105 -0
  27. package/src/commands/mcp/server.js +46 -0
  28. package/src/commands/mcp/status.js +48 -0
  29. package/src/data/index.js +74 -0
  30. package/src/data/octane-data.json +953 -0
  31. package/src/index.js +4 -0
  32. package/src/kernel/args.js +181 -0
  33. package/src/kernel/banner.js +98 -0
  34. package/src/kernel/command.js +84 -0
  35. package/src/kernel/context.js +78 -0
  36. package/src/kernel/edit.js +238 -0
  37. package/src/kernel/errors.js +42 -0
  38. package/src/kernel/exec.js +62 -0
  39. package/src/kernel/help.js +97 -0
  40. package/src/kernel/install.js +43 -0
  41. package/src/kernel/jsonc.js +91 -0
  42. package/src/kernel/main.js +166 -0
  43. package/src/kernel/project.js +376 -0
  44. package/src/kernel/registry.js +52 -0
  45. package/src/kernel/semver.js +111 -0
  46. package/src/kernel/ui.js +155 -0
@@ -0,0 +1,376 @@
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJsonc } from './jsonc.js';
4
+
5
+ const IGNORED_DIRS = new Set([
6
+ 'node_modules',
7
+ 'dist',
8
+ 'build',
9
+ '.git',
10
+ 'coverage',
11
+ '.next',
12
+ '.vercel',
13
+ ]);
14
+ const SOURCE_EXTENSIONS = new Set(['.tsrx', '.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs']);
15
+ const SOURCE_FILE_LIMIT = 5000;
16
+ const EXTENDS_LIMIT = 10;
17
+
18
+ const LOCKFILES = /** @type {const} */ ([
19
+ ['pnpm-lock.yaml', 'pnpm'],
20
+ ['package-lock.json', 'npm'],
21
+ ['yarn.lock', 'yarn'],
22
+ ['bun.lockb', 'bun'],
23
+ ['bun.lock', 'bun'],
24
+ ]);
25
+
26
+ const BUNDLER_CONFIGS = /** @type {const} */ ([
27
+ ['vite.config', 'vite'],
28
+ ['rspack.config', 'rspack'],
29
+ ['rsbuild.config', 'rsbuild'],
30
+ ['lynx.config', 'rspeedy'],
31
+ ]);
32
+
33
+ const CONFIG_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs', '.cts', '.cjs'];
34
+
35
+ /**
36
+ * @param {string} base
37
+ * @returns {string | null}
38
+ */
39
+ function firstExisting(base) {
40
+ for (const ext of CONFIG_EXTENSIONS) {
41
+ if (existsSync(base + ext)) return base + ext;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Nearest ancestor directory containing a `package.json`.
48
+ *
49
+ * @param {string} cwd
50
+ * @returns {string}
51
+ */
52
+ export function findRoot(cwd) {
53
+ let dir = path.resolve(cwd);
54
+ for (;;) {
55
+ if (existsSync(path.join(dir, 'package.json'))) return dir;
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir) return path.resolve(cwd);
58
+ dir = parent;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @param {string} root
64
+ * @param {string} spec
65
+ * @param {string} fromDir
66
+ * @returns {string | null}
67
+ */
68
+ function resolveExtends(root, spec, fromDir) {
69
+ if (spec.startsWith('.') || path.isAbsolute(spec)) {
70
+ const base = path.resolve(fromDir, spec);
71
+ if (existsSync(base) && !spec.endsWith('.json')) return path.join(base, 'tsconfig.json');
72
+ return existsSync(base) ? base : `${base}.json`;
73
+ }
74
+ const candidate = path.join(root, 'node_modules', spec);
75
+ if (existsSync(candidate)) {
76
+ return existsSync(path.join(candidate, 'tsconfig.json'))
77
+ ? path.join(candidate, 'tsconfig.json')
78
+ : candidate;
79
+ }
80
+ return existsSync(`${candidate}.json`) ? `${candidate}.json` : null;
81
+ }
82
+
83
+ /**
84
+ * Load a tsconfig with its `extends` chain flattened. `compilerOptions` merges
85
+ * shallowly with the child winning, which is what TypeScript itself does and is
86
+ * the only part the doctor checks read.
87
+ *
88
+ * @param {string} root
89
+ * @param {string} [file]
90
+ * @returns {{ path: string, config: any, chain: string[] } | null}
91
+ */
92
+ export function loadTsconfig(root, file = path.join(root, 'tsconfig.json')) {
93
+ if (!existsSync(file)) return null;
94
+
95
+ /** @type {string[]} */
96
+ const chain = [];
97
+ /** @type {any} */
98
+ let merged = {};
99
+ /** @type {string | null} */
100
+ let current = file;
101
+
102
+ while (current && chain.length < EXTENDS_LIMIT) {
103
+ const config = readJsonc(current);
104
+ if (!config) break;
105
+ chain.push(current);
106
+
107
+ merged = {
108
+ ...config,
109
+ ...merged,
110
+ compilerOptions: { ...(config.compilerOptions ?? {}), ...(merged.compilerOptions ?? {}) },
111
+ };
112
+
113
+ const parent = config.extends;
114
+ if (typeof parent !== 'string') break;
115
+ current = resolveExtends(root, parent, path.dirname(current));
116
+ }
117
+
118
+ return chain.length > 0 ? { path: file, config: merged, chain } : null;
119
+ }
120
+
121
+ /**
122
+ * Read an installed package's manifest by walking `node_modules` upwards. Done
123
+ * by hand rather than through `require.resolve` because most packages do not
124
+ * expose `./package.json` in their export map.
125
+ *
126
+ * @param {string} root
127
+ * @param {string} name
128
+ * @returns {{ version: string, dir: string, manifest: any } | null}
129
+ */
130
+ export function readInstalled(root, name) {
131
+ let dir = path.resolve(root);
132
+ for (;;) {
133
+ const candidate = path.join(dir, 'node_modules', name);
134
+ const manifest = readJsonc(path.join(candidate, 'package.json'));
135
+ if (manifest) return { version: String(manifest.version ?? '0.0.0'), dir: candidate, manifest };
136
+ const parent = path.dirname(dir);
137
+ if (parent === dir) return null;
138
+ dir = parent;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Every physically distinct copy of a package reachable from the project's
144
+ * direct dependencies.
145
+ *
146
+ * Two copies of `octane` in one tree is the single worst failure mode in the
147
+ * ecosystem: hooks and context are keyed per-runtime, so a duplicate breaks
148
+ * them silently rather than loudly. The scan is deliberately bounded to the
149
+ * top level and one level of nesting, which is where real duplicates surface,
150
+ * instead of walking the whole tree.
151
+ *
152
+ * @param {string} root
153
+ * @param {string} name
154
+ * @returns {{ version: string, realPath: string, via: string }[]}
155
+ */
156
+ export function findCopies(root, name) {
157
+ const modules = path.join(root, 'node_modules');
158
+ if (!existsSync(modules)) return [];
159
+
160
+ /** @type {Map<string, { version: string, realPath: string, via: string }>} */
161
+ const found = new Map();
162
+
163
+ const record = (/** @type {string} */ dir, /** @type {string} */ via) => {
164
+ const manifest = readJsonc(path.join(dir, 'package.json'));
165
+ if (!manifest) return;
166
+ let realPath = dir;
167
+ try {
168
+ realPath = realpathSync(dir);
169
+ } catch {
170
+ // Broken link; report the logical path.
171
+ }
172
+ if (!found.has(realPath)) {
173
+ found.set(realPath, { version: String(manifest.version ?? '0.0.0'), realPath, via });
174
+ }
175
+ };
176
+
177
+ record(path.join(modules, name), 'the project');
178
+
179
+ /** @type {string[]} */
180
+ const owners = [];
181
+ for (const entry of safeReaddir(modules)) {
182
+ if (entry.startsWith('.')) continue;
183
+ if (entry.startsWith('@')) {
184
+ for (const scoped of safeReaddir(path.join(modules, entry))) {
185
+ owners.push(`${entry}/${scoped}`);
186
+ }
187
+ } else {
188
+ owners.push(entry);
189
+ }
190
+ }
191
+
192
+ for (const owner of owners) {
193
+ if (owner === name) continue;
194
+ record(path.join(modules, owner, 'node_modules', name), owner);
195
+ }
196
+
197
+ return [...found.values()];
198
+ }
199
+
200
+ /**
201
+ * @param {string} dir
202
+ * @returns {string[]}
203
+ */
204
+ function safeReaddir(dir) {
205
+ try {
206
+ return readdirSync(dir);
207
+ } catch {
208
+ return [];
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Walk project sources, skipping build output and dependencies.
214
+ *
215
+ * @param {string} root
216
+ * @param {{ limit?: number }} [options]
217
+ * @returns {string[]}
218
+ */
219
+ export function scanSourceFiles(root, { limit = SOURCE_FILE_LIMIT } = {}) {
220
+ /** @type {string[]} */
221
+ const files = [];
222
+ /** @type {string[]} */
223
+ const queue = [root];
224
+
225
+ while (queue.length > 0 && files.length < limit) {
226
+ const dir = queue.pop();
227
+ if (dir === undefined) break;
228
+ let entries;
229
+ try {
230
+ entries = readdirSync(dir, { withFileTypes: true });
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (const entry of entries) {
235
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
236
+ const absolute = path.join(dir, entry.name);
237
+ if (entry.isDirectory()) {
238
+ if (!IGNORED_DIRS.has(entry.name)) queue.push(absolute);
239
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
240
+ files.push(absolute);
241
+ if (files.length >= limit) break;
242
+ }
243
+ }
244
+ }
245
+
246
+ return files;
247
+ }
248
+
249
+ /**
250
+ * @typedef {Object} Project
251
+ * @property {string} root
252
+ * @property {string | null} manifestPath null when no package.json exists
253
+ * anywhere up the tree, which is what separates "a project" from "a directory"
254
+ * @property {any} manifest
255
+ * @property {Record<string, string>} declaredDependencies every dependency kind, merged
256
+ * @property {string[]} bindings installed `@octanejs/*` package names
257
+ * @property {'pnpm' | 'npm' | 'yarn' | 'bun' | null} packageManager
258
+ * @property {string | null} lockfile
259
+ * @property {{ path: string, config: any, chain: string[] } | null} tsconfig
260
+ * @property {string | null} bundlerConfigPath
261
+ * @property {'vite' | 'rspack' | 'rsbuild' | 'rspeedy' | null} bundler
262
+ * @property {string | null} octaneConfigPath
263
+ * @property {string[]} sourceFiles
264
+ * @property {string[]} tsrxFiles
265
+ * @property {boolean} isOctaneProject
266
+ */
267
+
268
+ /**
269
+ * Detect everything the commands share about a project, in one pass. Commands
270
+ * read this model instead of re-globbing, so a `doctor` run touches the tree
271
+ * once.
272
+ *
273
+ * @param {string} cwd
274
+ * @returns {Project}
275
+ */
276
+ export function detectProject(cwd) {
277
+ const root = findRoot(cwd);
278
+ const manifestPath = path.join(root, 'package.json');
279
+ const manifest = readJsonc(manifestPath);
280
+
281
+ const declaredDependencies = {
282
+ ...(manifest?.dependencies ?? {}),
283
+ ...(manifest?.devDependencies ?? {}),
284
+ ...(manifest?.peerDependencies ?? {}),
285
+ };
286
+
287
+ // Walk up for the lockfile: in a monorepo it lives at the workspace root, not
288
+ // beside the package being inspected.
289
+ let packageManager = /** @type {Project['packageManager']} */ (null);
290
+ let lockfile = null;
291
+ for (let dir = root; ;) {
292
+ for (const [file, name] of LOCKFILES) {
293
+ if (existsSync(path.join(dir, file))) {
294
+ lockfile = path.relative(root, path.join(dir, file)) || file;
295
+ packageManager = name;
296
+ break;
297
+ }
298
+ }
299
+ const parent = path.dirname(dir);
300
+ if (lockfile || parent === dir) break;
301
+ dir = parent;
302
+ }
303
+
304
+ if (!packageManager && typeof manifest?.packageManager === 'string') {
305
+ packageManager = /** @type {Project['packageManager']} */ (
306
+ manifest.packageManager.split('@')[0]
307
+ );
308
+ }
309
+
310
+ let bundlerConfigPath = null;
311
+ let bundler = /** @type {Project['bundler']} */ (null);
312
+ for (const [base, name] of BUNDLER_CONFIGS) {
313
+ const found = firstExisting(path.join(root, base));
314
+ if (found) {
315
+ bundlerConfigPath = found;
316
+ bundler = name;
317
+ break;
318
+ }
319
+ }
320
+
321
+ const sourceFiles = scanSourceFiles(root);
322
+ const tsrxFiles = sourceFiles.filter((file) => file.endsWith('.tsrx'));
323
+
324
+ return {
325
+ root,
326
+ manifestPath: manifest === null ? null : manifestPath,
327
+ manifest: manifest ?? {},
328
+ declaredDependencies,
329
+ bindings: Object.keys(declaredDependencies).filter((name) => name.startsWith('@octanejs/')),
330
+ packageManager,
331
+ lockfile,
332
+ tsconfig: loadTsconfig(root),
333
+ bundlerConfigPath,
334
+ bundler,
335
+ octaneConfigPath: firstExisting(path.join(root, 'octane.config')),
336
+ sourceFiles,
337
+ tsrxFiles,
338
+ isOctaneProject:
339
+ 'octane' in declaredDependencies ||
340
+ tsrxFiles.length > 0 ||
341
+ readInstalled(root, 'octane') !== null,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * The shared, serializable view of a project. `doctor --json` and `info` both
347
+ * report it, so a bug report and a CI result describe the same thing.
348
+ *
349
+ * @param {Project} project
350
+ * @returns {Record<string, unknown>}
351
+ */
352
+ export function summarizeProject(project) {
353
+ const octane = readInstalled(project.root, 'octane');
354
+
355
+ return {
356
+ root: project.root,
357
+ name: project.manifest.name ?? null,
358
+ hasManifest: project.manifestPath !== null,
359
+ packageManager: project.packageManager,
360
+ lockfile: project.lockfile,
361
+ bundler: project.bundler,
362
+ bundlerConfig: project.bundlerConfigPath
363
+ ? path.relative(project.root, project.bundlerConfigPath)
364
+ : null,
365
+ octaneConfig: project.octaneConfigPath
366
+ ? path.relative(project.root, project.octaneConfigPath)
367
+ : null,
368
+ octane: octane?.version ?? null,
369
+ tsconfig: project.tsconfig ? path.relative(project.root, project.tsconfig.path) : null,
370
+ tsrxFileCount: project.tsrxFiles.length,
371
+ bindings: project.bindings.map((name) => ({
372
+ name,
373
+ version: readInstalled(project.root, name)?.version ?? null,
374
+ })),
375
+ };
376
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The root command table.
3
+ *
4
+ * Adding a command is one directory under `src/commands/` plus one entry here.
5
+ * The list is static so it stays greppable and cannot drift out of sync with
6
+ * the help output, while `load` keeps every command module off the startup
7
+ * path until it is the one being run.
8
+ *
9
+ * @type {import('./command.js').CommandEntry[]}
10
+ */
11
+ export const COMMANDS = [
12
+ {
13
+ name: 'init',
14
+ summary: 'Wire Octane into the project in this directory.',
15
+ load: () => import('../commands/init/index.js'),
16
+ },
17
+ {
18
+ name: 'doctor',
19
+ summary: 'Diagnose an Octane project and optionally repair it.',
20
+ load: () => import('../commands/doctor/index.js'),
21
+ },
22
+ {
23
+ name: 'analyze',
24
+ summary: 'Compile the project and report the Octane compiler diagnostics.',
25
+ load: () => import('../commands/analyze.js'),
26
+ },
27
+ {
28
+ name: 'info',
29
+ summary: 'Print environment and project details for a bug report.',
30
+ load: () => import('../commands/info.js'),
31
+ },
32
+ {
33
+ name: 'add',
34
+ summary: 'Install an Octane binding and report how it differs from upstream.',
35
+ load: () => import('../commands/add.js'),
36
+ },
37
+ {
38
+ name: 'bindings',
39
+ summary: 'List and search the @octanejs/* bindings.',
40
+ load: () => import('../commands/bindings.js'),
41
+ },
42
+ {
43
+ name: 'explain',
44
+ summary: 'Explain an Octane runtime error code.',
45
+ load: () => import('../commands/explain.js'),
46
+ },
47
+ {
48
+ name: 'mcp',
49
+ summary: 'Register the Octane MCP server with Claude Code, Codex, Cursor, or VS Code.',
50
+ load: () => import('../commands/mcp/index.js'),
51
+ },
52
+ ];
@@ -0,0 +1,111 @@
1
+ // Minor and patch are optional so that partial ranges (`>=22`, `^8.0`) parse.
2
+ const VERSION = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/;
3
+ const COMPARATOR = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/;
4
+
5
+ /**
6
+ * @typedef {{ parts: [number, number, number], prerelease: string | null }} Version
7
+ */
8
+
9
+ /**
10
+ * @param {string} input
11
+ * @returns {Version | null}
12
+ */
13
+ export function parseVersion(input) {
14
+ const match = VERSION.exec(String(input).trim());
15
+ if (!match) return null;
16
+ return {
17
+ parts: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)],
18
+ prerelease: match[4] ?? null,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * @param {Version} a
24
+ * @param {Version} b
25
+ * @returns {-1 | 0 | 1}
26
+ */
27
+ export function compareVersions(a, b) {
28
+ for (let i = 0; i < 3; i++) {
29
+ if (a.parts[i] !== b.parts[i]) return a.parts[i] < b.parts[i] ? -1 : 1;
30
+ }
31
+ if (a.prerelease === b.prerelease) return 0;
32
+ if (a.prerelease === null) return 1;
33
+ if (b.prerelease === null) return -1;
34
+ return a.prerelease < b.prerelease ? -1 : 1;
35
+ }
36
+
37
+ /**
38
+ * @param {Version} version
39
+ * @param {string} operator
40
+ * @param {Version} target
41
+ * @returns {boolean}
42
+ */
43
+ function test(version, operator, target) {
44
+ const order = compareVersions(version, target);
45
+ switch (operator) {
46
+ case '>=':
47
+ return order >= 0;
48
+ case '>':
49
+ return order > 0;
50
+ case '<=':
51
+ return order <= 0;
52
+ case '<':
53
+ return order < 0;
54
+ case '^': {
55
+ if (order < 0) return false;
56
+ const [major, minor] = target.parts;
57
+ if (major > 0) return version.parts[0] === major;
58
+ if (minor > 0) return version.parts[0] === 0 && version.parts[1] === minor;
59
+ return (
60
+ version.parts[0] === 0 && version.parts[1] === 0 && version.parts[2] === target.parts[2]
61
+ );
62
+ }
63
+ case '~':
64
+ return (
65
+ order >= 0 && version.parts[0] === target.parts[0] && version.parts[1] === target.parts[1]
66
+ );
67
+ default:
68
+ return order === 0;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Evaluate a version against a range.
74
+ *
75
+ * Deliberately narrow: it understands the comparator forms that Octane and its
76
+ * bindings actually publish, and returns `null` for anything else (`workspace:*`,
77
+ * git URLs, tags) so callers can say "not verified" instead of inventing a
78
+ * verdict.
79
+ *
80
+ * @param {string} version
81
+ * @param {string} range
82
+ * @returns {boolean | null}
83
+ */
84
+ export function satisfies(version, range) {
85
+ const current = parseVersion(version);
86
+ if (!current) return null;
87
+
88
+ const trimmed = String(range).trim();
89
+ if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'latest') return true;
90
+
91
+ for (const union of trimmed.split('||')) {
92
+ let matched = true;
93
+ let evaluated = false;
94
+
95
+ for (const token of union.trim().split(/\s+/)) {
96
+ if (token === '') continue;
97
+ const parsed = COMPARATOR.exec(token);
98
+ const target = parsed && parseVersion(parsed[2]);
99
+ if (!target) return null;
100
+ evaluated = true;
101
+ if (!test(current, parsed[1] ?? '=', target)) {
102
+ matched = false;
103
+ break;
104
+ }
105
+ }
106
+
107
+ if (evaluated && matched) return true;
108
+ }
109
+
110
+ return false;
111
+ }
@@ -0,0 +1,155 @@
1
+ import * as clack from '@clack/prompts';
2
+ import { createColors } from 'picocolors';
3
+ import { CliError, usageError } from './errors.js';
4
+
5
+ /**
6
+ * @typedef {'interactive' | 'plain' | 'json'} UiMode
7
+ *
8
+ * - `interactive` a TTY session: clack prompts, colour, spinners
9
+ * - `plain` a pipe, a CI log, or `--no-color`: line-oriented text only
10
+ * - `json` `--json`: every human channel is silenced so stdout carries
11
+ * exactly one JSON document
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} Choice
16
+ * @property {string} value
17
+ * @property {string} label
18
+ * @property {string} [hint]
19
+ */
20
+
21
+ export const SYMBOLS = {
22
+ pass: '✔',
23
+ fail: '✖',
24
+ warn: '⚠',
25
+ skip: '○',
26
+ };
27
+
28
+ /**
29
+ * @param {{ json?: boolean, color?: boolean, tty?: boolean, env?: NodeJS.ProcessEnv }} options
30
+ * @returns {UiMode}
31
+ */
32
+ export function resolveMode({ json = false, color = true, tty = false, env = {} } = {}) {
33
+ if (json) return 'json';
34
+ if (!tty || !color || env.NO_COLOR || env.CI) return 'plain';
35
+ return 'interactive';
36
+ }
37
+
38
+ /**
39
+ * @param {{ mode: UiMode, yes?: boolean, stdout?: NodeJS.WritableStream }} options
40
+ */
41
+ export function createUi({ mode, yes = false, stdout = process.stdout }) {
42
+ const quiet = mode === 'json';
43
+ const colors = createColors(mode === 'interactive');
44
+ const write = (/** @type {string} */ line) => {
45
+ if (!quiet) stdout.write(`${line}\n`);
46
+ };
47
+
48
+ /**
49
+ * Non-interactive sessions never block. Either `--yes` supplies the default
50
+ * or the caller is told the exact flag that would have answered the prompt.
51
+ *
52
+ * @param {string} flag
53
+ * @param {unknown} fallback
54
+ */
55
+ const unattended = (flag, fallback) => {
56
+ if (yes && fallback !== undefined) return fallback;
57
+ throw usageError(
58
+ `${flag} is required when the CLI is not interactive.`,
59
+ `Pass ${flag}, or re-run in a terminal to be prompted.`,
60
+ );
61
+ };
62
+
63
+ /** @param {unknown} value */
64
+ const guard = (value) => {
65
+ if (clack.isCancel(value)) throw new CliError('Cancelled.');
66
+ return value;
67
+ };
68
+
69
+ return {
70
+ mode,
71
+ colors,
72
+ get canPrompt() {
73
+ return mode === 'interactive';
74
+ },
75
+
76
+ /** @param {string} title */
77
+ intro(title) {
78
+ if (mode === 'interactive') clack.intro(colors.bgCyan(colors.black(` ${title} `)));
79
+ else write(title);
80
+ },
81
+
82
+ /** @param {string} message */
83
+ outro(message) {
84
+ if (mode === 'interactive') clack.outro(message);
85
+ else write(message);
86
+ },
87
+
88
+ /** @param {string} [line] */
89
+ log(line = '') {
90
+ write(line);
91
+ },
92
+
93
+ /**
94
+ * @param {string} title
95
+ * @param {string[]} lines
96
+ */
97
+ note(title, lines) {
98
+ if (mode === 'interactive') clack.note(lines.join('\n'), title);
99
+ else {
100
+ write(title);
101
+ for (const line of lines) write(` ${line}`);
102
+ }
103
+ },
104
+
105
+ /** @param {string} label */
106
+ spinner(label) {
107
+ if (mode !== 'interactive') {
108
+ write(label);
109
+ return { update() {}, stop: (/** @type {string} */ done) => done && write(` ${done}`) };
110
+ }
111
+ const spin = clack.spinner();
112
+ spin.start(label);
113
+ return {
114
+ update: (/** @type {string} */ message) => spin.message(message),
115
+ stop: (/** @type {string} */ message) => spin.stop(message ?? label),
116
+ };
117
+ },
118
+
119
+ /**
120
+ * Choices are always strings (command names, client ids, mode names), so
121
+ * this deliberately is not generic: callers narrow the result themselves.
122
+ *
123
+ * @param {{ message: string, flag: string, options: Choice[], initial?: string }} options
124
+ * @returns {Promise<string>}
125
+ */
126
+ async select({ message, flag, options, initial }) {
127
+ if (mode !== 'interactive') return String(unattended(flag, initial));
128
+ return String(guard(await clack.select({ message, options, initialValue: initial })));
129
+ },
130
+
131
+ /**
132
+ * @param {{ message: string, flag: string, options: Choice[], initial?: string[] }} options
133
+ * @returns {Promise<string[]>}
134
+ */
135
+ async multiselect({ message, flag, options, initial }) {
136
+ if (mode !== 'interactive') {
137
+ return /** @type {string[]} */ (unattended(flag, initial));
138
+ }
139
+ return /** @type {string[]} */ (
140
+ guard(await clack.multiselect({ message, options, initialValues: initial }))
141
+ );
142
+ },
143
+
144
+ /**
145
+ * @param {{ message: string, flag: string, initial?: boolean }} options
146
+ * @returns {Promise<boolean>}
147
+ */
148
+ async confirm({ message, flag, initial = true }) {
149
+ if (mode !== 'interactive') return Boolean(unattended(flag, initial));
150
+ return Boolean(guard(await clack.confirm({ message, initialValue: initial })));
151
+ },
152
+ };
153
+ }
154
+
155
+ /** @typedef {ReturnType<typeof createUi>} Ui */