@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
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { main, VERSION } from './kernel/main.js';
2
+ export { defineCommand } from './kernel/command.js';
3
+ export { CliError, EXIT } from './kernel/errors.js';
4
+ export { detectProject } from './kernel/project.js';
@@ -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
+ }