@octanejs/cli 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/package.json +50 -0
- package/src/bin/octane.js +4 -0
- package/src/commands/add.js +138 -0
- package/src/commands/analyze.js +271 -0
- package/src/commands/bindings.js +55 -0
- package/src/commands/doctor/check.js +43 -0
- package/src/commands/doctor/checks/bundler.js +104 -0
- package/src/commands/doctor/checks/config.js +184 -0
- package/src/commands/doctor/checks/dependencies.js +120 -0
- package/src/commands/doctor/checks/environment.js +38 -0
- package/src/commands/doctor/checks/source.js +108 -0
- package/src/commands/doctor/checks/typescript.js +183 -0
- package/src/commands/doctor/index.js +118 -0
- package/src/commands/doctor/registry.js +32 -0
- package/src/commands/doctor/report.js +158 -0
- package/src/commands/explain.js +95 -0
- package/src/commands/info.js +54 -0
- package/src/commands/init/index.js +277 -0
- package/src/commands/init/templates.js +124 -0
- package/src/commands/mcp/add.js +241 -0
- package/src/commands/mcp/clients.js +281 -0
- package/src/commands/mcp/detect.js +58 -0
- package/src/commands/mcp/index.js +23 -0
- package/src/commands/mcp/remove.js +105 -0
- package/src/commands/mcp/server.js +46 -0
- package/src/commands/mcp/status.js +48 -0
- package/src/data/index.js +74 -0
- package/src/data/octane-data.json +953 -0
- package/src/index.js +4 -0
- package/src/kernel/args.js +181 -0
- package/src/kernel/banner.js +98 -0
- package/src/kernel/command.js +84 -0
- package/src/kernel/context.js +78 -0
- package/src/kernel/edit.js +238 -0
- package/src/kernel/errors.js +42 -0
- package/src/kernel/exec.js +62 -0
- package/src/kernel/help.js +97 -0
- package/src/kernel/install.js +43 -0
- package/src/kernel/jsonc.js +91 -0
- package/src/kernel/main.js +166 -0
- package/src/kernel/project.js +376 -0
- package/src/kernel/registry.js +52 -0
- package/src/kernel/semver.js +111 -0
- package/src/kernel/ui.js +155 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { defineCommand } from '../../kernel/command.js';
|
|
4
|
+
import { setCompilerOption } from '../../kernel/edit.js';
|
|
5
|
+
import { CliError, EXIT } from '../../kernel/errors.js';
|
|
6
|
+
import { installPackages } from '../../kernel/install.js';
|
|
7
|
+
import {
|
|
8
|
+
MODES,
|
|
9
|
+
SCRIPTS,
|
|
10
|
+
VITE_SCRIPTS,
|
|
11
|
+
appComponent,
|
|
12
|
+
integrationFor,
|
|
13
|
+
octaneConfig,
|
|
14
|
+
tsconfig,
|
|
15
|
+
viteConfig,
|
|
16
|
+
} from './templates.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} Change
|
|
20
|
+
* @property {string} file relative to the project root
|
|
21
|
+
* @property {string} summary
|
|
22
|
+
* @property {() => void} apply
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refuse to rewrite a project with uncommitted work in it, so `git diff` stays
|
|
27
|
+
* a usable way to review what init did.
|
|
28
|
+
*
|
|
29
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
30
|
+
* @returns {Promise<boolean>} true when the tree is dirty
|
|
31
|
+
*/
|
|
32
|
+
async function isDirty(ctx) {
|
|
33
|
+
if (!ctx.exec.which('git')) return false;
|
|
34
|
+
const result = await ctx.exec.run('git', ['status', '--porcelain'], { cwd: ctx.cwd });
|
|
35
|
+
return result.code === 0 && result.stdout.trim() !== '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} file
|
|
40
|
+
* @param {string} body
|
|
41
|
+
* @returns {() => void}
|
|
42
|
+
*/
|
|
43
|
+
const writeFile = (file, body) => () => {
|
|
44
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
45
|
+
writeFileSync(file, body);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('../../kernel/project.js').Project} project
|
|
50
|
+
* @param {keyof typeof MODES} mode
|
|
51
|
+
* @param {ReturnType<typeof integrationFor>} integration
|
|
52
|
+
* @returns {{ changes: Change[], manual: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
function plan(project, mode, integration) {
|
|
55
|
+
const { root } = project;
|
|
56
|
+
/** @type {Change[]} */
|
|
57
|
+
const changes = [];
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const manual = [];
|
|
60
|
+
const at = (/** @type {string} */ file) => path.join(root, file);
|
|
61
|
+
|
|
62
|
+
if (!project.tsconfig) {
|
|
63
|
+
changes.push({
|
|
64
|
+
file: 'tsconfig.json',
|
|
65
|
+
summary: 'create, configured for .tsrx',
|
|
66
|
+
apply: writeFile(at('tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`),
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
const file = project.tsconfig.path;
|
|
70
|
+
const options = project.tsconfig.config.compilerOptions ?? {};
|
|
71
|
+
/** @type {[string, unknown][]} */
|
|
72
|
+
const needed = [];
|
|
73
|
+
if (options.jsxImportSource !== 'octane') needed.push(['jsxImportSource', 'octane']);
|
|
74
|
+
if (!Array.isArray(options.plugins))
|
|
75
|
+
needed.push(['plugins', [{ name: '@tsrx/typescript-plugin' }]]);
|
|
76
|
+
else if (
|
|
77
|
+
!options.plugins.some((/** @type {any} */ p) => p?.name === '@tsrx/typescript-plugin')
|
|
78
|
+
) {
|
|
79
|
+
manual.push('Add { "name": "@tsrx/typescript-plugin" } to compilerOptions.plugins.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (needed.length > 0) {
|
|
83
|
+
changes.push({
|
|
84
|
+
file: path.relative(root, file),
|
|
85
|
+
summary: `set ${needed.map(([key]) => key).join(', ')}`,
|
|
86
|
+
apply() {
|
|
87
|
+
let text = readFileSync(file, 'utf8');
|
|
88
|
+
for (const [key, value] of needed) {
|
|
89
|
+
const edited = setCompilerOption(text, key, value);
|
|
90
|
+
if (!edited) throw new CliError(`Could not edit ${path.basename(file)} safely.`);
|
|
91
|
+
text = edited.text;
|
|
92
|
+
}
|
|
93
|
+
writeFileSync(file, text);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const bundlerConfigPath = project.bundlerConfigPath ?? at('vite.config.ts');
|
|
100
|
+
if (!project.bundlerConfigPath) {
|
|
101
|
+
changes.push({
|
|
102
|
+
file: 'vite.config.ts',
|
|
103
|
+
summary: `create, registering ${integration.specifier}`,
|
|
104
|
+
apply: writeFile(bundlerConfigPath, viteConfig(mode)),
|
|
105
|
+
});
|
|
106
|
+
} else if (!readFileSync(bundlerConfigPath, 'utf8').includes(integration.specifier)) {
|
|
107
|
+
// Rewriting an arbitrary bundler config is guesswork; state the exact
|
|
108
|
+
// edit instead of attempting it. The specifier is the one for THIS
|
|
109
|
+
// project's bundler, not the Vite plugin init would otherwise scaffold.
|
|
110
|
+
manual.push(
|
|
111
|
+
`In ${path.relative(root, bundlerConfigPath)}, add: import { octane } from '${integration.specifier}';`,
|
|
112
|
+
'then include octane() in the plugins array.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// init only knows how to scaffold Vite. On another bundler it still does the
|
|
117
|
+
// parts that are correct everywhere (tsconfig, the right plugin, the right
|
|
118
|
+
// dependency) and states the rest, rather than writing an octane.config.ts
|
|
119
|
+
// whose import does not resolve or `vite` scripts with no vite installed.
|
|
120
|
+
// @octanejs/rspack-plugin in particular exports no defineConfig/RenderRoute:
|
|
121
|
+
// it is the low-level integration where the app owns its own shell.
|
|
122
|
+
const scaffoldsVite = project.bundler === null || project.bundler === 'vite';
|
|
123
|
+
|
|
124
|
+
if (mode === 'fullstack' && !scaffoldsVite) {
|
|
125
|
+
manual.push(
|
|
126
|
+
`Add an octane.config.ts for ${project.bundler}: see https://octanejs.dev/docs/build-tools`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (mode === 'fullstack' && scaffoldsVite) {
|
|
131
|
+
if (!project.octaneConfigPath) {
|
|
132
|
+
changes.push({
|
|
133
|
+
file: 'octane.config.ts',
|
|
134
|
+
summary: 'create, with one route at /',
|
|
135
|
+
apply: writeFile(at('octane.config.ts'), octaneConfig),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (!existsSync(at('src/App.tsrx'))) {
|
|
139
|
+
changes.push({
|
|
140
|
+
file: 'src/App.tsrx',
|
|
141
|
+
summary: 'create the route entry referenced by octane.config.ts',
|
|
142
|
+
apply: writeFile(at('src/App.tsrx'), appComponent),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scripts = project.manifest.scripts ?? {};
|
|
148
|
+
const applicable = scaffoldsVite ? { ...VITE_SCRIPTS, ...SCRIPTS } : SCRIPTS;
|
|
149
|
+
const missing = Object.entries(applicable).filter(([name]) => !scripts[name]);
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
changes.push({
|
|
152
|
+
file: 'package.json',
|
|
153
|
+
summary: `add scripts: ${missing.map(([name]) => name).join(', ')}`,
|
|
154
|
+
apply() {
|
|
155
|
+
const manifest = JSON.parse(readFileSync(at('package.json'), 'utf8'));
|
|
156
|
+
manifest.scripts = { ...manifest.scripts, ...Object.fromEntries(missing) };
|
|
157
|
+
writeFileSync(at('package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { changes, manual };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default defineCommand({
|
|
166
|
+
requiresProject: true,
|
|
167
|
+
description:
|
|
168
|
+
'Wire Octane into the project in this directory: the bundler plugin, the TypeScript\n' +
|
|
169
|
+
'settings .tsrx needs, the scripts, and the dependencies.',
|
|
170
|
+
flags: {
|
|
171
|
+
mode: {
|
|
172
|
+
type: 'string',
|
|
173
|
+
choices: Object.keys(MODES),
|
|
174
|
+
placeholder: '<mode>',
|
|
175
|
+
description: 'spa for a client-only app, fullstack for routing and SSR.',
|
|
176
|
+
},
|
|
177
|
+
install: {
|
|
178
|
+
type: 'boolean',
|
|
179
|
+
default: true,
|
|
180
|
+
description: 'Install dependencies (--no-install skips).',
|
|
181
|
+
},
|
|
182
|
+
force: { type: 'boolean', description: 'Proceed even with uncommitted changes.' },
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async run(ctx, input) {
|
|
186
|
+
const project = ctx.project();
|
|
187
|
+
|
|
188
|
+
if (!input.flags.force && !ctx.dryRun && (await isDirty(ctx))) {
|
|
189
|
+
throw new CliError('This project has uncommitted changes.', {
|
|
190
|
+
hint: 'Commit or stash first so you can review what init writes, or pass --force.',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const mode = /** @type {keyof typeof MODES} */ (
|
|
195
|
+
input.flags.mode ??
|
|
196
|
+
(await ctx.ui.select({
|
|
197
|
+
message: 'What are you building?',
|
|
198
|
+
flag: '--mode',
|
|
199
|
+
initial: 'spa',
|
|
200
|
+
options: Object.entries(MODES).map(([value, entry]) => ({
|
|
201
|
+
value,
|
|
202
|
+
label: entry.label,
|
|
203
|
+
hint: entry.hint,
|
|
204
|
+
})),
|
|
205
|
+
}))
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const integration = integrationFor(project.bundler, mode);
|
|
209
|
+
const { changes, manual } = plan(project, mode, integration);
|
|
210
|
+
const declared = project.declaredDependencies;
|
|
211
|
+
const dependencies = integration.dependencies.filter((name) => !declared[name]);
|
|
212
|
+
const devDependencies = integration.devDependencies.filter((name) => !declared[name]);
|
|
213
|
+
|
|
214
|
+
ctx.ui.intro('octane init');
|
|
215
|
+
|
|
216
|
+
if (changes.length === 0 && dependencies.length === 0 && devDependencies.length === 0) {
|
|
217
|
+
ctx.ui.outro('Already set up. Run `octane doctor` to confirm.');
|
|
218
|
+
return { json: { ok: true, mode, changes: [], installed: [] } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ctx.ui.note(
|
|
222
|
+
ctx.dryRun ? 'Would change' : 'Will change',
|
|
223
|
+
changes.length > 0
|
|
224
|
+
? changes.map((c) => `${c.file} ${ctx.ui.colors.dim(c.summary)}`)
|
|
225
|
+
: ['nothing'],
|
|
226
|
+
);
|
|
227
|
+
if (dependencies.length + devDependencies.length > 0) {
|
|
228
|
+
ctx.ui.note('Will install', [
|
|
229
|
+
...dependencies,
|
|
230
|
+
...devDependencies.map((name) => `${name} (dev)`),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (ctx.dryRun) {
|
|
235
|
+
return {
|
|
236
|
+
json: { ok: true, dryRun: true, mode, changes: changes.map((c) => c.file), manual },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const confirmed = await ctx.ui.confirm({ message: 'Apply?', flag: '--yes', initial: true });
|
|
241
|
+
if (!confirmed)
|
|
242
|
+
return { exitCode: EXIT.OK, json: { ok: true, mode, changes: [], installed: [] } };
|
|
243
|
+
|
|
244
|
+
for (const change of changes) change.apply();
|
|
245
|
+
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
const installed = [];
|
|
248
|
+
if (input.flags.install && dependencies.length + devDependencies.length > 0) {
|
|
249
|
+
const spinner = ctx.ui.spinner(`Installing with ${project.packageManager ?? 'npm'}`);
|
|
250
|
+
try {
|
|
251
|
+
for (const [names, dev] of [
|
|
252
|
+
[dependencies, false],
|
|
253
|
+
[devDependencies, true],
|
|
254
|
+
]) {
|
|
255
|
+
if (/** @type {string[]} */ (names).length === 0) continue;
|
|
256
|
+
await installPackages(ctx, project, /** @type {string[]} */ (names), {
|
|
257
|
+
dev: Boolean(dev),
|
|
258
|
+
});
|
|
259
|
+
installed.push(.../** @type {string[]} */ (names));
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
spinner.stop('Install failed');
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
spinner.stop(`Installed ${installed.length} package(s)`);
|
|
266
|
+
} else if (dependencies.length + devDependencies.length > 0) {
|
|
267
|
+
manual.push(`Install: ${[...dependencies, ...devDependencies].join(' ')}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (manual.length > 0) ctx.ui.note('Do this by hand', manual);
|
|
271
|
+
ctx.ui.outro('Run `octane doctor` to verify the result.');
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
json: { ok: true, mode, changes: changes.map((c) => c.file), installed, manual },
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two supported ways to compile Octane.
|
|
3
|
+
*
|
|
4
|
+
* `spa` wires the compiler plugin only, which is all a client-rendered app
|
|
5
|
+
* needs. `fullstack` wires the metaframework plugin, which adds routing,
|
|
6
|
+
* streaming SSR, hydration, and the production build.
|
|
7
|
+
*/
|
|
8
|
+
export const MODES = {
|
|
9
|
+
spa: {
|
|
10
|
+
label: 'Client-only app',
|
|
11
|
+
hint: "compiles .tsrx, keeps the bundler's standard HTML handling",
|
|
12
|
+
dependencies: [],
|
|
13
|
+
},
|
|
14
|
+
fullstack: {
|
|
15
|
+
label: 'Routing and SSR',
|
|
16
|
+
hint: 'adds octane.config.ts, streaming SSR, hydration, production build',
|
|
17
|
+
dependencies: [],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which package supplies the Octane plugin, per bundler and mode.
|
|
23
|
+
*
|
|
24
|
+
* `init` only scaffolds a Vite project, but it also advises projects that
|
|
25
|
+
* already have a bundler config, and naming the Vite plugin at an Rspack
|
|
26
|
+
* project would wire the wrong plugin into the wrong bundler.
|
|
27
|
+
*/
|
|
28
|
+
const INTEGRATIONS = {
|
|
29
|
+
vite: {
|
|
30
|
+
spa: { specifier: 'octane/compiler/vite', packages: [] },
|
|
31
|
+
fullstack: { specifier: '@octanejs/vite-plugin', packages: ['@octanejs/vite-plugin'] },
|
|
32
|
+
},
|
|
33
|
+
rspack: {
|
|
34
|
+
spa: { specifier: 'octane/compiler/bundler', packages: [] },
|
|
35
|
+
fullstack: { specifier: '@octanejs/rspack-plugin', packages: ['@octanejs/rspack-plugin'] },
|
|
36
|
+
},
|
|
37
|
+
rsbuild: {
|
|
38
|
+
spa: { specifier: 'octane/compiler/bundler', packages: [] },
|
|
39
|
+
fullstack: { specifier: '@octanejs/rsbuild-plugin', packages: ['@octanejs/rsbuild-plugin'] },
|
|
40
|
+
},
|
|
41
|
+
rspeedy: {
|
|
42
|
+
spa: { specifier: 'octane/compiler/bundler', packages: [] },
|
|
43
|
+
fullstack: { specifier: '@octanejs/rspeedy-plugin', packages: ['@octanejs/rspeedy-plugin'] },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {keyof typeof INTEGRATIONS | null} bundler defaults to vite, which is
|
|
49
|
+
* what `init` scaffolds when a project has no bundler config yet
|
|
50
|
+
* @param {keyof typeof MODES} mode
|
|
51
|
+
* @returns {{ specifier: string, dependencies: string[], devDependencies: string[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function integrationFor(bundler, mode) {
|
|
54
|
+
const target = INTEGRATIONS[bundler ?? 'vite'] ?? INTEGRATIONS.vite;
|
|
55
|
+
return {
|
|
56
|
+
specifier: target[mode].specifier,
|
|
57
|
+
dependencies: ['octane', ...target[mode].packages],
|
|
58
|
+
// The bundler itself is only ours to install when we are the ones
|
|
59
|
+
// creating its config.
|
|
60
|
+
devDependencies: ['@tsrx/typescript-plugin', ...(bundler === null ? ['vite'] : [])],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {keyof typeof MODES} mode
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export const viteConfig = (mode) => `import { defineConfig } from 'vite';
|
|
69
|
+
import { octane } from '${integrationFor('vite', mode).specifier}';
|
|
70
|
+
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
\tplugins: [octane()],
|
|
73
|
+
\tbuild: { target: 'esnext' },
|
|
74
|
+
});
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
export const octaneConfig = `import { defineConfig, RenderRoute } from '@octanejs/vite-plugin';
|
|
78
|
+
|
|
79
|
+
export default defineConfig({
|
|
80
|
+
\trouter: {
|
|
81
|
+
\t\troutes: [new RenderRoute({ path: '/', entry: ['App', '/src/App.tsrx'] })],
|
|
82
|
+
\t},
|
|
83
|
+
});
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
export const appComponent = `export function App() @{
|
|
87
|
+
\t<div class="app">
|
|
88
|
+
\t\t<h1>{'Hello from Octane' as string}</h1>
|
|
89
|
+
\t</div>
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
export const tsconfig = {
|
|
94
|
+
compilerOptions: {
|
|
95
|
+
target: 'esnext',
|
|
96
|
+
module: 'esnext',
|
|
97
|
+
moduleResolution: 'bundler',
|
|
98
|
+
lib: ['esnext', 'dom', 'dom.iterable'],
|
|
99
|
+
strict: true,
|
|
100
|
+
noEmit: true,
|
|
101
|
+
allowImportingTsExtensions: true,
|
|
102
|
+
isolatedModules: true,
|
|
103
|
+
esModuleInterop: true,
|
|
104
|
+
skipLibCheck: true,
|
|
105
|
+
jsx: 'react-jsx',
|
|
106
|
+
jsxImportSource: 'octane',
|
|
107
|
+
plugins: [{ name: '@tsrx/typescript-plugin' }],
|
|
108
|
+
},
|
|
109
|
+
include: ['src/**/*', 'vite.config.ts'],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Scripts that are correct whatever the bundler is. `tsrx-tsc` is the only
|
|
114
|
+
* typechecker that reads `.tsrx`, regardless of how the project builds.
|
|
115
|
+
*/
|
|
116
|
+
export const SCRIPTS = {
|
|
117
|
+
typecheck: 'tsrx-tsc --noEmit -p tsconfig.json',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Added only when init is the one creating the Vite config. */
|
|
121
|
+
export const VITE_SCRIPTS = {
|
|
122
|
+
dev: 'vite',
|
|
123
|
+
build: 'vite build',
|
|
124
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { defineCommand } from '../../kernel/command.js';
|
|
4
|
+
import { CliError, EXIT, usageError } from '../../kernel/errors.js';
|
|
5
|
+
import { CLIENTS, findClient, readConfigText } from './clients.js';
|
|
6
|
+
import { detectClients, isPresent } from './detect.js';
|
|
7
|
+
import { SERVER_NAME, SERVER_PACKAGE, buildServerEntry, findRepoRoot } from './server.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} Outcome
|
|
11
|
+
* @property {string} client
|
|
12
|
+
* @property {string} scope
|
|
13
|
+
* @property {'cli' | 'file'} method
|
|
14
|
+
* @property {string} target
|
|
15
|
+
* @property {'added' | 'updated' | 'unchanged' | 'skipped' | 'failed'} status
|
|
16
|
+
* @property {string} message
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Install by handing the work to the client's own CLI, which owns its config
|
|
21
|
+
* schema and can change it without telling us.
|
|
22
|
+
*
|
|
23
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
24
|
+
* @param {import('./detect.js').ClientState} state
|
|
25
|
+
* @param {import('./server.js').ServerEntry} entry
|
|
26
|
+
* @returns {Promise<Outcome | null>} null when the CLI route is unavailable
|
|
27
|
+
*/
|
|
28
|
+
async function installViaCli(ctx, state, entry) {
|
|
29
|
+
const { client } = state;
|
|
30
|
+
const args =
|
|
31
|
+
client.cli && state.cliAvailable ? client.cliArgs(SERVER_NAME, entry, state.scope) : null;
|
|
32
|
+
if (!client.cli || !args) return null;
|
|
33
|
+
|
|
34
|
+
const result = await ctx.exec.run(client.cli, args, { cwd: ctx.cwd });
|
|
35
|
+
const base = {
|
|
36
|
+
client: client.id,
|
|
37
|
+
scope: state.scope,
|
|
38
|
+
method: /** @type {const} */ ('cli'),
|
|
39
|
+
target: client.cli,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (result.code !== 0) {
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
status: 'failed',
|
|
46
|
+
message:
|
|
47
|
+
(result.stderr || result.stdout).trim() || `${client.cli} exited with ${result.code}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
status: state.server ? 'updated' : 'added',
|
|
53
|
+
message: `${client.cli} ${args.join(' ')}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
59
|
+
* @param {import('./detect.js').ClientState} state
|
|
60
|
+
* @param {import('./server.js').ServerEntry} entry
|
|
61
|
+
* @returns {Outcome}
|
|
62
|
+
*/
|
|
63
|
+
function installViaFile(ctx, state, entry) {
|
|
64
|
+
const { client, configPath } = state;
|
|
65
|
+
const current = readConfigText(configPath);
|
|
66
|
+
const next = client.write(current, SERVER_NAME, entry);
|
|
67
|
+
const base = {
|
|
68
|
+
client: client.id,
|
|
69
|
+
scope: state.scope,
|
|
70
|
+
method: /** @type {const} */ ('file'),
|
|
71
|
+
target: configPath,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (next === current) return { ...base, status: 'unchanged', message: 'Already up to date' };
|
|
75
|
+
|
|
76
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
77
|
+
// Back up anything that was already there. These files hold the user's other
|
|
78
|
+
// servers and, for Claude Code, unrelated state.
|
|
79
|
+
if (current !== '') copyFileSync(configPath, `${configPath}.octane-backup`);
|
|
80
|
+
writeFileSync(configPath, next);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
status: state.server ? 'updated' : 'added',
|
|
85
|
+
message:
|
|
86
|
+
current === '' ? 'Created' : `Merged (backup at ${path.basename(configPath)}.octane-backup)`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default defineCommand({
|
|
91
|
+
description:
|
|
92
|
+
`Register the Octane MCP server (${SERVER_PACKAGE}) with a coding agent, so it can\n` +
|
|
93
|
+
'reach the Octane skills, bindings data, and React-to-Octane bridging tools.',
|
|
94
|
+
positionals: [
|
|
95
|
+
{
|
|
96
|
+
name: 'client',
|
|
97
|
+
description: `Clients to configure: ${CLIENTS.map((c) => c.id).join(', ')}. Defaults to every detected client.`,
|
|
98
|
+
variadic: true,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
flags: {
|
|
102
|
+
scope: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
choices: ['user', 'project'],
|
|
105
|
+
default: 'user',
|
|
106
|
+
placeholder: '<scope>',
|
|
107
|
+
description: 'Install for this user, or into this project so the team shares it.',
|
|
108
|
+
},
|
|
109
|
+
command: {
|
|
110
|
+
type: 'string',
|
|
111
|
+
placeholder: '<bin>',
|
|
112
|
+
description: `Run the server this way instead of \`npx -y ${SERVER_PACKAGE}\`.`,
|
|
113
|
+
},
|
|
114
|
+
force: { type: 'boolean', description: 'Overwrite an existing octane entry without asking.' },
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async run(ctx, input) {
|
|
118
|
+
const scope = /** @type {import('./clients.js').Scope} */ (input.flags.scope);
|
|
119
|
+
const project = ctx.project();
|
|
120
|
+
const detected = detectClients(ctx, scope, project.root);
|
|
121
|
+
|
|
122
|
+
const selected =
|
|
123
|
+
input.positionals.length > 0
|
|
124
|
+
? resolveRequested(input.positionals, detected)
|
|
125
|
+
: await chooseClients(ctx, detected);
|
|
126
|
+
|
|
127
|
+
if (selected.length === 0) throw new CliError('No clients selected.');
|
|
128
|
+
|
|
129
|
+
const repoRoot = findRepoRoot(ctx.cwd);
|
|
130
|
+
const entry = buildServerEntry({ command: input.flags.command, repoRoot });
|
|
131
|
+
|
|
132
|
+
ctx.ui.intro('octane mcp add');
|
|
133
|
+
if (repoRoot)
|
|
134
|
+
ctx.ui.log(ctx.ui.colors.dim(`Detected an Octane checkout: OCTANE_REPO_ROOT=${repoRoot}`));
|
|
135
|
+
|
|
136
|
+
if (ctx.dryRun) {
|
|
137
|
+
for (const state of selected) {
|
|
138
|
+
ctx.ui.note(`${state.client.label} (${scope})`, [
|
|
139
|
+
state.cliAvailable && state.client.cli
|
|
140
|
+
? `would run: ${state.client.cli} ${state.client.cliArgs(SERVER_NAME, entry, scope)?.join(' ')}`
|
|
141
|
+
: `would write: ${state.configPath}`,
|
|
142
|
+
...JSON.stringify({ [SERVER_NAME]: entry }, null, 2).split('\n'),
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
return { json: { ok: true, dryRun: true, clients: selected.map((s) => s.client.id) } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @type {Outcome[]} */
|
|
149
|
+
const outcomes = [];
|
|
150
|
+
for (const state of selected) {
|
|
151
|
+
const existing = state.server && !input.flags.force;
|
|
152
|
+
if (existing) {
|
|
153
|
+
const replace = await ctx.ui.confirm({
|
|
154
|
+
message: `${state.client.label} already has an "${SERVER_NAME}" server. Replace it?`,
|
|
155
|
+
flag: '--force',
|
|
156
|
+
initial: false,
|
|
157
|
+
});
|
|
158
|
+
if (!replace) {
|
|
159
|
+
const viaCli = state.cliAvailable && state.client.cli;
|
|
160
|
+
outcomes.push({
|
|
161
|
+
client: state.client.id,
|
|
162
|
+
scope,
|
|
163
|
+
method: viaCli ? 'cli' : 'file',
|
|
164
|
+
target: viaCli ? /** @type {string} */ (state.client.cli) : state.configPath,
|
|
165
|
+
status: 'skipped',
|
|
166
|
+
message: 'Left the existing entry in place',
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
outcomes.push((await installViaCli(ctx, state, entry)) ?? installViaFile(ctx, state, entry));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const outcome of outcomes) {
|
|
175
|
+
const failed = outcome.status === 'failed';
|
|
176
|
+
const mark = failed ? ctx.ui.colors.red('✖') : ctx.ui.colors.green('✔');
|
|
177
|
+
ctx.ui.log(
|
|
178
|
+
` ${mark} ${outcome.client} (${outcome.scope}) ${ctx.ui.colors.dim(outcome.message)}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
ctx.ui.outro('Restart the agent to pick up the new server.');
|
|
182
|
+
|
|
183
|
+
const ok = outcomes.every((outcome) => outcome.status !== 'failed');
|
|
184
|
+
return { exitCode: ok ? EXIT.OK : EXIT.FAILURE, json: { ok, server: entry, outcomes } };
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string[]} ids
|
|
190
|
+
* @param {import('./detect.js').ClientState[]} detected
|
|
191
|
+
* @returns {import('./detect.js').ClientState[]}
|
|
192
|
+
*/
|
|
193
|
+
function resolveRequested(ids, detected) {
|
|
194
|
+
return ids.map((id) => {
|
|
195
|
+
if (!findClient(id)) {
|
|
196
|
+
throw usageError(
|
|
197
|
+
`Unknown client: ${id}`,
|
|
198
|
+
`Known clients: ${CLIENTS.map((c) => c.id).join(', ')}.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const state = detected.find((candidate) => candidate.client.id === id);
|
|
202
|
+
if (!state) {
|
|
203
|
+
throw usageError(
|
|
204
|
+
`${id} does not support --scope ${detected[0]?.scope ?? 'this scope'}.`,
|
|
205
|
+
`${id} supports: ${findClient(id)?.scopes.join(', ')}.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return state;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {import('../../kernel/context.js').Ctx} ctx
|
|
214
|
+
* @param {import('./detect.js').ClientState[]} detected
|
|
215
|
+
* @returns {Promise<import('./detect.js').ClientState[]>}
|
|
216
|
+
*/
|
|
217
|
+
async function chooseClients(ctx, detected) {
|
|
218
|
+
const present = detected.filter(isPresent);
|
|
219
|
+
if (present.length === 0) {
|
|
220
|
+
throw new CliError('No supported agent found on this machine.', {
|
|
221
|
+
hint: `Name one explicitly: octane mcp add ${CLIENTS.map((c) => c.id).join('|')}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const chosen = await ctx.ui.multiselect({
|
|
226
|
+
message: 'Which agents should get the Octane MCP server?',
|
|
227
|
+
flag: '<client>',
|
|
228
|
+
initial: present.map((state) => state.client.id),
|
|
229
|
+
options: present.map((state) => ({
|
|
230
|
+
value: state.client.id,
|
|
231
|
+
label: state.client.label,
|
|
232
|
+
hint: state.server
|
|
233
|
+
? 'already configured'
|
|
234
|
+
: state.cliAvailable
|
|
235
|
+
? 'via its CLI'
|
|
236
|
+
: state.configPath,
|
|
237
|
+
})),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return present.filter((state) => chosen.includes(state.client.id));
|
|
241
|
+
}
|