@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
package/src/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { usageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} FlagSpec
|
|
5
|
+
* @property {'boolean' | 'string' | 'number'} type
|
|
6
|
+
* @property {string} description
|
|
7
|
+
* @property {string} [short] single character alias, without the dash
|
|
8
|
+
* @property {unknown} [default]
|
|
9
|
+
* @property {readonly string[]} [choices]
|
|
10
|
+
* @property {boolean} [repeatable] collect every occurrence into an array
|
|
11
|
+
* @property {string} [placeholder] value name shown in help, e.g. `<dir>`
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} PositionalSpec
|
|
16
|
+
* @property {string} name
|
|
17
|
+
* @property {string} description
|
|
18
|
+
* @property {boolean} [required] documentation only; commands enforce it
|
|
19
|
+
* themselves so that an interactive session can prompt for the value instead
|
|
20
|
+
* @property {boolean} [variadic]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} ParsedArgs
|
|
25
|
+
* @property {Record<string, unknown>} flags
|
|
26
|
+
* @property {string[]} positionals
|
|
27
|
+
* @property {string[]} rest tokens after a bare `--`
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flags every command accepts. Merged into each command's own spec so that
|
|
32
|
+
* parsing, validation, and help all see one flat table.
|
|
33
|
+
*
|
|
34
|
+
* @type {Record<string, FlagSpec>}
|
|
35
|
+
*/
|
|
36
|
+
export const GLOBAL_FLAGS = {
|
|
37
|
+
help: { type: 'boolean', short: 'h', description: 'Show help for this command.' },
|
|
38
|
+
version: { type: 'boolean', short: 'v', description: 'Print the CLI version.' },
|
|
39
|
+
json: { type: 'boolean', description: 'Emit a single JSON result and no human output.' },
|
|
40
|
+
cwd: { type: 'string', placeholder: '<dir>', description: 'Run against a different directory.' },
|
|
41
|
+
yes: { type: 'boolean', short: 'y', description: 'Accept every prompt with its default.' },
|
|
42
|
+
'dry-run': { type: 'boolean', description: 'Report what would change without writing.' },
|
|
43
|
+
color: { type: 'boolean', default: true, description: 'Colorize output (--no-color disables).' },
|
|
44
|
+
verbose: { type: 'boolean', description: 'Include diagnostic detail in output.' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {FlagSpec} spec
|
|
49
|
+
* @param {string} name
|
|
50
|
+
* @param {string} raw
|
|
51
|
+
* @returns {unknown}
|
|
52
|
+
*/
|
|
53
|
+
function coerce(spec, name, raw) {
|
|
54
|
+
if (spec.type === 'number') {
|
|
55
|
+
const value = Number(raw);
|
|
56
|
+
if (!Number.isFinite(value)) throw usageError(`--${name} expects a number, got "${raw}".`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (spec.type === 'boolean') {
|
|
60
|
+
if (raw === 'true') return true;
|
|
61
|
+
if (raw === 'false') return false;
|
|
62
|
+
throw usageError(`--${name} expects true or false, got "${raw}".`);
|
|
63
|
+
}
|
|
64
|
+
if (spec.choices && !spec.choices.includes(raw)) {
|
|
65
|
+
throw usageError(`--${name} expects one of: ${spec.choices.join(', ')}. Got "${raw}".`);
|
|
66
|
+
}
|
|
67
|
+
return raw;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Record<string, FlagSpec>} specs
|
|
72
|
+
* @returns {Map<string, string>}
|
|
73
|
+
*/
|
|
74
|
+
function shortIndex(specs) {
|
|
75
|
+
const index = new Map();
|
|
76
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
77
|
+
if (spec.short) index.set(spec.short, name);
|
|
78
|
+
}
|
|
79
|
+
return index;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse argv against a flag/positional spec.
|
|
84
|
+
*
|
|
85
|
+
* Supports `--name`, `--name=value`, `--name value`, `--no-name` for booleans,
|
|
86
|
+
* `-x` short aliases, and a bare `--` terminator. An unrecognized flag is a
|
|
87
|
+
* usage error rather than a silently ignored token, because a typo'd flag that
|
|
88
|
+
* parses as a positional is the worst possible failure mode for a `--fix`-style
|
|
89
|
+
* command.
|
|
90
|
+
*
|
|
91
|
+
* @param {string[]} argv
|
|
92
|
+
* @param {{ flags?: Record<string, FlagSpec>, positionals?: PositionalSpec[] }} spec
|
|
93
|
+
* @returns {ParsedArgs}
|
|
94
|
+
*/
|
|
95
|
+
export function parseArgs(argv, spec = {}) {
|
|
96
|
+
const specs = { ...GLOBAL_FLAGS, ...(spec.flags ?? {}) };
|
|
97
|
+
const shorts = shortIndex(specs);
|
|
98
|
+
|
|
99
|
+
/** @type {Record<string, unknown>} */
|
|
100
|
+
const flags = {};
|
|
101
|
+
/** @type {string[]} */
|
|
102
|
+
const positionals = [];
|
|
103
|
+
/** @type {string[]} */
|
|
104
|
+
let rest = [];
|
|
105
|
+
|
|
106
|
+
for (const [name, entry] of Object.entries(specs)) {
|
|
107
|
+
if (entry.default !== undefined) flags[name] = entry.default;
|
|
108
|
+
else if (entry.repeatable) flags[name] = [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} name
|
|
113
|
+
* @param {unknown} value
|
|
114
|
+
*/
|
|
115
|
+
const assign = (name, value) => {
|
|
116
|
+
const entry = specs[name];
|
|
117
|
+
if (entry.repeatable) {
|
|
118
|
+
const list = Array.isArray(flags[name]) ? /** @type {unknown[]} */ (flags[name]) : [];
|
|
119
|
+
list.push(value);
|
|
120
|
+
flags[name] = list;
|
|
121
|
+
} else {
|
|
122
|
+
flags[name] = value;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < argv.length; i++) {
|
|
127
|
+
const token = argv[i];
|
|
128
|
+
|
|
129
|
+
if (token === '--') {
|
|
130
|
+
rest = argv.slice(i + 1);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!token.startsWith('-') || token === '-') {
|
|
135
|
+
positionals.push(token);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isLong = token.startsWith('--');
|
|
140
|
+
const body = isLong ? token.slice(2) : token.slice(1);
|
|
141
|
+
const eq = body.indexOf('=');
|
|
142
|
+
const key = eq === -1 ? body : body.slice(0, eq);
|
|
143
|
+
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
144
|
+
|
|
145
|
+
let name = (isLong ? key : shorts.get(key)) ?? '';
|
|
146
|
+
let negated = false;
|
|
147
|
+
|
|
148
|
+
if (isLong && !specs[name] && key.startsWith('no-') && specs[key.slice(3)]) {
|
|
149
|
+
name = key.slice(3);
|
|
150
|
+
negated = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const entry = specs[name];
|
|
154
|
+
if (!entry) throw usageError(`Unknown flag: ${token}`, 'Run with --help to see valid flags.');
|
|
155
|
+
|
|
156
|
+
if (negated) {
|
|
157
|
+
if (entry.type !== 'boolean') throw usageError(`--no-${name} is only valid for flags.`);
|
|
158
|
+
assign(name, false);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (inline !== undefined) {
|
|
163
|
+
assign(name, coerce(entry, name, inline));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (entry.type === 'boolean') {
|
|
168
|
+
assign(name, true);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const next = argv[i + 1];
|
|
173
|
+
if (next === undefined || (next.startsWith('-') && next !== '-')) {
|
|
174
|
+
throw usageError(`--${name} expects a value.`);
|
|
175
|
+
}
|
|
176
|
+
assign(name, coerce(entry, name, next));
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { flags, positionals, rest };
|
|
181
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Octane mark, for the root invocation only.
|
|
3
|
+
*
|
|
4
|
+
* It is decoration, so it appears exactly where decoration is harmless: a human
|
|
5
|
+
* run of bare `octane` or `octane --help`. Never under `--json`, where it would
|
|
6
|
+
* sit in front of the document, and never on a subcommand, where it would be
|
|
7
|
+
* noise on every `doctor` run in a loop.
|
|
8
|
+
*
|
|
9
|
+
* The glyphs are the art itself, not a fill pattern: their varying weight is
|
|
10
|
+
* what gives the flame its shading, so they are kept verbatim rather than
|
|
11
|
+
* flattened to block characters. Colour is the only thing applied on top, and
|
|
12
|
+
* it follows the two fills in `icon.svg`.
|
|
13
|
+
*/
|
|
14
|
+
const MARK = [
|
|
15
|
+
' :&',
|
|
16
|
+
' ::&&',
|
|
17
|
+
' :&&&&&&:',
|
|
18
|
+
' :&&&&r; :&&&:',
|
|
19
|
+
' &&&&&rr; rr&&&',
|
|
20
|
+
':&&:&rrrrrr&&&&',
|
|
21
|
+
' &&&&rr&r&&&&:',
|
|
22
|
+
' ::&&&&&::',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The low-ink glyphs that make up the inner flame. Everything else is the outer
|
|
27
|
+
* body, including the edge characters that share some of these shapes.
|
|
28
|
+
*/
|
|
29
|
+
const INNER = new Set('rltj;-|+_');
|
|
30
|
+
|
|
31
|
+
/** The two fills in `icon.svg`, which are also `--text` and `--accent` on the site. */
|
|
32
|
+
const CREAM = [244, 238, 232];
|
|
33
|
+
const FIRE = [255, 65, 90];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('./ui.js').Ui} ui
|
|
37
|
+
* @param {NodeJS.ProcessEnv} env
|
|
38
|
+
* @returns {{ cream: (s: string) => string, fire: (s: string) => string }}
|
|
39
|
+
*/
|
|
40
|
+
function palette(ui, env) {
|
|
41
|
+
if (ui.mode !== 'interactive') {
|
|
42
|
+
const plain = (/** @type {string} */ s) => s;
|
|
43
|
+
return { cream: plain, fire: plain };
|
|
44
|
+
}
|
|
45
|
+
if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') {
|
|
46
|
+
const rgb =
|
|
47
|
+
(/** @type {number[]} */ [r, g, b]) =>
|
|
48
|
+
(/** @type {string} */ s) =>
|
|
49
|
+
`\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
|
|
50
|
+
return { cream: rgb(CREAM), fire: rgb(FIRE) };
|
|
51
|
+
}
|
|
52
|
+
// 256- and 16-colour terminals: the mark still reads as a two-tone flame,
|
|
53
|
+
// just in the nearest available pair.
|
|
54
|
+
return { cream: ui.colors.white, fire: ui.colors.red };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Colour a row, emitting one escape per run of like-coloured glyphs rather than
|
|
59
|
+
* per character.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} row
|
|
62
|
+
* @param {ReturnType<typeof palette>} colors
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function paintRow(row, colors) {
|
|
66
|
+
let out = '';
|
|
67
|
+
for (let i = 0; i < row.length;) {
|
|
68
|
+
const kind = row[i] === ' ' ? 'gap' : INNER.has(row[i]) ? 'inner' : 'outer';
|
|
69
|
+
let end = i;
|
|
70
|
+
while (
|
|
71
|
+
end < row.length &&
|
|
72
|
+
(row[end] === ' ' ? 'gap' : INNER.has(row[end]) ? 'inner' : 'outer') === kind
|
|
73
|
+
) {
|
|
74
|
+
end++;
|
|
75
|
+
}
|
|
76
|
+
const run = row.slice(i, end);
|
|
77
|
+
out += kind === 'gap' ? run : (kind === 'inner' ? colors.fire : colors.cream)(run);
|
|
78
|
+
i = end;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {import('./context.js').Ctx} ctx
|
|
85
|
+
*/
|
|
86
|
+
export function renderBanner(ctx) {
|
|
87
|
+
if (ctx.json) return;
|
|
88
|
+
|
|
89
|
+
const colors = palette(ctx.ui, ctx.env);
|
|
90
|
+
const { bold, dim } = ctx.ui.colors;
|
|
91
|
+
|
|
92
|
+
ctx.ui.log('');
|
|
93
|
+
for (const row of MARK) ctx.ui.log(` ${paintRow(row, colors)}`);
|
|
94
|
+
ctx.ui.log('');
|
|
95
|
+
ctx.ui.log(` ${bold(colors.cream('OCTANE CLI'))} ${dim(`v${ctx.version}`)}`);
|
|
96
|
+
ctx.ui.log(` ${dim("React's programming model, compiled.")}`);
|
|
97
|
+
ctx.ui.log('');
|
|
98
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} CommandEntry
|
|
3
|
+
* An index record. Name, aliases, and the one-line summary live here and
|
|
4
|
+
* nowhere else, so `octane --help` can list every command without importing a
|
|
5
|
+
* single command module.
|
|
6
|
+
* @property {string} name
|
|
7
|
+
* @property {string} summary
|
|
8
|
+
* @property {string[]} [aliases]
|
|
9
|
+
* @property {() => Promise<{ default: CommandModule }>} load
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} CommandModule
|
|
14
|
+
* The behaviour half of a command. It never repeats its own name or summary.
|
|
15
|
+
* @property {string} [description] long help, shown above the flag table
|
|
16
|
+
* @property {Record<string, import('./args.js').FlagSpec>} [flags]
|
|
17
|
+
* @property {import('./args.js').PositionalSpec[]} [positionals]
|
|
18
|
+
* @property {CommandEntry[]} [subcommands]
|
|
19
|
+
* @property {boolean} [requiresProject] refuse to run outside a package.json,
|
|
20
|
+
* for commands that write into one
|
|
21
|
+
* @property {(ctx: import('./context.js').Ctx, input: CommandInput) => Promise<CommandResult>} [run]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} CommandInput
|
|
26
|
+
* @property {Record<string, any>} flags
|
|
27
|
+
* @property {string[]} positionals
|
|
28
|
+
* @property {string[]} rest
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {void | number | { exitCode?: number, json?: unknown }} CommandResult
|
|
33
|
+
* Human output is written through `ctx.ui` as the command runs. `json` is the
|
|
34
|
+
* machine payload, printed by the kernel only under `--json`, so neither mode
|
|
35
|
+
* has to know about the other.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Identity function that pins a module to the {@link CommandModule} shape.
|
|
40
|
+
*
|
|
41
|
+
* @param {CommandModule} command
|
|
42
|
+
* @returns {CommandModule}
|
|
43
|
+
*/
|
|
44
|
+
export function defineCommand(command) {
|
|
45
|
+
return command;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {CommandEntry[]} entries
|
|
50
|
+
* @param {string} name
|
|
51
|
+
* @returns {CommandEntry | undefined}
|
|
52
|
+
*/
|
|
53
|
+
export function findEntry(entries, name) {
|
|
54
|
+
return entries.find((entry) => entry.name === name || entry.aliases?.includes(name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Walk argv through nested command registries, loading only the modules that
|
|
59
|
+
* are actually on the resolved path.
|
|
60
|
+
*
|
|
61
|
+
* @param {CommandEntry[]} entries
|
|
62
|
+
* @param {string[]} argv
|
|
63
|
+
* @returns {Promise<{ path: string[], module: CommandModule | null, argv: string[] }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveCommand(entries, argv) {
|
|
66
|
+
/** @type {string[]} */
|
|
67
|
+
const path = [];
|
|
68
|
+
/** @type {CommandModule | null} */
|
|
69
|
+
let module = null;
|
|
70
|
+
let available = entries;
|
|
71
|
+
let rest = argv;
|
|
72
|
+
|
|
73
|
+
while (rest.length > 0) {
|
|
74
|
+
const entry = rest[0].startsWith('-') ? undefined : findEntry(available, rest[0]);
|
|
75
|
+
if (!entry) break;
|
|
76
|
+
|
|
77
|
+
path.push(entry.name);
|
|
78
|
+
rest = rest.slice(1);
|
|
79
|
+
module = (await entry.load()).default;
|
|
80
|
+
available = module.subcommands ?? [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { path, module, argv: rest };
|
|
84
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createExec } from './exec.js';
|
|
4
|
+
import { usageError } from './errors.js';
|
|
5
|
+
import { detectProject } from './project.js';
|
|
6
|
+
import { createUi, resolveMode } from './ui.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} Ctx
|
|
10
|
+
* @property {string} cwd
|
|
11
|
+
* @property {NodeJS.ProcessEnv} env
|
|
12
|
+
* @property {string} version
|
|
13
|
+
* @property {import('./ui.js').Ui} ui
|
|
14
|
+
* @property {import('./exec.js').Exec} exec
|
|
15
|
+
* @property {boolean} json
|
|
16
|
+
* @property {boolean} yes
|
|
17
|
+
* @property {boolean} dryRun
|
|
18
|
+
* @property {boolean} verbose
|
|
19
|
+
* @property {() => import('./project.js').Project} project
|
|
20
|
+
* @property {() => import('./project.js').Project} refreshProject
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* flags: Record<string, any>,
|
|
26
|
+
* version: string,
|
|
27
|
+
* env?: NodeJS.ProcessEnv,
|
|
28
|
+
* stdout?: NodeJS.WritableStream,
|
|
29
|
+
* tty?: boolean,
|
|
30
|
+
* exec?: import('./exec.js').Exec,
|
|
31
|
+
* }} options
|
|
32
|
+
* @returns {Ctx}
|
|
33
|
+
*/
|
|
34
|
+
export function createContext({
|
|
35
|
+
flags,
|
|
36
|
+
version,
|
|
37
|
+
env = process.env,
|
|
38
|
+
stdout = process.stdout,
|
|
39
|
+
tty = Boolean(/** @type {any} */ (stdout).isTTY),
|
|
40
|
+
exec,
|
|
41
|
+
}) {
|
|
42
|
+
const cwd = path.resolve(flags.cwd ?? process.cwd());
|
|
43
|
+
// A typo'd --cwd would otherwise be reported on as if it were an empty
|
|
44
|
+
// project, which reads as a real (and wrong) result.
|
|
45
|
+
if (flags.cwd !== undefined && !existsSync(cwd)) {
|
|
46
|
+
throw usageError(`--cwd directory does not exist: ${cwd}`);
|
|
47
|
+
}
|
|
48
|
+
const json = Boolean(flags.json);
|
|
49
|
+
const ui = createUi({
|
|
50
|
+
mode: resolveMode({ json, color: flags.color !== false, tty, env }),
|
|
51
|
+
yes: Boolean(flags.yes),
|
|
52
|
+
stdout,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** @type {import('./project.js').Project | undefined} */
|
|
56
|
+
let project;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
cwd,
|
|
60
|
+
env,
|
|
61
|
+
version,
|
|
62
|
+
ui,
|
|
63
|
+
exec: exec ?? createExec(env),
|
|
64
|
+
json,
|
|
65
|
+
yes: Boolean(flags.yes),
|
|
66
|
+
dryRun: Boolean(flags['dry-run']),
|
|
67
|
+
verbose: Boolean(flags.verbose),
|
|
68
|
+
project() {
|
|
69
|
+
project ??= detectProject(cwd);
|
|
70
|
+
return project;
|
|
71
|
+
},
|
|
72
|
+
/** Re-read the project from disk, after a command has written to it. */
|
|
73
|
+
refreshProject() {
|
|
74
|
+
project = detectProject(cwd);
|
|
75
|
+
return project;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical JSONC editing.
|
|
3
|
+
*
|
|
4
|
+
* `--fix` rewrites files people hand-maintain, so edits are made as text
|
|
5
|
+
* splices rather than by reparsing and reserializing. A tsconfig that comes
|
|
6
|
+
* back with its comments stripped and its formatting churned is a worse outcome
|
|
7
|
+
* than the problem being fixed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Advance past a string literal or a comment beginning at `i`.
|
|
12
|
+
*
|
|
13
|
+
* Every scanner below routes through this. `tsconfig.json` is JSONC, so a `}`
|
|
14
|
+
* or a quoted key inside a comment is ordinary prose: treating it as syntax
|
|
15
|
+
* closes the wrong object and lets an edit land in the comment, or duplicate a
|
|
16
|
+
* key that was already there.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} text
|
|
19
|
+
* @param {number} i
|
|
20
|
+
* @returns {{ end: number, kind: 'string' | 'comment' } | null}
|
|
21
|
+
*/
|
|
22
|
+
function skipTrivia(text, i) {
|
|
23
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
24
|
+
let j = i + 2;
|
|
25
|
+
while (j < text.length && text[j] !== '\n') j++;
|
|
26
|
+
return { end: j, kind: 'comment' };
|
|
27
|
+
}
|
|
28
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
29
|
+
let j = i + 2;
|
|
30
|
+
while (j < text.length && !(text[j] === '*' && text[j + 1] === '/')) j++;
|
|
31
|
+
return { end: Math.min(j + 2, text.length), kind: 'comment' };
|
|
32
|
+
}
|
|
33
|
+
if (text[i] === '"') {
|
|
34
|
+
let j = i + 1;
|
|
35
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
36
|
+
return { end: j + 1, kind: 'string' };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Index of the document's opening brace, skipping any leading comment.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {number}
|
|
46
|
+
*/
|
|
47
|
+
function findRootBrace(text) {
|
|
48
|
+
for (let i = 0; i < text.length;) {
|
|
49
|
+
const trivia = skipTrivia(text, i);
|
|
50
|
+
if (trivia) {
|
|
51
|
+
i = trivia.end;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (text[i] === '{') return i;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Match the object that starts at `open`.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @param {number} open index of the `{`
|
|
65
|
+
* @returns {number} index of the matching `}`, or -1
|
|
66
|
+
*/
|
|
67
|
+
function matchBrace(text, open) {
|
|
68
|
+
let depth = 0;
|
|
69
|
+
for (let i = open; i < text.length;) {
|
|
70
|
+
const trivia = skipTrivia(text, i);
|
|
71
|
+
if (trivia) {
|
|
72
|
+
i = trivia.end;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const ch = text[i];
|
|
76
|
+
if (ch === '{' || ch === '[') depth++;
|
|
77
|
+
else if (ch === '}' || ch === ']') {
|
|
78
|
+
depth--;
|
|
79
|
+
if (depth === 0) return i;
|
|
80
|
+
}
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Locate a top-level property inside the object spanning [open, close].
|
|
88
|
+
*
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @param {number} open
|
|
91
|
+
* @param {number} close
|
|
92
|
+
* @param {string} key
|
|
93
|
+
* @returns {{ start: number, valueStart: number, valueEnd: number } | null}
|
|
94
|
+
*/
|
|
95
|
+
function findProperty(text, open, close, key) {
|
|
96
|
+
const needle = `"${key}"`;
|
|
97
|
+
let depth = 0;
|
|
98
|
+
|
|
99
|
+
for (let i = open + 1; i < close;) {
|
|
100
|
+
const trivia = skipTrivia(text, i);
|
|
101
|
+
if (trivia) {
|
|
102
|
+
if (trivia.kind === 'string' && depth === 0 && text.slice(i, trivia.end) === needle) {
|
|
103
|
+
let cursor = trivia.end;
|
|
104
|
+
while (cursor < close && /\s/.test(text[cursor])) cursor++;
|
|
105
|
+
if (text[cursor] === ':') {
|
|
106
|
+
cursor++;
|
|
107
|
+
while (cursor < close && /\s/.test(text[cursor])) cursor++;
|
|
108
|
+
const valueStart = cursor;
|
|
109
|
+
const valueEnd =
|
|
110
|
+
text[cursor] === '{' || text[cursor] === '['
|
|
111
|
+
? matchBrace(text, cursor) + 1
|
|
112
|
+
: scanScalar(text, cursor, close);
|
|
113
|
+
return { start: i, valueStart, valueEnd };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
i = trivia.end;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const ch = text[i];
|
|
120
|
+
if (ch === '{' || ch === '[') depth++;
|
|
121
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} text
|
|
129
|
+
* @param {number} from
|
|
130
|
+
* @param {number} limit
|
|
131
|
+
* @returns {number}
|
|
132
|
+
*/
|
|
133
|
+
function scanScalar(text, from, limit) {
|
|
134
|
+
const trivia = skipTrivia(text, from);
|
|
135
|
+
if (trivia?.kind === 'string') return trivia.end;
|
|
136
|
+
|
|
137
|
+
let i = from;
|
|
138
|
+
// Stop before a trailing comment as well as the structural characters: the
|
|
139
|
+
// value ends where the comment begins, and the comment is not ours to move.
|
|
140
|
+
while (i < limit && !/[,}\]\n]/.test(text[i]) && !(text[i] === '/' && /[/*]/.test(text[i + 1]))) {
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
return i;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} text
|
|
148
|
+
* @param {number} open
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function detectIndent(text, open) {
|
|
152
|
+
const rest = text.slice(open + 1);
|
|
153
|
+
const match = /^\r?\n([ \t]+)/.exec(rest);
|
|
154
|
+
if (match) return match[1];
|
|
155
|
+
const outer = /\n([ \t]+)[^\n]*$/.exec(text.slice(0, open));
|
|
156
|
+
return outer ? `${outer[1]}${outer[1]}` : '\t';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Set `compilerOptions.<key>` to a JSON value, adding `compilerOptions` itself
|
|
161
|
+
* if the file does not have one.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} text
|
|
164
|
+
* @param {string} key
|
|
165
|
+
* @param {unknown} value
|
|
166
|
+
* @returns {{ text: string, changed: boolean } | null} `null` when the shape is
|
|
167
|
+
* not one this editor can change safely
|
|
168
|
+
*/
|
|
169
|
+
export function setCompilerOption(text, key, value) {
|
|
170
|
+
const serialized = JSON.stringify(value);
|
|
171
|
+
const rootOpen = findRootBrace(text);
|
|
172
|
+
if (rootOpen === -1) return null;
|
|
173
|
+
const rootClose = matchBrace(text, rootOpen);
|
|
174
|
+
if (rootClose === -1) return null;
|
|
175
|
+
|
|
176
|
+
const options = findProperty(text, rootOpen, rootClose, 'compilerOptions');
|
|
177
|
+
|
|
178
|
+
if (!options) {
|
|
179
|
+
const indent = detectIndent(text, rootOpen);
|
|
180
|
+
const insertion = `\n${indent}"compilerOptions": { ${JSON.stringify(key)}: ${serialized} },`;
|
|
181
|
+
return {
|
|
182
|
+
text: text.slice(0, rootOpen + 1) + insertion + text.slice(rootOpen + 1),
|
|
183
|
+
changed: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (text[options.valueStart] !== '{') return null;
|
|
188
|
+
const open = options.valueStart;
|
|
189
|
+
const close = matchBrace(text, open);
|
|
190
|
+
if (close === -1) return null;
|
|
191
|
+
|
|
192
|
+
const existing = findProperty(text, open, close, key);
|
|
193
|
+
if (existing) {
|
|
194
|
+
if (text.slice(existing.valueStart, existing.valueEnd).trim() === serialized) {
|
|
195
|
+
return { text, changed: false };
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
text: text.slice(0, existing.valueStart) + serialized + text.slice(existing.valueEnd),
|
|
199
|
+
changed: true,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const indent = detectIndent(text, open);
|
|
204
|
+
const body = text.slice(open + 1, close);
|
|
205
|
+
const separator = body.trim() === '' ? '' : ',';
|
|
206
|
+
const insertion = `\n${indent}${JSON.stringify(key)}: ${serialized}${separator}`;
|
|
207
|
+
return { text: text.slice(0, open + 1) + insertion + text.slice(open + 1), changed: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a nested property to its value range, for callers that want to splice
|
|
212
|
+
* rather than reserialize.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} text
|
|
215
|
+
* @param {string[]} keys
|
|
216
|
+
* @returns {{ valueStart: number, valueEnd: number } | null}
|
|
217
|
+
*/
|
|
218
|
+
export function findNestedProperty(text, keys) {
|
|
219
|
+
let open = text.indexOf('{');
|
|
220
|
+
if (open === -1) return null;
|
|
221
|
+
let close = matchBrace(text, open);
|
|
222
|
+
if (close === -1) return null;
|
|
223
|
+
|
|
224
|
+
/** @type {{ start: number, valueStart: number, valueEnd: number } | null} */
|
|
225
|
+
let found = null;
|
|
226
|
+
|
|
227
|
+
for (const key of keys) {
|
|
228
|
+
found = findProperty(text, open, close, key);
|
|
229
|
+
if (!found) return null;
|
|
230
|
+
if (text[found.valueStart] === '{') {
|
|
231
|
+
open = found.valueStart;
|
|
232
|
+
close = matchBrace(text, open);
|
|
233
|
+
if (close === -1) return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return found && { valueStart: found.valueStart, valueEnd: found.valueEnd };
|
|
238
|
+
}
|