@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @octanejs/cli
2
+
3
+ The Octane command line. Diagnose a project, wire Octane into an existing one,
4
+ install bindings, decode runtime errors, and register the Octane MCP server with
5
+ your coding agent.
6
+
7
+ ```bash
8
+ pnpm add -D @octanejs/cli
9
+ octane doctor
10
+ ```
11
+
12
+ Or without installing:
13
+
14
+ ```bash
15
+ pnpm dlx @octanejs/cli doctor
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ | Command | What it does |
21
+ | --- | --- |
22
+ | `octane init` | Wire Octane into the project in this directory: bundler plugin, tsconfig, scripts, dependencies. |
23
+ | `octane doctor` | Check the project for the mistakes that break Octane quietly. `--fix` repairs the mechanical ones. |
24
+ | `octane analyze` | Compile the project and report every Octane compiler diagnostic, with its code and suggested edit. |
25
+ | `octane add <package>` | Install a binding, by its own name or by the React package it ports, and print its divergences. |
26
+ | `octane bindings [query]` | List and search the `@octanejs/*` bindings. |
27
+ | `octane explain <error>` | Decode a runtime error code, including the minified production message. |
28
+ | `octane info` | Environment and project details worth pasting into a bug report. |
29
+ | `octane mcp add` | Register the Octane MCP server with Claude Code, Codex, Cursor, or VS Code. |
30
+
31
+ Run `octane <command> --help` for the flags. Every command accepts the same
32
+ global options: `--json`, `--cwd <dir>`, `--yes`, `--dry-run`, `--no-color`,
33
+ `--verbose`.
34
+
35
+ ## `octane doctor`
36
+
37
+ Octane is a compiler framework, so its misconfigurations tend to fail quietly
38
+ rather than loudly. Doctor looks for the ones that do:
39
+
40
+ - **A second copy of `octane` in the tree.** Hooks and context are keyed per
41
+ runtime instance, so a duplicate breaks them without raising an error.
42
+ - **`jsxImportSource` not set to `octane`**, or `@tsrx/typescript-plugin`
43
+ missing from `compilerOptions.plugins`.
44
+ - **`tsc` instead of `tsrx-tsc`** in the typecheck script. Plain `tsc` cannot
45
+ read `.tsrx`.
46
+ - **`declare module '*.tsrx'`** anywhere in your sources. It silences `.tsrx`
47
+ resolution instead of fixing it, so every import it covers becomes `any`,
48
+ including your own components.
49
+ - **No Octane plugin in the bundler config**, or both the compiler plugin and
50
+ the metaframework plugin at once.
51
+ - **Routes in `octane.config.ts` pointing at files that do not exist.**
52
+ - **`forwardRef` imported from `octane`.** It does not exist; refs are plain
53
+ props.
54
+
55
+ ```bash
56
+ octane doctor # report
57
+ octane doctor --fix # repair the mechanical findings
58
+ octane doctor --json # for CI; exits 3 when an error-severity check fails
59
+ ```
60
+
61
+ `--fix` only touches findings whose repair is unambiguous, and it edits files as
62
+ text splices, so comments and formatting in your `tsconfig.json` survive.
63
+ Anything else is reported with the exact remedy rather than guessed at.
64
+
65
+ ## `octane analyze`
66
+
67
+ Where `doctor` checks how the project is wired, `analyze` checks the code. It
68
+ compiles every `.tsrx` through the project's own `octane` and reports what the
69
+ compiler found, so the results are exactly what a build would warn about, and
70
+ new compiler diagnostics show up here without a CLI change.
71
+
72
+ ```bash
73
+ octane analyze # every .tsrx in the project
74
+ octane analyze src/App.tsrx # just these
75
+ octane analyze --code OCTANE_HYDRATE_SPLIT_STYLE
76
+ octane analyze --strict # warnings fail the run too
77
+ ```
78
+
79
+ ```
80
+ src/Form.tsrx
81
+ ⚠ 12:43 `onChange` on <input type="text"> is a native commit event in Octane …
82
+ OCTANE_NATIVE_TEXT_ONCHANGE
83
+ suggestion: use `onInput` at 12:43
84
+ ```
85
+
86
+ A file that will not parse is reported as an error and does not stop the rest of
87
+ the run. Exit code is `3` when anything error-severity was found, or when
88
+ `--strict` and there were warnings.
89
+
90
+ ## For agents and CI
91
+
92
+ Every command is fully drivable by flags and emits a single JSON document under
93
+ `--json`. Prompts only ever fill in *missing* input, and only in a real
94
+ terminal: in a pipe or under `CI`, a missing answer is an error naming the flag
95
+ that would have supplied it, never a hang.
96
+
97
+ Exit codes: `0` success, `1` command failure, `2` usage error, `3` doctor found
98
+ error-severity problems.
99
+
100
+ ## `octane mcp add`
101
+
102
+ Registers `@octanejs/mcp-server` with whichever agents are installed. Where the
103
+ client ships its own CLI (`claude`, `codex`) that CLI does the writing, since it
104
+ owns its config schema; otherwise the config file is read, merged, backed up,
105
+ and rewritten. Run inside an Octane checkout, it also sets `OCTANE_REPO_ROOT` so
106
+ the maintainer tools register.
107
+
108
+ ```bash
109
+ octane mcp add # pick from the agents it finds
110
+ octane mcp add claude --scope project
111
+ octane mcp status
112
+ octane mcp remove cursor
113
+ ```
114
+
115
+ ## Adding a command
116
+
117
+ `src/kernel/registry.js` is the command table. An entry carries the name, the
118
+ one-line summary, and a lazy `load()`; the module it loads carries the flags,
119
+ positionals, and `run`. Nothing is duplicated between them, help text is derived
120
+ from the spec rather than written by hand, and only the command actually being
121
+ run is ever imported.
122
+
123
+ ```js
124
+ // src/kernel/registry.js
125
+ { name: 'lint', summary: 'Lint .tsrx sources.', load: () => import('../commands/lint.js') }
126
+ ```
127
+
128
+ ```js
129
+ // src/commands/lint.js
130
+ import { defineCommand } from '../kernel/command.js';
131
+
132
+ export default defineCommand({
133
+ description: 'Lint .tsrx sources.',
134
+ flags: { strict: { type: 'boolean', description: 'Fail on warnings.' } },
135
+ async run(ctx, input) {
136
+ const project = ctx.project();
137
+ ctx.ui.log(`Linting ${project.tsrxFiles.length} file(s)`);
138
+ return { json: { files: project.tsrxFiles.length } };
139
+ },
140
+ });
141
+ ```
142
+
143
+ Commands write human output through `ctx.ui` and return their machine payload as
144
+ `json`; the kernel prints whichever the caller asked for. Process access goes
145
+ through `ctx.exec` so commands that shell out stay testable without spawning.
146
+ A command that writes into the project sets `requiresProject: true`, and the
147
+ kernel refuses to run it outside a `package.json` rather than letting it fail
148
+ somewhere inside an `fs` call.
149
+
150
+ Doctor checks follow the same pattern: add one to
151
+ `src/commands/doctor/checks/<category>.js` with an `id`, a `severity`, a `run`,
152
+ and, only when the repair is unambiguous, a `fix`.
153
+
154
+ ## License
155
+
156
+ MIT
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@octanejs/cli",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=22"
7
+ },
8
+ "description": "The Octane command line: diagnose, configure, and extend an Octane project.",
9
+ "license": "MIT",
10
+ "author": {
11
+ "name": "Dominic Gannaway",
12
+ "email": "dg@domgan.com"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octanejs/octane.git",
17
+ "directory": "packages/cli"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "octane",
24
+ "cli",
25
+ "doctor",
26
+ "tsrx"
27
+ ],
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "bin": {
34
+ "octane": "src/bin/octane.js"
35
+ },
36
+ "exports": {
37
+ ".": "./src/index.js",
38
+ "./package.json": "./package.json"
39
+ },
40
+ "dependencies": {
41
+ "@clack/prompts": "^1.7.0",
42
+ "picocolors": "^1.1.1"
43
+ },
44
+ "devDependencies": {
45
+ "vitest": "^4.1.10"
46
+ },
47
+ "scripts": {
48
+ "test": "cd ../.. && vitest run --project cli"
49
+ }
50
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../kernel/main.js';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,138 @@
1
+ import { defineCommand } from '../kernel/command.js';
2
+ import { BINDINGS, resolveBinding, searchBindings } from '../data/index.js';
3
+ import { EXIT, usageError } from '../kernel/errors.js';
4
+ import { installCommand, installPackages } from '../kernel/install.js';
5
+
6
+ /**
7
+ * @typedef {Object} Resolution
8
+ * @property {string} requested
9
+ * @property {import('../data/index.js').Binding | null} binding
10
+ * @property {'binding' | 'react-package'} [via]
11
+ * @property {string[]} suggestions
12
+ */
13
+
14
+ /**
15
+ * @param {string} name
16
+ * @returns {Resolution}
17
+ */
18
+ function resolve(name) {
19
+ const found = resolveBinding(name);
20
+ if (found) return { requested: name, binding: found.binding, via: found.via, suggestions: [] };
21
+
22
+ // Nothing ports this package. Offer the closest thing rather than a bare no.
23
+ const stem = name.replace(/^@[^/]+\//, '').replace(/^react-|-react$/g, '');
24
+ return {
25
+ requested: name,
26
+ binding: null,
27
+ suggestions: searchBindings(stem, { surface: false })
28
+ .slice(0, 3)
29
+ .map((binding) => binding.name),
30
+ };
31
+ }
32
+
33
+ export default defineCommand({
34
+ requiresProject: true,
35
+ description:
36
+ 'Install an Octane binding, by its own name or by the React package it ports, and\n' +
37
+ 'report the surface it supports and where it deliberately differs from upstream.',
38
+ positionals: [
39
+ {
40
+ name: 'package',
41
+ description: 'Binding or React package name.',
42
+ required: true,
43
+ variadic: true,
44
+ },
45
+ ],
46
+ flags: {
47
+ install: {
48
+ type: 'boolean',
49
+ default: true,
50
+ description: 'Install (--no-install just reports).',
51
+ },
52
+ dev: { type: 'boolean', description: 'Install as a devDependency.' },
53
+ },
54
+
55
+ async run(ctx, input) {
56
+ if (input.positionals.length === 0) {
57
+ throw usageError('Nothing to add.', 'Try: octane add @tanstack/react-query');
58
+ }
59
+
60
+ const project = ctx.project();
61
+ const resolutions = input.positionals.map(resolve);
62
+ const found = resolutions.filter((entry) => entry.binding !== null);
63
+ const missing = resolutions.filter((entry) => entry.binding === null);
64
+
65
+ ctx.ui.intro('octane add');
66
+
67
+ for (const entry of missing) {
68
+ ctx.ui.log(
69
+ ` ${ctx.ui.colors.red('✖')} ${entry.requested} ${ctx.ui.colors.dim('has no Octane binding')}`,
70
+ );
71
+ if (entry.suggestions.length > 0) {
72
+ ctx.ui.log(` ${ctx.ui.colors.dim(`closest: ${entry.suggestions.join(', ')}`)}`);
73
+ }
74
+ }
75
+
76
+ const names = [...new Set(found.map((entry) => /** @type {any} */ (entry.binding).name))];
77
+ const toInstall = names.filter((name) => !project.declaredDependencies[name]);
78
+
79
+ if (names.length > 0) {
80
+ const { manager, args } = installCommand(
81
+ project,
82
+ toInstall.length > 0 ? toInstall : names,
83
+ input.flags.dev,
84
+ );
85
+
86
+ if (ctx.dryRun || !input.flags.install) {
87
+ ctx.ui.note('Would run', [`${manager} ${args.join(' ')}`]);
88
+ } else if (toInstall.length === 0) {
89
+ ctx.ui.log(` ${ctx.ui.colors.dim('Already declared; nothing to install.')}`);
90
+ } else {
91
+ const spinner = ctx.ui.spinner(`Installing ${toInstall.join(', ')}`);
92
+ const ran = await installPackages(ctx, project, toInstall, { dev: input.flags.dev });
93
+ spinner.stop(ran);
94
+ }
95
+ }
96
+
97
+ for (const entry of found) {
98
+ const binding = /** @type {import('../data/index.js').Binding} */ (entry.binding);
99
+ const heading =
100
+ entry.via === 'react-package'
101
+ ? `${binding.name} (ports ${entry.requested})`
102
+ : binding.name;
103
+
104
+ ctx.ui.note(heading, [
105
+ binding.surface,
106
+ ...(binding.divergences.length > 0
107
+ ? ['', 'Differs from upstream:', ...binding.divergences.map((line) => ` - ${line}`)]
108
+ : []),
109
+ ]);
110
+ }
111
+
112
+ const ok = missing.length === 0;
113
+ ctx.ui.outro(
114
+ ok
115
+ ? 'Run `octane doctor` to verify the result.'
116
+ : `Run \`octane bindings\` to browse all ${BINDINGS.length}.`,
117
+ );
118
+
119
+ return {
120
+ exitCode: ok ? EXIT.OK : EXIT.FAILURE,
121
+ json: {
122
+ ok,
123
+ installed: ctx.dryRun || !input.flags.install ? [] : toInstall,
124
+ resolved: found.map((entry) => ({
125
+ requested: entry.requested,
126
+ binding: /** @type {any} */ (entry.binding).name,
127
+ via: entry.via,
128
+ surface: /** @type {any} */ (entry.binding).surface,
129
+ divergences: /** @type {any} */ (entry.binding).divergences,
130
+ })),
131
+ unavailable: missing.map((entry) => ({
132
+ requested: entry.requested,
133
+ suggestions: entry.suggestions,
134
+ })),
135
+ },
136
+ };
137
+ },
138
+ });
@@ -0,0 +1,271 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { defineCommand } from '../kernel/command.js';
6
+ import { CliError, EXIT } from '../kernel/errors.js';
7
+ import { SYMBOLS } from '../kernel/ui.js';
8
+
9
+ /**
10
+ * @typedef {Object} Finding
11
+ * @property {string} file relative to the project root
12
+ * @property {number} line
13
+ * @property {number} column 1-based, matching editor gutters
14
+ * @property {'error' | 'warning'} severity
15
+ * @property {string} code
16
+ * @property {string} message
17
+ * @property {string[]} suggestions
18
+ */
19
+
20
+ /**
21
+ * Load the project's own compiler.
22
+ *
23
+ * The diagnostics belong to the compiler, and it is the compiler in the
24
+ * project's node_modules that decides what this project's code means. Shipping
25
+ * a second copy in the CLI would report on a different version than the one
26
+ * that builds the app.
27
+ *
28
+ * @param {string} root
29
+ * @returns {Promise<(source: string, filename: string, options?: object) => { diagnostics: readonly any[] }>}
30
+ */
31
+ async function loadCompiler(root) {
32
+ const require = createRequire(path.join(root, 'noop.js'));
33
+ let entry;
34
+ try {
35
+ entry = require.resolve('octane/compiler');
36
+ } catch {
37
+ throw new CliError('Could not resolve `octane/compiler` from this project.', {
38
+ hint: 'Install the runtime first: pnpm add octane',
39
+ });
40
+ }
41
+ const module = await import(pathToFileURL(entry).href);
42
+ if (typeof module.compile !== 'function') {
43
+ throw new CliError('The installed octane build exposes no compiler.');
44
+ }
45
+ return module.compile;
46
+ }
47
+
48
+ /**
49
+ * Compiler suggestions are structured edits (a span plus the replacement), not
50
+ * prose. Describe the ones whose shape is known and drop the rest, so the report
51
+ * never prints `[object Object]`.
52
+ *
53
+ * @param {readonly any[] | undefined} suggestions
54
+ * @returns {string[]}
55
+ */
56
+ function describeSuggestions(suggestions) {
57
+ const described = [];
58
+ for (const suggestion of suggestions ?? []) {
59
+ if (typeof suggestion === 'string') described.push(suggestion);
60
+ else if (typeof suggestion?.message === 'string') described.push(suggestion.message);
61
+ else if (typeof suggestion?.attribute === 'string') {
62
+ const at = suggestion.start
63
+ ? ` at ${suggestion.start.line}:${suggestion.start.column + 1}`
64
+ : '';
65
+ described.push(`use \`${suggestion.attribute}\`${at}`);
66
+ }
67
+ }
68
+ return described;
69
+ }
70
+
71
+ /**
72
+ * Position appended by the compiler to a thrown message, e.g. `(App.tsrx:6:18)`.
73
+ * Semantic errors carry their location this way rather than on `loc`.
74
+ */
75
+ const TRAILING_LOCATION = /\s*\(([^()\s]+):(\d+):(\d+)\)\s*$/;
76
+
77
+ /**
78
+ * Normalise a thrown compile failure into a finding.
79
+ *
80
+ * Two shapes reach here. A genuine parse failure carries a Babel-style `loc`.
81
+ * A semantic failure (a slot-keyed hook in a plain JS loop, say) is thrown with
82
+ * its position appended to the message instead, so recovering it keeps the
83
+ * report pointing at the offending line rather than at 1:1.
84
+ *
85
+ * @param {unknown} error
86
+ * @param {string} file
87
+ * @param {string} [code] overrides the derived code, for a read failure
88
+ * @returns {Finding}
89
+ */
90
+ function thrownFailure(error, file, code) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ const loc = /** @type {any} */ (error)?.loc;
93
+ if (loc) {
94
+ return {
95
+ file,
96
+ line: loc.line ?? 1,
97
+ column: (loc.column ?? 0) + 1,
98
+ severity: 'error',
99
+ code: code ?? 'OCTANE_PARSE_ERROR',
100
+ message,
101
+ suggestions: [],
102
+ };
103
+ }
104
+
105
+ const trailing = TRAILING_LOCATION.exec(message);
106
+ return {
107
+ file,
108
+ line: trailing ? Number(trailing[2]) : 1,
109
+ column: trailing ? Number(trailing[3]) : 1,
110
+ severity: 'error',
111
+ // Not every throw is a parse failure; saying so sends people looking for
112
+ // a syntax mistake that is not there.
113
+ code: code ?? 'OCTANE_COMPILE_ERROR',
114
+ // The position now has its own columns, so drop the duplicate tail.
115
+ message: trailing ? message.slice(0, trailing.index) : message,
116
+ suggestions: [],
117
+ };
118
+ }
119
+
120
+ export default defineCommand({
121
+ description:
122
+ 'Compile the project and report every diagnostic the Octane compiler raises:\n' +
123
+ 'native-event mistakes, hydration hazards, renderer-boundary and client-only errors.',
124
+ positionals: [
125
+ {
126
+ name: 'path',
127
+ description: 'Files to analyze. Defaults to every .tsrx in the project.',
128
+ variadic: true,
129
+ },
130
+ ],
131
+ flags: {
132
+ code: {
133
+ type: 'string',
134
+ repeatable: true,
135
+ placeholder: '<CODE>',
136
+ description: 'Only report this diagnostic code. Repeatable.',
137
+ },
138
+ strict: { type: 'boolean', description: 'Fail the run on warnings, not just errors.' },
139
+ },
140
+
141
+ async run(ctx, input) {
142
+ const project = ctx.project();
143
+ const compile = await loadCompiler(project.root);
144
+
145
+ const targets =
146
+ input.positionals.length > 0
147
+ ? input.positionals.map((file) => path.resolve(ctx.cwd, file))
148
+ : project.tsrxFiles;
149
+
150
+ if (targets.length === 0) {
151
+ ctx.ui.intro('octane analyze');
152
+ ctx.ui.outro('No .tsrx files found.');
153
+ return { json: { ok: true, analyzed: 0, findings: [] } };
154
+ }
155
+
156
+ ctx.ui.intro('octane analyze');
157
+ const spinner = ctx.ui.spinner(`Compiling ${targets.length} file(s)`);
158
+
159
+ /** @type {Finding[]} */
160
+ const findings = [];
161
+ for (const absolute of targets) {
162
+ const file = displayPath(project.root, ctx.cwd, absolute);
163
+ let source;
164
+ try {
165
+ source = readFileSync(absolute, 'utf8');
166
+ } catch (error) {
167
+ // Unreadable is not unparseable; saying so sends people to the wrong fix.
168
+ findings.push(thrownFailure(error, file, 'OCTANE_READ_ERROR'));
169
+ continue;
170
+ }
171
+
172
+ try {
173
+ for (const diagnostic of compile(source, absolute, {}).diagnostics ?? []) {
174
+ findings.push({
175
+ file,
176
+ line: diagnostic.start?.line ?? 1,
177
+ column: (diagnostic.start?.column ?? 0) + 1,
178
+ severity: diagnostic.severity === 'error' ? 'error' : 'warning',
179
+ code: diagnostic.code,
180
+ // The compiler prefixes its own code; the report already has a
181
+ // column for it.
182
+ message: String(diagnostic.message).replace(`[${diagnostic.code}] `, ''),
183
+ suggestions: describeSuggestions(diagnostic.suggestions),
184
+ });
185
+ }
186
+ } catch (error) {
187
+ // A file that will not compile is the most severe thing analyze can
188
+ // find, and it must not stop the other files being reported.
189
+ findings.push(thrownFailure(error, file));
190
+ }
191
+ }
192
+ spinner.stop(`Compiled ${targets.length} file(s)`);
193
+
194
+ const selected = input.flags.code?.length
195
+ ? findings.filter((finding) => input.flags.code.includes(finding.code))
196
+ : findings;
197
+
198
+ render(ctx, selected, targets.length);
199
+
200
+ const errors = selected.filter((finding) => finding.severity === 'error').length;
201
+ const warnings = selected.length - errors;
202
+ const failed = errors > 0 || (input.flags.strict && warnings > 0);
203
+
204
+ return {
205
+ exitCode: failed ? EXIT.DIAGNOSTIC : EXIT.OK,
206
+ json: {
207
+ ok: !failed,
208
+ analyzed: targets.length,
209
+ summary: { errors, warnings },
210
+ findings: selected,
211
+ },
212
+ };
213
+ },
214
+ });
215
+
216
+ /**
217
+ * Project-relative where that is meaningful, otherwise relative to the invoking
218
+ * directory, otherwise absolute. An explicit path outside the project would
219
+ * otherwise render as a wall of `../`.
220
+ *
221
+ * @param {string} root
222
+ * @param {string} cwd
223
+ * @param {string} absolute
224
+ * @returns {string}
225
+ */
226
+ function displayPath(root, cwd, absolute) {
227
+ const fromRoot = path.relative(root, absolute);
228
+ if (!fromRoot.startsWith('..')) return fromRoot;
229
+ const fromCwd = path.relative(cwd, absolute);
230
+ return fromCwd.startsWith('..') ? absolute : fromCwd;
231
+ }
232
+
233
+ /**
234
+ * @param {import('../kernel/context.js').Ctx} ctx
235
+ * @param {Finding[]} findings
236
+ * @param {number} analyzed
237
+ */
238
+ function render(ctx, findings, analyzed) {
239
+ const { colors } = ctx.ui;
240
+
241
+ if (findings.length === 0) {
242
+ ctx.ui.outro(colors.green(`No diagnostics across ${analyzed} file(s).`));
243
+ return;
244
+ }
245
+
246
+ for (const file of [...new Set(findings.map((finding) => finding.file))]) {
247
+ ctx.ui.log('');
248
+ ctx.ui.log(colors.bold(file));
249
+
250
+ for (const finding of findings.filter((entry) => entry.file === file)) {
251
+ const mark =
252
+ finding.severity === 'error' ? colors.red(SYMBOLS.fail) : colors.yellow(SYMBOLS.warn);
253
+ const where = colors.dim(`${finding.line}:${finding.column}`);
254
+ ctx.ui.log(` ${mark} ${where} ${finding.message}`);
255
+ ctx.ui.log(` ${colors.dim(finding.code)}`);
256
+ for (const suggestion of finding.suggestions) {
257
+ ctx.ui.log(` ${colors.dim(`suggestion: ${suggestion}`)}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
263
+ const warnings = findings.length - errors;
264
+ /** @type {string[]} */
265
+ const parts = [];
266
+ if (errors > 0) parts.push(colors.red(`${errors} error${errors === 1 ? '' : 's'}`));
267
+ if (warnings > 0) parts.push(colors.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`));
268
+
269
+ ctx.ui.log('');
270
+ ctx.ui.log(`${parts.join(colors.dim(' · '))} ${colors.dim(`across ${analyzed} file(s)`)}`);
271
+ }
@@ -0,0 +1,55 @@
1
+ import { defineCommand } from '../kernel/command.js';
2
+ import { BINDINGS, searchBindings } from '../data/index.js';
3
+
4
+ export default defineCommand({
5
+ description: 'List the @octanejs/* bindings, what they port, and how they differ from upstream.',
6
+ positionals: [
7
+ { name: 'query', description: 'Filter by binding name, upstream package, or category.' },
8
+ ],
9
+ flags: {
10
+ divergences: { type: 'boolean', description: "Include each binding's known divergences." },
11
+ },
12
+
13
+ async run(ctx, input) {
14
+ const query = input.positionals.join(' ');
15
+ const matches = query ? searchBindings(query) : BINDINGS;
16
+
17
+ ctx.ui.intro(query ? `octane bindings: ${query}` : 'octane bindings');
18
+
19
+ if (matches.length === 0) {
20
+ ctx.ui.outro(
21
+ `Nothing matches "${query}". Run \`octane bindings\` to see all ${BINDINGS.length}.`,
22
+ );
23
+ return { json: { query, bindings: [] } };
24
+ }
25
+
26
+ const width = matches.reduce((max, binding) => Math.max(max, binding.name.length), 0);
27
+
28
+ for (const category of [...new Set(matches.map((binding) => binding.category))]) {
29
+ ctx.ui.log('');
30
+ ctx.ui.log(ctx.ui.colors.bold(category));
31
+
32
+ for (const binding of matches.filter((entry) => entry.category === category)) {
33
+ const upstream = binding.upstream
34
+ ? `${binding.upstream.package}@${binding.upstream.version}`
35
+ : 'no React upstream';
36
+ ctx.ui.log(` ${binding.name.padEnd(width)} ${ctx.ui.colors.dim(upstream)}`);
37
+
38
+ if (input.flags.divergences) {
39
+ for (const divergence of binding.divergences) {
40
+ ctx.ui.log(
41
+ ` ${ctx.ui.colors.yellow('diverges:')} ${ctx.ui.colors.dim(divergence)}`,
42
+ );
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ ctx.ui.log('');
49
+ ctx.ui.outro(
50
+ `${matches.length} of ${BINDINGS.length} binding(s). \`octane add <package>\` installs one.`,
51
+ );
52
+
53
+ return { json: { query: query || null, bindings: matches } };
54
+ },
55
+ });