@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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/package.json +50 -0
- package/src/bin/octane.js +4 -0
- package/src/commands/add.js +138 -0
- package/src/commands/analyze.js +271 -0
- package/src/commands/bindings.js +55 -0
- package/src/commands/doctor/check.js +43 -0
- package/src/commands/doctor/checks/bundler.js +104 -0
- package/src/commands/doctor/checks/config.js +184 -0
- package/src/commands/doctor/checks/dependencies.js +120 -0
- package/src/commands/doctor/checks/environment.js +38 -0
- package/src/commands/doctor/checks/source.js +108 -0
- package/src/commands/doctor/checks/typescript.js +183 -0
- package/src/commands/doctor/index.js +118 -0
- package/src/commands/doctor/registry.js +32 -0
- package/src/commands/doctor/report.js +158 -0
- package/src/commands/explain.js +95 -0
- package/src/commands/info.js +54 -0
- package/src/commands/init/index.js +277 -0
- package/src/commands/init/templates.js +124 -0
- package/src/commands/mcp/add.js +241 -0
- package/src/commands/mcp/clients.js +281 -0
- package/src/commands/mcp/detect.js +58 -0
- package/src/commands/mcp/index.js +23 -0
- package/src/commands/mcp/remove.js +105 -0
- package/src/commands/mcp/server.js +46 -0
- package/src/commands/mcp/status.js +48 -0
- package/src/data/index.js +74 -0
- package/src/data/octane-data.json +953 -0
- package/src/index.js +4 -0
- package/src/kernel/args.js +181 -0
- package/src/kernel/banner.js +98 -0
- package/src/kernel/command.js +84 -0
- package/src/kernel/context.js +78 -0
- package/src/kernel/edit.js +238 -0
- package/src/kernel/errors.js +42 -0
- package/src/kernel/exec.js +62 -0
- package/src/kernel/help.js +97 -0
- package/src/kernel/install.js +43 -0
- package/src/kernel/jsonc.js +91 -0
- package/src/kernel/main.js +166 -0
- package/src/kernel/project.js +376 -0
- package/src/kernel/registry.js +52 -0
- package/src/kernel/semver.js +111 -0
- 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
|
+
}
|
package/src/kernel/ui.js
ADDED
|
@@ -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 */
|