@octanejs/mcp-server 0.2.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 +129 -0
- package/package.json +41 -0
- package/skills/bridge-react-package.md +117 -0
- package/skills/migrate-react-component.md +98 -0
- package/skills/react-divergences.md +50 -0
- package/skills/setup-ssr.md +73 -0
- package/src/bridge.js +314 -0
- package/src/bridge.test.js +154 -0
- package/src/index.js +428 -0
- package/src/index.test.js +87 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { bridgeReport, KNOWN_BINDINGS } from './bridge.js';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const PACKAGE_ROOT = resolve(dirname(__filename), '..');
|
|
14
|
+
|
|
15
|
+
// Bundled skills ship with the npm package and work in ANY project using
|
|
16
|
+
// octane. Repo skills live in the octane monorepo's .ai/skills and are only
|
|
17
|
+
// available when the server runs against a checkout (they cover maintainer
|
|
18
|
+
// workflows: triage, PRs, core changes).
|
|
19
|
+
export const BUNDLED_SKILLS = {
|
|
20
|
+
'bridge-react-package': 'skills/bridge-react-package.md',
|
|
21
|
+
'migrate-react-component': 'skills/migrate-react-component.md',
|
|
22
|
+
'react-divergences': 'skills/react-divergences.md',
|
|
23
|
+
'setup-ssr': 'skills/setup-ssr.md',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const REPO_SKILLS = {
|
|
27
|
+
'bug-hunter': '.ai/skills/bug-hunter.md',
|
|
28
|
+
'create-a-pr': '.ai/skills/create-a-pr.md',
|
|
29
|
+
'handle-issue': '.ai/skills/handle-issue.md',
|
|
30
|
+
'octane-core-extend': '.ai/skills/octane-core-extend.md',
|
|
31
|
+
'performance-audit': '.ai/skills/performance-audit.md',
|
|
32
|
+
'react-library-port': '.ai/skills/react-library-port.md',
|
|
33
|
+
triage: '.ai/skills/triage.md',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const BENCHMARKS = {
|
|
37
|
+
dbmon: '@benchmarks/dbmon',
|
|
38
|
+
'js-framework': 'octane-js-framework-benchmarks',
|
|
39
|
+
news: '@benchmarks/news',
|
|
40
|
+
'recursive-context': '@benchmarks/recursive-context',
|
|
41
|
+
'signal-favoring': '@benchmarks/signal-favoring',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
45
|
+
|
|
46
|
+
export function text(content) {
|
|
47
|
+
return { content: [{ type: 'text', text: content }] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isOctaneRepo(root) {
|
|
51
|
+
return existsSync(resolve(root, 'packages/octane/src/runtime.ts'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function areaForPath(path) {
|
|
55
|
+
if (path.startsWith('packages/octane/src/compiler/')) return 'compiler';
|
|
56
|
+
if (path.startsWith('packages/octane/src/server/') || path.includes('runtime.server'))
|
|
57
|
+
return 'ssr';
|
|
58
|
+
if (path.startsWith('packages/octane/src/runtime') || path === 'packages/octane/src/index.ts') {
|
|
59
|
+
return 'core-runtime';
|
|
60
|
+
}
|
|
61
|
+
if (path.startsWith('packages/octane/tests/')) return 'core-tests';
|
|
62
|
+
if (path.startsWith('packages/vite-plugin-octane/')) return 'vite-plugin';
|
|
63
|
+
if (path.startsWith('packages/octane-mcp-server/')) return 'mcp-server';
|
|
64
|
+
if (/^packages\/(zustand|query|motion|stylex|router|lexical|floating-ui|radix)\//.test(path)) {
|
|
65
|
+
return 'ecosystem-binding';
|
|
66
|
+
}
|
|
67
|
+
if (path.startsWith('benchmarks/')) return 'benchmark';
|
|
68
|
+
if (path.startsWith('.rulesync/')) return 'rulesync-source';
|
|
69
|
+
if (path.startsWith('.ai/') || path.startsWith('.codex/') || path.startsWith('.claude/')) {
|
|
70
|
+
return 'agent-instructions';
|
|
71
|
+
}
|
|
72
|
+
if (path.startsWith('docs/') || path.endsWith('.md')) return 'docs';
|
|
73
|
+
return 'repo-tooling';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validationFor(paths, taskKind) {
|
|
77
|
+
const areas = new Set(paths.map(areaForPath));
|
|
78
|
+
const commands = new Set();
|
|
79
|
+
|
|
80
|
+
if (areas.has('rulesync-source')) commands.add('pnpm rules:generate');
|
|
81
|
+
if (
|
|
82
|
+
areas.has('core-runtime') ||
|
|
83
|
+
areas.has('compiler') ||
|
|
84
|
+
areas.has('ssr') ||
|
|
85
|
+
areas.has('core-tests')
|
|
86
|
+
) {
|
|
87
|
+
commands.add('./node_modules/.bin/vitest run packages/octane/tests --project octane');
|
|
88
|
+
}
|
|
89
|
+
if (areas.has('ecosystem-binding')) {
|
|
90
|
+
for (const path of paths) {
|
|
91
|
+
const match = path.match(
|
|
92
|
+
/^packages\/(zustand|query|motion|stylex|router|lexical|floating-ui|radix)\//,
|
|
93
|
+
);
|
|
94
|
+
if (match)
|
|
95
|
+
commands.add(
|
|
96
|
+
`./node_modules/.bin/vitest run packages/${match[1]}/tests --project ${match[1]}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (areas.has('mcp-server')) {
|
|
101
|
+
commands.add(
|
|
102
|
+
'./node_modules/.bin/vitest run packages/octane-mcp-server --project octane-mcp-server',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (areas.has('vite-plugin')) commands.add('pnpm typecheck');
|
|
106
|
+
if (areas.has('benchmark') || taskKind === 'performance') commands.add('pnpm bench');
|
|
107
|
+
if (taskKind === 'api' || taskKind === 'core' || taskKind === 'package')
|
|
108
|
+
commands.add('pnpm typecheck');
|
|
109
|
+
commands.add('pnpm format:check');
|
|
110
|
+
|
|
111
|
+
return [...commands];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function runCommand(command, args, options = {}) {
|
|
115
|
+
const cwd = options.cwd ?? process.cwd();
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
117
|
+
|
|
118
|
+
return new Promise((resolvePromise) => {
|
|
119
|
+
const child = spawn(command, args, {
|
|
120
|
+
cwd,
|
|
121
|
+
env: { ...process.env, ...options.env },
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
});
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let stderr = '';
|
|
126
|
+
let settled = false;
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
child.kill('SIGTERM');
|
|
131
|
+
resolvePromise({
|
|
132
|
+
code: null,
|
|
133
|
+
signal: 'SIGTERM',
|
|
134
|
+
stdout,
|
|
135
|
+
stderr: `${stderr}\nCommand timed out after ${timeoutMs}ms`,
|
|
136
|
+
});
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
|
|
139
|
+
child.stdout.on('data', (chunk) => {
|
|
140
|
+
stdout += chunk;
|
|
141
|
+
});
|
|
142
|
+
child.stderr.on('data', (chunk) => {
|
|
143
|
+
stderr += chunk;
|
|
144
|
+
});
|
|
145
|
+
child.on('error', (error) => {
|
|
146
|
+
if (settled) return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolvePromise({ code: null, signal: null, stdout, stderr: `${stderr}\n${error.message}` });
|
|
150
|
+
});
|
|
151
|
+
child.on('close', (code, signal) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolvePromise({ code, signal, stdout, stderr });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function commandResult(result) {
|
|
161
|
+
return text(JSON.stringify(result, null, 2));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function scaffoldReactPort(repoRoot, input) {
|
|
165
|
+
const args = ['scripts/scaffold-react-port.mjs', input.reactTestFile];
|
|
166
|
+
if (input.outFile) args.push('--out', input.outFile);
|
|
167
|
+
const result = await runCommand(process.execPath, args, {
|
|
168
|
+
cwd: repoRoot,
|
|
169
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
170
|
+
});
|
|
171
|
+
return { command: [process.execPath, ...args], ...result };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function runBenchmark(repoRoot, input) {
|
|
175
|
+
const args =
|
|
176
|
+
input.benchmark === 'all'
|
|
177
|
+
? ['bench']
|
|
178
|
+
: ['--filter', BENCHMARKS[input.benchmark], 'run', 'bench'];
|
|
179
|
+
const result = await runCommand('pnpm', args, {
|
|
180
|
+
cwd: repoRoot,
|
|
181
|
+
timeoutMs: input.timeoutMs ?? 300_000,
|
|
182
|
+
});
|
|
183
|
+
return { command: ['pnpm', ...args], benchmark: input.benchmark, ...result };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function issueContext(repoRoot, input) {
|
|
187
|
+
const fields =
|
|
188
|
+
'number,title,body,author,labels,state,comments,assignees,milestone,url,createdAt,updatedAt';
|
|
189
|
+
const result = await runCommand('gh', ['issue', 'view', String(input.issue), '--json', fields], {
|
|
190
|
+
cwd: repoRoot,
|
|
191
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
192
|
+
});
|
|
193
|
+
if (result.code !== 0)
|
|
194
|
+
return { command: ['gh', 'issue', 'view', String(input.issue), '--json', fields], ...result };
|
|
195
|
+
|
|
196
|
+
let issue;
|
|
197
|
+
try {
|
|
198
|
+
issue = JSON.parse(result.stdout);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return {
|
|
201
|
+
command: ['gh', 'issue', 'view', String(input.issue), '--json', fields],
|
|
202
|
+
parseError: error.message,
|
|
203
|
+
...result,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body = `${issue.title}\n${issue.body ?? ''}`.toLowerCase();
|
|
208
|
+
const labels = issue.labels?.map((label) => label.name) ?? [];
|
|
209
|
+
const suggestedArea =
|
|
210
|
+
body.includes('compiler') || body.includes('tsrx')
|
|
211
|
+
? 'compiler'
|
|
212
|
+
: body.includes('hydration')
|
|
213
|
+
? 'hydration'
|
|
214
|
+
: body.includes('ssr')
|
|
215
|
+
? 'ssr'
|
|
216
|
+
: body.includes('performance') || body.includes('perf')
|
|
217
|
+
? 'performance'
|
|
218
|
+
: 'triage-needed';
|
|
219
|
+
return {
|
|
220
|
+
command: ['gh', 'issue', 'view', String(issue.number ?? input.issue), '--json', fields],
|
|
221
|
+
issue,
|
|
222
|
+
triage: {
|
|
223
|
+
suggestedArea,
|
|
224
|
+
labels,
|
|
225
|
+
state: issue.state,
|
|
226
|
+
url: issue.url,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function registerUserTools(server, repoRoot, repoMode) {
|
|
232
|
+
const skills = repoMode ? { ...BUNDLED_SKILLS, ...REPO_SKILLS } : BUNDLED_SKILLS;
|
|
233
|
+
|
|
234
|
+
server.registerTool(
|
|
235
|
+
'octane_skill',
|
|
236
|
+
{
|
|
237
|
+
title: 'Octane skill',
|
|
238
|
+
description:
|
|
239
|
+
'Return an Octane agent skill by name. Bundled skills cover working WITH octane in any project: bridging React packages, migrating React components to .tsrx, intentional React divergences, and SSR setup.' +
|
|
240
|
+
(repoMode ? ' Repo skills cover octane maintainer workflows.' : ''),
|
|
241
|
+
inputSchema: {
|
|
242
|
+
name: z.enum(Object.keys(skills)),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
async ({ name }) => {
|
|
246
|
+
const root = name in BUNDLED_SKILLS ? PACKAGE_ROOT : repoRoot;
|
|
247
|
+
const body = await readFile(resolve(root, skills[name]), 'utf8');
|
|
248
|
+
return text(body);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
server.registerTool(
|
|
253
|
+
'octane_bridge_react_package',
|
|
254
|
+
{
|
|
255
|
+
title: 'Bridge a React package to Octane',
|
|
256
|
+
description:
|
|
257
|
+
'Scan a React package (from node_modules by name, or any source directory by path) for React API usage and return an Octane compatibility report: which APIs map 1:1, which need rewrites (forwardRef, useDebugValue, lazy, class components), whether a framework-agnostic core can be reused verbatim, whether an official @octanejs binding already exists, and a step-by-step bridge plan. Follow up with the bridge-react-package skill for the full workflow.',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
package: z
|
|
260
|
+
.string()
|
|
261
|
+
.optional()
|
|
262
|
+
.describe('npm package name to scan, resolved from projectRoot/node_modules.'),
|
|
263
|
+
path: z
|
|
264
|
+
.string()
|
|
265
|
+
.optional()
|
|
266
|
+
.describe('Directory of source files to scan instead of an installed package.'),
|
|
267
|
+
projectRoot: z
|
|
268
|
+
.string()
|
|
269
|
+
.optional()
|
|
270
|
+
.describe('Project to resolve node_modules from. Defaults to the server cwd.'),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
async (input) => {
|
|
274
|
+
if (!input.package && !input.path) {
|
|
275
|
+
return text(JSON.stringify({ error: "Provide either 'package' or 'path'." }, null, 2));
|
|
276
|
+
}
|
|
277
|
+
const report = await bridgeReport({
|
|
278
|
+
packageName: input.package,
|
|
279
|
+
path: input.path,
|
|
280
|
+
projectRoot: input.projectRoot,
|
|
281
|
+
});
|
|
282
|
+
return text(JSON.stringify(report, null, 2));
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
server.registerTool(
|
|
287
|
+
'octane_bindings',
|
|
288
|
+
{
|
|
289
|
+
title: 'List official Octane bindings',
|
|
290
|
+
description:
|
|
291
|
+
'Return the map of React packages that already have maintained @octanejs/* Octane ports. Check here before bridging by hand.',
|
|
292
|
+
inputSchema: {},
|
|
293
|
+
},
|
|
294
|
+
async () => text(JSON.stringify(KNOWN_BINDINGS, null, 2)),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function registerRepoTools(server, repoRoot) {
|
|
299
|
+
server.registerTool(
|
|
300
|
+
'octane_project_map',
|
|
301
|
+
{
|
|
302
|
+
title: 'Octane project map',
|
|
303
|
+
description:
|
|
304
|
+
'Return Octane repository map, source ownership, validation commands, and skill paths.',
|
|
305
|
+
inputSchema: {},
|
|
306
|
+
},
|
|
307
|
+
async () => {
|
|
308
|
+
const projectMap = await readFile(resolve(repoRoot, '.ai/project-map.md'), 'utf8');
|
|
309
|
+
return text(projectMap);
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
server.registerTool(
|
|
314
|
+
'octane_triage_paths',
|
|
315
|
+
{
|
|
316
|
+
title: 'Triage Octane paths',
|
|
317
|
+
description: 'Classify changed paths by Octane repo area.',
|
|
318
|
+
inputSchema: {
|
|
319
|
+
paths: z.array(z.string()).describe('Repository-relative paths'),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
async ({ paths }) => {
|
|
323
|
+
const rows = paths.map((path) => ({ path, area: areaForPath(path) }));
|
|
324
|
+
return text(JSON.stringify({ repoRoot, paths: rows }, null, 2));
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
server.registerTool(
|
|
329
|
+
'octane_validate_plan',
|
|
330
|
+
{
|
|
331
|
+
title: 'Octane validation plan',
|
|
332
|
+
description: 'Recommend validation commands for changed paths and task kind.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
paths: z.array(z.string()).default([]).describe('Repository-relative changed paths'),
|
|
335
|
+
taskKind: z
|
|
336
|
+
.enum([
|
|
337
|
+
'bug',
|
|
338
|
+
'feature',
|
|
339
|
+
'docs',
|
|
340
|
+
'test',
|
|
341
|
+
'performance',
|
|
342
|
+
'core',
|
|
343
|
+
'compiler',
|
|
344
|
+
'package',
|
|
345
|
+
'api',
|
|
346
|
+
'unknown',
|
|
347
|
+
])
|
|
348
|
+
.default('unknown'),
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
async ({ paths, taskKind }) => {
|
|
352
|
+
return text(
|
|
353
|
+
JSON.stringify({ repoRoot, taskKind, commands: validationFor(paths, taskKind) }, null, 2),
|
|
354
|
+
);
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
server.registerTool(
|
|
359
|
+
'octane_scaffold_react_port',
|
|
360
|
+
{
|
|
361
|
+
title: 'Scaffold React test port',
|
|
362
|
+
description: 'Run scripts/scaffold-react-port.mjs for a React upstream test file.',
|
|
363
|
+
inputSchema: {
|
|
364
|
+
reactTestFile: z
|
|
365
|
+
.string()
|
|
366
|
+
.describe('Path to the upstream React test file, relative to repo root or absolute.'),
|
|
367
|
+
outFile: z
|
|
368
|
+
.string()
|
|
369
|
+
.optional()
|
|
370
|
+
.describe('Optional output file for the generated Vitest skeleton.'),
|
|
371
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
async (input) => commandResult(await scaffoldReactPort(repoRoot, input)),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
server.registerTool(
|
|
378
|
+
'octane_benchmark',
|
|
379
|
+
{
|
|
380
|
+
title: 'Run Octane benchmark',
|
|
381
|
+
description:
|
|
382
|
+
'Run one of the known Octane benchmark workspaces, or all benchmarks via pnpm bench.',
|
|
383
|
+
inputSchema: {
|
|
384
|
+
benchmark: z.enum(['all', ...Object.keys(BENCHMARKS)]).default('all'),
|
|
385
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
async (input) => commandResult(await runBenchmark(repoRoot, input)),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
server.registerTool(
|
|
392
|
+
'octane_issue_context',
|
|
393
|
+
{
|
|
394
|
+
title: 'Fetch Octane issue context',
|
|
395
|
+
description:
|
|
396
|
+
'Fetch a GitHub issue with gh and return structured context plus lightweight triage hints.',
|
|
397
|
+
inputSchema: {
|
|
398
|
+
issue: z
|
|
399
|
+
.union([z.number().int().positive(), z.string()])
|
|
400
|
+
.describe('Issue number or URL accepted by gh issue view.'),
|
|
401
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
async (input) => commandResult(await issueContext(repoRoot, input)),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function createServer(options = {}) {
|
|
409
|
+
const repoRoot = resolve(options.repoRoot || process.env.OCTANE_REPO_ROOT || process.cwd());
|
|
410
|
+
const repoMode = isOctaneRepo(repoRoot);
|
|
411
|
+
const server = new McpServer({ name: 'octane', version: '0.2.0' });
|
|
412
|
+
registerUserTools(server, repoRoot, repoMode);
|
|
413
|
+
if (repoMode) registerRepoTools(server, repoRoot);
|
|
414
|
+
return server;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function main() {
|
|
418
|
+
const server = createServer();
|
|
419
|
+
const transport = new StdioServerTransport();
|
|
420
|
+
await server.connect(transport);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
|
|
424
|
+
main().catch((error) => {
|
|
425
|
+
console.error(error);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
areaForPath,
|
|
9
|
+
BUNDLED_SKILLS,
|
|
10
|
+
isOctaneRepo,
|
|
11
|
+
scaffoldReactPort,
|
|
12
|
+
validationFor,
|
|
13
|
+
} from './index.js';
|
|
14
|
+
|
|
15
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
|
|
17
|
+
describe('@octanejs/mcp-server helpers', () => {
|
|
18
|
+
it('classifies Octane repository paths', () => {
|
|
19
|
+
expect(areaForPath('packages/octane/src/compiler/compile.js')).toBe('compiler');
|
|
20
|
+
expect(areaForPath('packages/octane/src/runtime.ts')).toBe('core-runtime');
|
|
21
|
+
expect(areaForPath('packages/octane/src/runtime.server.ts')).toBe('ssr');
|
|
22
|
+
expect(areaForPath('packages/zustand/src/index.ts')).toBe('ecosystem-binding');
|
|
23
|
+
expect(areaForPath('packages/radix/src/index.ts')).toBe('ecosystem-binding');
|
|
24
|
+
expect(areaForPath('packages/octane-mcp-server/src/index.js')).toBe('mcp-server');
|
|
25
|
+
expect(areaForPath('benchmarks/news/run.mjs')).toBe('benchmark');
|
|
26
|
+
expect(areaForPath('.rulesync/rules/project.md')).toBe('rulesync-source');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('recommends validation commands from changed paths', () => {
|
|
30
|
+
const commands = validationFor(
|
|
31
|
+
[
|
|
32
|
+
'packages/octane/src/runtime.ts',
|
|
33
|
+
'packages/zustand/src/index.ts',
|
|
34
|
+
'packages/radix/src/index.ts',
|
|
35
|
+
'.rulesync/rules/project.md',
|
|
36
|
+
],
|
|
37
|
+
'core',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(commands).toContain('pnpm rules:generate');
|
|
41
|
+
expect(commands).toContain(
|
|
42
|
+
'./node_modules/.bin/vitest run packages/octane/tests --project octane',
|
|
43
|
+
);
|
|
44
|
+
expect(commands).toContain(
|
|
45
|
+
'./node_modules/.bin/vitest run packages/zustand/tests --project zustand',
|
|
46
|
+
);
|
|
47
|
+
expect(commands).toContain(
|
|
48
|
+
'./node_modules/.bin/vitest run packages/radix/tests --project radix',
|
|
49
|
+
);
|
|
50
|
+
expect(commands).toContain('pnpm typecheck');
|
|
51
|
+
expect(commands).toContain('pnpm format:check');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('detects the octane monorepo for repo-mode tools', async () => {
|
|
55
|
+
expect(isOctaneRepo(resolve(PACKAGE_ROOT, '../..'))).toBe(true);
|
|
56
|
+
const elsewhere = await mkdtemp(join(tmpdir(), 'octane-mcp-test-'));
|
|
57
|
+
expect(isOctaneRepo(elsewhere)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('ships every bundled skill inside the package', async () => {
|
|
61
|
+
for (const file of Object.values(BUNDLED_SKILLS)) {
|
|
62
|
+
expect(existsSync(resolve(PACKAGE_ROOT, file))).toBe(true);
|
|
63
|
+
const body = await readFile(resolve(PACKAGE_ROOT, file), 'utf8');
|
|
64
|
+
expect(body).toMatch(/^# Skill:/);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('runs the React port scaffolder wrapper', async () => {
|
|
69
|
+
const repoRoot = await mkdtemp(join(tmpdir(), 'octane-mcp-test-'));
|
|
70
|
+
await mkdir(join(repoRoot, 'scripts'), { recursive: true });
|
|
71
|
+
await mkdir(join(repoRoot, 'react'), { recursive: true });
|
|
72
|
+
await writeFile(
|
|
73
|
+
join(repoRoot, 'scripts/scaffold-react-port.mjs'),
|
|
74
|
+
"import { writeFileSync } from 'node:fs';\nconst out = process.argv[process.argv.indexOf('--out') + 1];\nwriteFileSync(out, 'generated');\nconsole.log('ok');\n",
|
|
75
|
+
);
|
|
76
|
+
await writeFile(join(repoRoot, 'react/source-test.js'), "it('works', () => {});\n");
|
|
77
|
+
|
|
78
|
+
const result = await scaffoldReactPort(repoRoot, {
|
|
79
|
+
reactTestFile: 'react/source-test.js',
|
|
80
|
+
outFile: 'ported.test.ts',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.code).toBe(0);
|
|
84
|
+
expect(result.stdout).toContain('ok');
|
|
85
|
+
await expect(readFile(join(repoRoot, 'ported.test.ts'), 'utf8')).resolves.toBe('generated');
|
|
86
|
+
});
|
|
87
|
+
});
|