@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes are part of the CLI's contract: CI and agents branch on them, so
|
|
3
|
+
* they are fixed here rather than invented per command.
|
|
4
|
+
*
|
|
5
|
+
* - `OK` nothing to report
|
|
6
|
+
* - `FAILURE` the command ran and failed
|
|
7
|
+
* - `USAGE` the invocation was wrong (unknown flag, missing input)
|
|
8
|
+
* - `DIAGNOSTIC` the command ran fine and reported problems (doctor findings)
|
|
9
|
+
*/
|
|
10
|
+
export const EXIT = {
|
|
11
|
+
OK: 0,
|
|
12
|
+
FAILURE: 1,
|
|
13
|
+
USAGE: 2,
|
|
14
|
+
DIAGNOSTIC: 3,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An error with a presentation. Anything thrown as a `CliError` is rendered as
|
|
19
|
+
* a message the caller can act on; anything else is an internal bug and is
|
|
20
|
+
* reported with its stack.
|
|
21
|
+
*/
|
|
22
|
+
export class CliError extends Error {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} message
|
|
25
|
+
* @param {{ exitCode?: number, hint?: string }} [options]
|
|
26
|
+
*/
|
|
27
|
+
constructor(message, options = {}) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'CliError';
|
|
30
|
+
this.exitCode = options.exitCode ?? EXIT.FAILURE;
|
|
31
|
+
this.hint = options.hint;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} message
|
|
37
|
+
* @param {string} [hint]
|
|
38
|
+
* @returns {CliError}
|
|
39
|
+
*/
|
|
40
|
+
export function usageError(message, hint) {
|
|
41
|
+
return new CliError(message, { exitCode: EXIT.USAGE, hint });
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} ExecResult
|
|
7
|
+
* @property {number} code
|
|
8
|
+
* @property {string} stdout
|
|
9
|
+
* @property {string} stderr
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} Exec
|
|
14
|
+
* @property {(bin: string) => string | null} which
|
|
15
|
+
* @property {(file: string, args: string[], options?: { cwd?: string }) => Promise<ExecResult>} run
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Process access, isolated behind one small interface. This is the only such
|
|
20
|
+
* seam in the kernel: it exists so that commands which shell out to another
|
|
21
|
+
* tool (`claude mcp add`, a package manager install) stay testable without
|
|
22
|
+
* spawning anything. Filesystem work deliberately does *not* go through an
|
|
23
|
+
* adapter, because fixture directories on disk test it more honestly than a
|
|
24
|
+
* mock would.
|
|
25
|
+
*
|
|
26
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
27
|
+
* @returns {Exec}
|
|
28
|
+
*/
|
|
29
|
+
export function createExec(env = process.env) {
|
|
30
|
+
const extensions =
|
|
31
|
+
process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
which(bin) {
|
|
35
|
+
for (const dir of (env.PATH ?? '').split(path.delimiter)) {
|
|
36
|
+
if (!dir) continue;
|
|
37
|
+
for (const ext of extensions) {
|
|
38
|
+
const candidate = path.join(dir, bin + ext);
|
|
39
|
+
try {
|
|
40
|
+
accessSync(candidate, constants.X_OK);
|
|
41
|
+
return candidate;
|
|
42
|
+
} catch {
|
|
43
|
+
// Not here; keep walking PATH.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
run(file, args, options = {}) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
execFile(file, args, { cwd: options.cwd, env }, (error, stdout, stderr) => {
|
|
53
|
+
resolve({
|
|
54
|
+
code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
|
|
55
|
+
stdout: String(stdout),
|
|
56
|
+
stderr: String(stderr),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { GLOBAL_FLAGS } from './args.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} name
|
|
5
|
+
* @param {import('./args.js').FlagSpec} spec
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
function flagLabel(name, spec) {
|
|
9
|
+
const short = spec.short ? `-${spec.short}, ` : ' ';
|
|
10
|
+
const value = spec.type === 'boolean' ? '' : ` ${spec.placeholder ?? '<value>'}`;
|
|
11
|
+
return `${short}--${name}${value}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {[string, string][]} rows
|
|
16
|
+
* @param {(text: string) => string} accent
|
|
17
|
+
* @returns {string[]}
|
|
18
|
+
*/
|
|
19
|
+
function table(rows, accent) {
|
|
20
|
+
const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
|
|
21
|
+
return rows.map(([label, description]) => ` ${accent(label.padEnd(width))} ${description}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render help entirely from the command spec, so usage text cannot drift from
|
|
26
|
+
* what the parser actually accepts.
|
|
27
|
+
*
|
|
28
|
+
* @param {{
|
|
29
|
+
* path: string[],
|
|
30
|
+
* module: import('./command.js').CommandModule | null,
|
|
31
|
+
* entries: import('./command.js').CommandEntry[],
|
|
32
|
+
* colors: { bold: (s: string) => string, dim: (s: string) => string, cyan: (s: string) => string },
|
|
33
|
+
* }} input
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function renderHelp({ path, module, entries, colors }) {
|
|
37
|
+
const invocation = ['octane', ...path].join(' ');
|
|
38
|
+
const subcommands = module?.subcommands ?? (path.length === 0 ? entries : []);
|
|
39
|
+
const positionals = module?.positionals ?? [];
|
|
40
|
+
|
|
41
|
+
/** @type {string[]} */
|
|
42
|
+
const lines = [];
|
|
43
|
+
|
|
44
|
+
const argsUsage = positionals
|
|
45
|
+
.map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`) + (p.variadic ? '...' : ''))
|
|
46
|
+
.join(' ');
|
|
47
|
+
|
|
48
|
+
lines.push(colors.bold('Usage'));
|
|
49
|
+
lines.push(
|
|
50
|
+
` ${invocation}${subcommands.length > 0 ? ' <command>' : ''}${argsUsage ? ` ${argsUsage}` : ''} [options]`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (module?.description) {
|
|
54
|
+
lines.push('', module.description);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (subcommands.length > 0) {
|
|
58
|
+
lines.push('', colors.bold('Commands'));
|
|
59
|
+
lines.push(
|
|
60
|
+
...table(
|
|
61
|
+
subcommands.map((entry) => [entry.name, entry.summary]),
|
|
62
|
+
colors.cyan,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (positionals.length > 0) {
|
|
68
|
+
lines.push('', colors.bold('Arguments'));
|
|
69
|
+
lines.push(
|
|
70
|
+
...table(
|
|
71
|
+
positionals.map((p) => [p.name, p.description]),
|
|
72
|
+
colors.cyan,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const own = Object.entries(module?.flags ?? {});
|
|
78
|
+
if (own.length > 0) {
|
|
79
|
+
lines.push('', colors.bold('Options'));
|
|
80
|
+
lines.push(
|
|
81
|
+
...table(
|
|
82
|
+
own.map(([name, spec]) => [flagLabel(name, spec), spec.description]),
|
|
83
|
+
colors.cyan,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
lines.push('', colors.bold('Global options'));
|
|
89
|
+
lines.push(
|
|
90
|
+
...table(
|
|
91
|
+
Object.entries(GLOBAL_FLAGS).map(([name, spec]) => [flagLabel(name, spec), spec.description]),
|
|
92
|
+
colors.dim,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** How each package manager spells "add a dependency". */
|
|
4
|
+
const MANAGERS = {
|
|
5
|
+
pnpm: { add: ['add'], dev: '-D' },
|
|
6
|
+
npm: { add: ['install'], dev: '--save-dev' },
|
|
7
|
+
yarn: { add: ['add'], dev: '-D' },
|
|
8
|
+
bun: { add: ['add'], dev: '-d' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('./project.js').Project} project
|
|
13
|
+
* @param {string[]} names
|
|
14
|
+
* @param {boolean} dev
|
|
15
|
+
* @returns {{ manager: string, args: string[] }}
|
|
16
|
+
*/
|
|
17
|
+
export function installCommand(project, names, dev = false) {
|
|
18
|
+
const manager = project.packageManager ?? 'npm';
|
|
19
|
+
const recipe = MANAGERS[/** @type {keyof typeof MANAGERS} */ (manager)] ?? MANAGERS.npm;
|
|
20
|
+
return { manager, args: [...recipe.add, ...(dev ? [recipe.dev] : []), ...names] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Install through the project's own package manager, which owns version
|
|
25
|
+
* resolution. The CLI never invents a version range.
|
|
26
|
+
*
|
|
27
|
+
* @param {import('./context.js').Ctx} ctx
|
|
28
|
+
* @param {import('./project.js').Project} project
|
|
29
|
+
* @param {string[]} names
|
|
30
|
+
* @param {{ dev?: boolean }} [options]
|
|
31
|
+
* @returns {Promise<string>} the command that ran
|
|
32
|
+
*/
|
|
33
|
+
export async function installPackages(ctx, project, names, { dev = false } = {}) {
|
|
34
|
+
const { manager, args } = installCommand(project, names, dev);
|
|
35
|
+
const result = await ctx.exec.run(manager, args, { cwd: project.root });
|
|
36
|
+
|
|
37
|
+
if (result.code !== 0) {
|
|
38
|
+
throw new CliError(`\`${manager} ${args.join(' ')}\` failed.`, {
|
|
39
|
+
hint: (result.stderr || result.stdout).trim() || 'Install the packages by hand.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return `${manager} ${args.join(' ')}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse JSON with comments and trailing commas, the dialect `tsconfig.json` and
|
|
5
|
+
* every editor MCP config are actually written in. Comments and commas are
|
|
6
|
+
* handled in one pass so that a `,` or `//` inside a string literal is never
|
|
7
|
+
* mistaken for syntax.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} text
|
|
10
|
+
* @returns {unknown}
|
|
11
|
+
*/
|
|
12
|
+
export function parseJsonc(text) {
|
|
13
|
+
let out = '';
|
|
14
|
+
let lastMeaningful = -1;
|
|
15
|
+
let i = 0;
|
|
16
|
+
|
|
17
|
+
const push = (/** @type {string} */ ch) => {
|
|
18
|
+
if ((ch === '}' || ch === ']') && lastMeaningful >= 0 && out[lastMeaningful] === ',') {
|
|
19
|
+
out = out.slice(0, lastMeaningful) + out.slice(lastMeaningful + 1);
|
|
20
|
+
lastMeaningful = -1;
|
|
21
|
+
for (let j = out.length - 1; j >= 0; j--) {
|
|
22
|
+
if (!/\s/.test(out[j])) {
|
|
23
|
+
lastMeaningful = j;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
out += ch;
|
|
29
|
+
if (!/\s/.test(ch)) lastMeaningful = out.length - 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
while (i < text.length) {
|
|
33
|
+
const ch = text[i];
|
|
34
|
+
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
// Emit the whole literal verbatim. Nothing inside it is syntax, so a
|
|
37
|
+
// `//`, a `]`, or a `,` in a string value must not reach `push`.
|
|
38
|
+
const start = i++;
|
|
39
|
+
while (i < text.length) {
|
|
40
|
+
if (text[i] === '\\') {
|
|
41
|
+
i += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (text[i] === '"') {
|
|
45
|
+
i++;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
out += text.slice(start, i);
|
|
51
|
+
lastMeaningful = out.length - 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
56
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
61
|
+
i += 2;
|
|
62
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
63
|
+
i += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
push(ch);
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return JSON.parse(out);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} file
|
|
76
|
+
* @returns {any | null} `null` when the file is missing, empty, or unparseable
|
|
77
|
+
*/
|
|
78
|
+
export function readJsonc(file) {
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = readFileSync(file, 'utf8');
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (text.trim() === '') return null;
|
|
86
|
+
try {
|
|
87
|
+
return parseJsonc(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parseArgs } from './args.js';
|
|
3
|
+
import { renderBanner } from './banner.js';
|
|
4
|
+
import { resolveCommand } from './command.js';
|
|
5
|
+
import { createContext } from './context.js';
|
|
6
|
+
import { CliError, EXIT, usageError } from './errors.js';
|
|
7
|
+
import { renderHelp } from './help.js';
|
|
8
|
+
import { COMMANDS } from './registry.js';
|
|
9
|
+
|
|
10
|
+
export const VERSION = JSON.parse(
|
|
11
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} RunOptions
|
|
16
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
17
|
+
* @property {NodeJS.WritableStream} [stdout]
|
|
18
|
+
* @property {NodeJS.WritableStream} [stderr]
|
|
19
|
+
* @property {boolean} [tty]
|
|
20
|
+
* @property {import('./exec.js').Exec} [exec]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Offer the commands available at `path` and dispatch the chosen one.
|
|
25
|
+
*
|
|
26
|
+
* Recursive by construction: choosing a group re-enters `dispatch`, which finds
|
|
27
|
+
* a module with subcommands and no `run` and calls back here. So `octane` then
|
|
28
|
+
* `mcp` walks into add/status/remove without the caller knowing how deep the
|
|
29
|
+
* tree goes.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('./context.js').Ctx} ctx
|
|
32
|
+
* @param {RunOptions} options the original invocation options, so the chosen
|
|
33
|
+
* command runs against the same streams and injected process access
|
|
34
|
+
* @param {string[]} path commands already chosen
|
|
35
|
+
* @param {import('./command.js').CommandEntry[]} entries what is available here
|
|
36
|
+
* @returns {Promise<number>}
|
|
37
|
+
*/
|
|
38
|
+
async function runMenu(ctx, options, path, entries) {
|
|
39
|
+
const name = await ctx.ui.select({
|
|
40
|
+
message: path.length === 0 ? 'What would you like to do?' : `octane ${path.join(' ')}`,
|
|
41
|
+
flag: '<command>',
|
|
42
|
+
options: entries.map((entry) => ({
|
|
43
|
+
value: entry.name,
|
|
44
|
+
label: entry.name,
|
|
45
|
+
hint: entry.summary,
|
|
46
|
+
})),
|
|
47
|
+
});
|
|
48
|
+
return dispatch([...path, name], options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string[]} argv
|
|
53
|
+
* @param {RunOptions} options
|
|
54
|
+
* @returns {Promise<number>}
|
|
55
|
+
*/
|
|
56
|
+
async function dispatch(argv, options) {
|
|
57
|
+
const stdout = options.stdout ?? process.stdout;
|
|
58
|
+
const { path, module, argv: rest } = await resolveCommand(COMMANDS, argv);
|
|
59
|
+
|
|
60
|
+
if (!module && rest.length > 0 && !rest[0].startsWith('-')) {
|
|
61
|
+
throw usageError(`Unknown command: ${rest[0]}`, 'Run `octane --help` to list commands.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parsed = parseArgs(rest, module ?? {});
|
|
65
|
+
const ctx = createContext({
|
|
66
|
+
flags: parsed.flags,
|
|
67
|
+
version: VERSION,
|
|
68
|
+
env: options.env,
|
|
69
|
+
stdout,
|
|
70
|
+
tty: options.tty,
|
|
71
|
+
exec: options.exec,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// `--version` is documented as a global flag, so it answers everywhere rather
|
|
75
|
+
// than only at the root.
|
|
76
|
+
if (parsed.flags.version) {
|
|
77
|
+
if (ctx.json) stdout.write(`${JSON.stringify({ version: VERSION }, null, 2)}\n`);
|
|
78
|
+
else ctx.ui.log(VERSION);
|
|
79
|
+
return EXIT.OK;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!module?.run || parsed.flags.help) {
|
|
83
|
+
if (ctx.json) {
|
|
84
|
+
const listed = module?.subcommands ?? COMMANDS;
|
|
85
|
+
stdout.write(
|
|
86
|
+
`${JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
command: path.join(' ') || null,
|
|
89
|
+
commands: listed.map((e) => ({ name: e.name, summary: e.summary })),
|
|
90
|
+
},
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
)}\n`,
|
|
94
|
+
);
|
|
95
|
+
return EXIT.OK;
|
|
96
|
+
}
|
|
97
|
+
// Root invocation only: the wordmark heads `octane` and `octane --help`,
|
|
98
|
+
// not every subcommand's help.
|
|
99
|
+
if (path.length === 0) renderBanner(ctx);
|
|
100
|
+
|
|
101
|
+
// Nothing runnable was named, so offer what is available here instead of
|
|
102
|
+
// printing help at someone who has not asked for it. `--help` is an
|
|
103
|
+
// explicit request for the text, so it still wins.
|
|
104
|
+
const choices = module?.subcommands ?? (module ? [] : COMMANDS);
|
|
105
|
+
if (!parsed.flags.help && ctx.ui.canPrompt && choices.length > 0) {
|
|
106
|
+
return runMenu(ctx, options, path, choices);
|
|
107
|
+
}
|
|
108
|
+
ctx.ui.log(renderHelp({ path, module, entries: COMMANDS, colors: ctx.ui.colors }));
|
|
109
|
+
return EXIT.OK;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Commands that write into a project need one to exist. Without this they
|
|
113
|
+
// fail deep inside an fs call with a raw ENOENT stack.
|
|
114
|
+
if (module.requiresProject && ctx.project().manifestPath === null) {
|
|
115
|
+
throw new CliError(`No package.json found in ${ctx.cwd} or any parent directory.`, {
|
|
116
|
+
hint: 'Run this inside a project, or create one first with `npm init -y`.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = await module.run(ctx, {
|
|
121
|
+
flags: parsed.flags,
|
|
122
|
+
positionals: parsed.positionals,
|
|
123
|
+
rest: parsed.rest,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (typeof result === 'number') return result;
|
|
127
|
+
if (result && typeof result === 'object') {
|
|
128
|
+
if (ctx.json && result.json !== undefined) {
|
|
129
|
+
stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
return result.exitCode ?? EXIT.OK;
|
|
132
|
+
}
|
|
133
|
+
return EXIT.OK;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Entry point. Never throws: every failure is rendered and turned into an exit
|
|
138
|
+
* code, in the caller's chosen output format.
|
|
139
|
+
*
|
|
140
|
+
* @param {string[]} argv
|
|
141
|
+
* @param {RunOptions} [options]
|
|
142
|
+
* @returns {Promise<number>}
|
|
143
|
+
*/
|
|
144
|
+
export async function main(argv, options = {}) {
|
|
145
|
+
const stderr = options.stderr ?? process.stderr;
|
|
146
|
+
// Read from raw argv: a failure can happen before the flags are parsed, and
|
|
147
|
+
// the parser accepts `--json=true` as well as the bare form.
|
|
148
|
+
const json = argv.some((token) => token === '--json' || token.startsWith('--json='));
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return await dispatch(argv, options);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const known = error instanceof CliError;
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
|
|
156
|
+
if (json) {
|
|
157
|
+
stderr.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
|
|
158
|
+
} else {
|
|
159
|
+
stderr.write(`\n${message}\n`);
|
|
160
|
+
if (known && error.hint) stderr.write(`${error.hint}\n`);
|
|
161
|
+
if (!known && error instanceof Error && error.stack) stderr.write(`\n${error.stack}\n`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return known ? error.exitCode : EXIT.FAILURE;
|
|
165
|
+
}
|
|
166
|
+
}
|