@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/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,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
|
+
});
|