@loicngr/kobo 0.1.1
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/AGENTS.md +227 -0
- package/LICENSE +674 -0
- package/README.md +199 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
- package/dist/mcp-server/kobo-tasks-server.js +116 -0
- package/dist/server/db/index.js +22 -0
- package/dist/server/db/migrations.js +20 -0
- package/dist/server/db/schema.js +49 -0
- package/dist/server/index.js +178 -0
- package/dist/server/routes/dev-server.js +74 -0
- package/dist/server/routes/git.js +20 -0
- package/dist/server/routes/notion.js +24 -0
- package/dist/server/routes/settings.js +92 -0
- package/dist/server/routes/workspaces.js +730 -0
- package/dist/server/services/agent-manager.js +435 -0
- package/dist/server/services/dev-server-service.js +298 -0
- package/dist/server/services/notion-service.js +369 -0
- package/dist/server/services/pr-template-service.js +38 -0
- package/dist/server/services/settings-service.js +205 -0
- package/dist/server/services/websocket-service.js +212 -0
- package/dist/server/services/workspace-service.js +208 -0
- package/dist/server/services/worktree-service.js +117 -0
- package/dist/server/utils/git-ops.js +117 -0
- package/dist/server/utils/paths.js +95 -0
- package/dist/server/utils/process-tracker.js +46 -0
- package/package.json +84 -0
- package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-6hzaLlYO.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
- package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
- package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
- package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
- package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
- package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
- package/src/client/dist/spa/index.html +4 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
- package/src/mcp-server/kobo-tasks-server.ts +128 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getProjectSettings } from './settings-service.js';
|
|
5
|
+
import { emitEphemeral } from './websocket-service.js';
|
|
6
|
+
import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
|
|
7
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
8
|
+
/** workspaceId -> spawned dev-server process */
|
|
9
|
+
const trackedProcesses = new Map();
|
|
10
|
+
// ── Pure helpers ───────────────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize a branch name for use as a Docker instance name.
|
|
13
|
+
* Replace `/` and `_` with `-`, lowercase.
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeBranchName(branch) {
|
|
16
|
+
return branch.toLowerCase().replace(/[/_]/g, '-');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse a `.env` file content into key=value pairs.
|
|
20
|
+
* Skips empty lines and comments (#). Handles quotes.
|
|
21
|
+
*/
|
|
22
|
+
export function parseEnvFile(content) {
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const line of content.split('\n')) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
27
|
+
continue;
|
|
28
|
+
const eqIndex = trimmed.indexOf('=');
|
|
29
|
+
if (eqIndex === -1)
|
|
30
|
+
continue;
|
|
31
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
32
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
33
|
+
// Strip surrounding quotes
|
|
34
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
result[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the dev-server instance config for a given project + branch.
|
|
43
|
+
* Looks in `<projectPath>/.container/instances/` for `.env` files.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveInstance(projectPath, workingBranch) {
|
|
46
|
+
const instancesDir = path.join(projectPath, '.container', 'instances');
|
|
47
|
+
if (!existsSync(instancesDir))
|
|
48
|
+
return null;
|
|
49
|
+
const sanitized = sanitizeBranchName(workingBranch);
|
|
50
|
+
const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const content = readFileSync(path.join(instancesDir, file), 'utf-8');
|
|
53
|
+
const parsed = parseEnvFile(content);
|
|
54
|
+
if (parsed.INSTANCE_NAME && parsed.INSTANCE_NAME.toLowerCase() === sanitized) {
|
|
55
|
+
return {
|
|
56
|
+
instanceName: parsed.INSTANCE_NAME,
|
|
57
|
+
projectName: parsed.PROJECT_NAME ?? '',
|
|
58
|
+
httpPort: parsed.HTTP_PORT ?? '',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* List all running Docker container names.
|
|
67
|
+
* Note: uses execSync with shell because docker ps --format requires
|
|
68
|
+
* Go template syntax with `{{}}`. Input is a static string, no injection risk.
|
|
69
|
+
*/
|
|
70
|
+
export function listRunningContainers() {
|
|
71
|
+
try {
|
|
72
|
+
const output = execSync('docker ps --format "{{.Names}}"', {
|
|
73
|
+
encoding: 'utf-8',
|
|
74
|
+
timeout: 10000,
|
|
75
|
+
});
|
|
76
|
+
return output
|
|
77
|
+
.split('\n')
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ── Status ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* Get the dev-server status for a given project + branch.
|
|
88
|
+
*/
|
|
89
|
+
export function getStatus(projectPath, workingBranch) {
|
|
90
|
+
const config = resolveInstance(projectPath, workingBranch);
|
|
91
|
+
if (!config) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'unknown',
|
|
94
|
+
instanceName: '',
|
|
95
|
+
projectName: '',
|
|
96
|
+
httpPort: '',
|
|
97
|
+
url: '',
|
|
98
|
+
containers: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const running = listRunningContainers();
|
|
102
|
+
const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
|
|
103
|
+
if (matching.length > 0) {
|
|
104
|
+
return {
|
|
105
|
+
status: 'running',
|
|
106
|
+
instanceName: config.instanceName,
|
|
107
|
+
projectName: config.projectName,
|
|
108
|
+
httpPort: config.httpPort,
|
|
109
|
+
url: `http://localhost:${config.httpPort}`,
|
|
110
|
+
containers: matching,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: 'stopped',
|
|
115
|
+
instanceName: config.instanceName,
|
|
116
|
+
projectName: config.projectName,
|
|
117
|
+
httpPort: config.httpPort,
|
|
118
|
+
url: '',
|
|
119
|
+
containers: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Start the dev-server for a workspace.
|
|
125
|
+
*/
|
|
126
|
+
export function startDevServer(workspaceId) {
|
|
127
|
+
const workspace = getWorkspace(workspaceId);
|
|
128
|
+
if (!workspace) {
|
|
129
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
130
|
+
}
|
|
131
|
+
const settings = getProjectSettings(workspace.projectPath);
|
|
132
|
+
if (!settings?.devServer.startCommand) {
|
|
133
|
+
throw new Error('No dev-server start command configured');
|
|
134
|
+
}
|
|
135
|
+
const instanceName = sanitizeBranchName(workspace.workingBranch);
|
|
136
|
+
// Execute as bash script (supports multi-line scripts)
|
|
137
|
+
const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
|
|
138
|
+
cwd: workspace.projectPath,
|
|
139
|
+
env: {
|
|
140
|
+
...process.env,
|
|
141
|
+
INSTANCE: instanceName,
|
|
142
|
+
DEV_DOCKER_NO_FOLLOW: '1',
|
|
143
|
+
},
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
detached: true,
|
|
146
|
+
});
|
|
147
|
+
trackedProcesses.set(workspaceId, proc);
|
|
148
|
+
// Log stdout/stderr for debugging
|
|
149
|
+
proc.stdout?.on('data', (data) => {
|
|
150
|
+
console.log(`[dev-server:${instanceName}] ${data.toString().trim()}`);
|
|
151
|
+
});
|
|
152
|
+
proc.stderr?.on('data', (data) => {
|
|
153
|
+
console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
|
|
154
|
+
});
|
|
155
|
+
proc.on('exit', (code) => {
|
|
156
|
+
trackedProcesses.delete(workspaceId);
|
|
157
|
+
const currentStatus = getStatus(workspace.projectPath, workspace.workingBranch);
|
|
158
|
+
updateDevServerStatus(workspaceId, currentStatus.status);
|
|
159
|
+
emitEphemeral(workspaceId, 'devserver:status', currentStatus);
|
|
160
|
+
if (code !== 0) {
|
|
161
|
+
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
proc.on('error', (err) => {
|
|
165
|
+
trackedProcesses.delete(workspaceId);
|
|
166
|
+
updateDevServerStatus(workspaceId, 'error');
|
|
167
|
+
console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
|
|
168
|
+
emitEphemeral(workspaceId, 'devserver:status', {
|
|
169
|
+
status: 'error',
|
|
170
|
+
instanceName,
|
|
171
|
+
projectName: '',
|
|
172
|
+
httpPort: '',
|
|
173
|
+
url: '',
|
|
174
|
+
containers: [],
|
|
175
|
+
error: err.message,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
const status = {
|
|
179
|
+
status: 'starting',
|
|
180
|
+
instanceName,
|
|
181
|
+
projectName: '',
|
|
182
|
+
httpPort: '',
|
|
183
|
+
url: '',
|
|
184
|
+
containers: [],
|
|
185
|
+
};
|
|
186
|
+
updateDevServerStatus(workspaceId, 'starting');
|
|
187
|
+
emitEphemeral(workspaceId, 'devserver:status', status);
|
|
188
|
+
return status;
|
|
189
|
+
}
|
|
190
|
+
// ── Stop ───────────────────────────────────────────────────────────────────────
|
|
191
|
+
/**
|
|
192
|
+
* Stop the dev-server for a workspace.
|
|
193
|
+
*/
|
|
194
|
+
export function stopDevServer(workspaceId) {
|
|
195
|
+
const workspace = getWorkspace(workspaceId);
|
|
196
|
+
if (!workspace) {
|
|
197
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
198
|
+
}
|
|
199
|
+
const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
|
|
200
|
+
const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
|
|
201
|
+
// Kill tracked process first (covers Node servers and any spawned process)
|
|
202
|
+
const tracked = trackedProcesses.get(workspaceId);
|
|
203
|
+
if (tracked) {
|
|
204
|
+
try {
|
|
205
|
+
if (tracked.pid) {
|
|
206
|
+
process.kill(-tracked.pid, 'SIGTERM');
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
tracked.kill('SIGTERM');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.error('[dev-server] Failed to kill tracked process:', err instanceof Error ? err.message : err);
|
|
214
|
+
}
|
|
215
|
+
trackedProcesses.delete(workspaceId);
|
|
216
|
+
}
|
|
217
|
+
const settings = getProjectSettings(workspace.projectPath);
|
|
218
|
+
if (settings?.devServer.stopCommand) {
|
|
219
|
+
// Custom stop script — run synchronously with instance context in env
|
|
220
|
+
try {
|
|
221
|
+
execSync(settings.devServer.stopCommand, {
|
|
222
|
+
cwd: workspace.projectPath,
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
INSTANCE: instanceName,
|
|
226
|
+
PROJECT_NAME: config?.projectName ?? '',
|
|
227
|
+
},
|
|
228
|
+
encoding: 'utf-8',
|
|
229
|
+
timeout: 30000,
|
|
230
|
+
shell: 'bash',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error(`[dev-server] Stop command failed:`, err instanceof Error ? err.message : err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Always try docker compose down with project name if we have one
|
|
238
|
+
// (handles cases where custom stop command doesn't use -p flag)
|
|
239
|
+
if (config?.projectName) {
|
|
240
|
+
try {
|
|
241
|
+
execSync(`docker compose -p "${config.projectName}" down`, {
|
|
242
|
+
cwd: workspace.projectPath,
|
|
243
|
+
encoding: 'utf-8',
|
|
244
|
+
timeout: 30000,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// May already be stopped by the custom command — ignore
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const status = {
|
|
252
|
+
status: 'stopped',
|
|
253
|
+
instanceName,
|
|
254
|
+
projectName: config?.projectName ?? '',
|
|
255
|
+
httpPort: config?.httpPort ?? '',
|
|
256
|
+
url: '',
|
|
257
|
+
containers: [],
|
|
258
|
+
};
|
|
259
|
+
updateDevServerStatus(workspaceId, 'stopped');
|
|
260
|
+
emitEphemeral(workspaceId, 'devserver:status', status);
|
|
261
|
+
return status;
|
|
262
|
+
}
|
|
263
|
+
// ── Logs ───────────────────────────────────────────────────────────────────────
|
|
264
|
+
/**
|
|
265
|
+
* Get logs from running dev-server containers for a workspace.
|
|
266
|
+
* Note: uses execSync for `docker logs` — container names come from
|
|
267
|
+
* `docker ps` output (not user input), so no injection risk.
|
|
268
|
+
*/
|
|
269
|
+
export function getDevServerLogs(workspaceId, tail = 200) {
|
|
270
|
+
const workspace = getWorkspace(workspaceId);
|
|
271
|
+
if (!workspace) {
|
|
272
|
+
return 'Workspace not found';
|
|
273
|
+
}
|
|
274
|
+
const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
|
|
275
|
+
if (!config) {
|
|
276
|
+
return 'No dev-server instance found';
|
|
277
|
+
}
|
|
278
|
+
const running = listRunningContainers();
|
|
279
|
+
const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
|
|
280
|
+
if (matching.length === 0) {
|
|
281
|
+
return 'No running containers found';
|
|
282
|
+
}
|
|
283
|
+
const outputs = [];
|
|
284
|
+
for (const container of matching) {
|
|
285
|
+
try {
|
|
286
|
+
const logs = execSync(`docker logs --tail ${tail} ${container}`, {
|
|
287
|
+
encoding: 'utf-8',
|
|
288
|
+
timeout: 10000,
|
|
289
|
+
});
|
|
290
|
+
outputs.push(`=== ${container} ===\n${logs}`);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
294
|
+
outputs.push(`=== ${container} ===\n[Error fetching logs: ${message}]`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return outputs.join('\n\n');
|
|
298
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
// Gherkin keywords (French and English)
|
|
4
|
+
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
5
|
+
// C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
|
|
6
|
+
const nextRpcId = (() => {
|
|
7
|
+
let counter = 1;
|
|
8
|
+
return () => counter++;
|
|
9
|
+
})();
|
|
10
|
+
/**
|
|
11
|
+
* Parse a Notion URL and extract the page_id in UUID format (with dashes).
|
|
12
|
+
* Handles:
|
|
13
|
+
* https://www.notion.so/workspace/Title-<32hexChars>
|
|
14
|
+
* https://www.notion.so/workspace/<32hexChars>
|
|
15
|
+
* https://www.notion.so/<32hexChars>
|
|
16
|
+
*/
|
|
17
|
+
export function parseNotionUrl(url) {
|
|
18
|
+
// Strip query string and fragment
|
|
19
|
+
const cleanUrl = url.split('?')[0].split('#')[0];
|
|
20
|
+
// The page ID is always the last 32 hex characters (no dashes) at the end of the path
|
|
21
|
+
const match = cleanUrl.match(/([0-9a-f]{32})$/i);
|
|
22
|
+
if (!match) {
|
|
23
|
+
// Try to find a UUID with dashes
|
|
24
|
+
const uuidMatch = cleanUrl.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
25
|
+
if (uuidMatch) {
|
|
26
|
+
return uuidMatch[1];
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Could not extract page ID from Notion URL: ${url}`);
|
|
29
|
+
}
|
|
30
|
+
const raw = match[1];
|
|
31
|
+
// Convert 32 hex chars to UUID format: 8-4-4-4-12
|
|
32
|
+
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Send a JSON-RPC request to the MCP process and read the response.
|
|
36
|
+
* M4: parameter renamed from `process` to `mcpProcess` to avoid shadowing global `process`.
|
|
37
|
+
* C1: 30s timeout added to prevent hanging indefinitely.
|
|
38
|
+
*/
|
|
39
|
+
export async function callMcpTool(mcpProcess, toolName, args) {
|
|
40
|
+
const id = nextRpcId();
|
|
41
|
+
const request = JSON.stringify({
|
|
42
|
+
jsonrpc: '2.0',
|
|
43
|
+
id,
|
|
44
|
+
method: 'tools/call',
|
|
45
|
+
params: {
|
|
46
|
+
name: toolName,
|
|
47
|
+
arguments: args,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
if (!mcpProcess.stdin || !mcpProcess.stdout) {
|
|
52
|
+
reject(new Error('MCP process stdin/stdout not available'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let buffer = '';
|
|
56
|
+
// C1: 30s timeout
|
|
57
|
+
const timeout = setTimeout(() => {
|
|
58
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
59
|
+
mcpProcess.stdout?.removeListener('error', onError);
|
|
60
|
+
reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
|
|
61
|
+
}, 30_000);
|
|
62
|
+
const onData = (chunk) => {
|
|
63
|
+
buffer += chunk.toString();
|
|
64
|
+
// Try to parse complete JSON lines
|
|
65
|
+
const lines = buffer.split('\n');
|
|
66
|
+
// Keep the last (potentially incomplete) line in the buffer
|
|
67
|
+
buffer = lines.pop() ?? '';
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(trimmed);
|
|
74
|
+
if (parsed.id === id) {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
77
|
+
mcpProcess.stdout?.removeListener('error', onError);
|
|
78
|
+
if (parsed.error) {
|
|
79
|
+
reject(new Error(`MCP tool '${toolName}' error: ${parsed.error.message} (code: ${parsed.error.code})`));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
resolve(parsed.result);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Ignore JSON parse errors for partial lines
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const onError = (err) => {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
94
|
+
reject(err);
|
|
95
|
+
};
|
|
96
|
+
mcpProcess.stdout.on('data', onData);
|
|
97
|
+
mcpProcess.stdout.once('error', onError);
|
|
98
|
+
mcpProcess.stdin.write(`${request}\n`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read the Notion token from Claude Code's config file as a fallback.
|
|
103
|
+
*/
|
|
104
|
+
function readNotionTokenFromClaudeConfig() {
|
|
105
|
+
try {
|
|
106
|
+
const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
107
|
+
const configPath = `${homedir}/.claude.json`;
|
|
108
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
109
|
+
const config = JSON.parse(raw);
|
|
110
|
+
const mcpServers = config.mcpServers;
|
|
111
|
+
const notionServer = mcpServers?.notion;
|
|
112
|
+
return notionServer?.env?.NOTION_TOKEN ?? notionServer?.env?.NOTION_API_TOKEN ?? '';
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function spawnMcpProcess() {
|
|
119
|
+
const notionToken = process.env.NOTION_API_TOKEN ?? process.env.NOTION_TOKEN ?? readNotionTokenFromClaudeConfig();
|
|
120
|
+
const mcpCommand = process.env.NOTION_MCP_COMMAND ?? 'npx';
|
|
121
|
+
const mcpArgs = process.env.NOTION_MCP_ARGS
|
|
122
|
+
? process.env.NOTION_MCP_ARGS.split(' ')
|
|
123
|
+
: ['-y', '@notionhq/notion-mcp-server'];
|
|
124
|
+
const mcpProcess = spawn(mcpCommand, mcpArgs, {
|
|
125
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
126
|
+
env: {
|
|
127
|
+
...process.env,
|
|
128
|
+
OPENAPI_MCP_HEADERS: JSON.stringify({
|
|
129
|
+
Authorization: `Bearer ${notionToken}`,
|
|
130
|
+
'Notion-Version': '2022-06-28',
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
mcpProcess.stderr?.on('data', (data) => {
|
|
135
|
+
// Silently consume stderr to avoid cluttering logs
|
|
136
|
+
const text = data.toString();
|
|
137
|
+
if (process.env.DEBUG_NOTION_MCP) {
|
|
138
|
+
console.error('[notion-mcp stderr]', text);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return mcpProcess;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Initialize the MCP server by sending an initialize request.
|
|
145
|
+
* I1: notifications/initialized is sent after receiving the initialize response.
|
|
146
|
+
* I4: onData listener is removed in the reject path.
|
|
147
|
+
* C1: 10s timeout added.
|
|
148
|
+
*/
|
|
149
|
+
async function initializeMcp(mcpProcess) {
|
|
150
|
+
const id = nextRpcId();
|
|
151
|
+
const request = JSON.stringify({
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
id,
|
|
154
|
+
method: 'initialize',
|
|
155
|
+
params: {
|
|
156
|
+
protocolVersion: '2024-11-05',
|
|
157
|
+
capabilities: {},
|
|
158
|
+
clientInfo: { name: 'kobo', version: '0.1.0' },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
if (!mcpProcess.stdin || !mcpProcess.stdout) {
|
|
163
|
+
reject(new Error('MCP process not ready'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let buffer = '';
|
|
167
|
+
// C1: 10s timeout for initialization
|
|
168
|
+
const timeout = setTimeout(() => {
|
|
169
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
170
|
+
reject(new Error('initializeMcp timed out after 10s'));
|
|
171
|
+
}, 10_000);
|
|
172
|
+
const onData = (chunk) => {
|
|
173
|
+
buffer += chunk.toString();
|
|
174
|
+
const lines = buffer.split('\n');
|
|
175
|
+
buffer = lines.pop() ?? '';
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(trimmed);
|
|
182
|
+
if (parsed.id === id) {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
185
|
+
// I1: Send notifications/initialized AFTER receiving the initialize response
|
|
186
|
+
const initialized = JSON.stringify({
|
|
187
|
+
jsonrpc: '2.0',
|
|
188
|
+
method: 'notifications/initialized',
|
|
189
|
+
});
|
|
190
|
+
mcpProcess.stdin?.write(`${initialized}\n`);
|
|
191
|
+
resolve();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
// I4: onError handler to clean up listener on error
|
|
200
|
+
const onError = (err) => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
mcpProcess.stdout?.removeListener('data', onData);
|
|
203
|
+
reject(err);
|
|
204
|
+
};
|
|
205
|
+
mcpProcess.stdout.on('data', onData);
|
|
206
|
+
mcpProcess.stdout.once('error', onError);
|
|
207
|
+
mcpProcess.stdin.write(`${request}\n`);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Unwrap MCP tool response.
|
|
212
|
+
* MCP returns { content: [{ type: "text", text: "..." }] }
|
|
213
|
+
* where text is a JSON-stringified API response.
|
|
214
|
+
*/
|
|
215
|
+
function unwrapMcpResult(result) {
|
|
216
|
+
if (result && typeof result === 'object') {
|
|
217
|
+
const obj = result;
|
|
218
|
+
if (Array.isArray(obj.content)) {
|
|
219
|
+
const first = obj.content[0];
|
|
220
|
+
if (first?.type === 'text' && first.text) {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(first.text);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return first.text;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
function extractTextFromRichText(richText) {
|
|
233
|
+
if (!Array.isArray(richText))
|
|
234
|
+
return '';
|
|
235
|
+
return richText
|
|
236
|
+
.map((rt) => {
|
|
237
|
+
if (rt && typeof rt === 'object' && 'plain_text' in rt) {
|
|
238
|
+
return rt.plain_text;
|
|
239
|
+
}
|
|
240
|
+
return '';
|
|
241
|
+
})
|
|
242
|
+
.join('');
|
|
243
|
+
}
|
|
244
|
+
export function parseBlocks(blocks) {
|
|
245
|
+
const todos = [];
|
|
246
|
+
const gherkinFeatures = [];
|
|
247
|
+
let goal = '';
|
|
248
|
+
let insideObjectif = false;
|
|
249
|
+
let currentGherkinBlock = [];
|
|
250
|
+
for (const block of blocks) {
|
|
251
|
+
const blockType = block.type;
|
|
252
|
+
if (blockType === 'heading_1' || blockType === 'heading_2' || blockType === 'heading_3') {
|
|
253
|
+
// Flush current gherkin block
|
|
254
|
+
if (currentGherkinBlock.length > 0) {
|
|
255
|
+
gherkinFeatures.push(currentGherkinBlock.join('\n'));
|
|
256
|
+
currentGherkinBlock = [];
|
|
257
|
+
}
|
|
258
|
+
const headingData = block[blockType];
|
|
259
|
+
const headingText = extractTextFromRichText(headingData?.rich_text ?? [])
|
|
260
|
+
.toLowerCase()
|
|
261
|
+
.trim();
|
|
262
|
+
insideObjectif = headingText === 'objectif' || headingText === 'goal';
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (blockType === 'to_do') {
|
|
266
|
+
insideObjectif = false;
|
|
267
|
+
const todoData = block.to_do;
|
|
268
|
+
const title = extractTextFromRichText(todoData?.rich_text ?? []);
|
|
269
|
+
const checked = todoData?.checked ?? false;
|
|
270
|
+
todos.push({ title, checked });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (blockType === 'paragraph' || blockType === 'bulleted_list_item' || blockType === 'numbered_list_item') {
|
|
274
|
+
const data = block[blockType];
|
|
275
|
+
const text = extractTextFromRichText(data?.rich_text ?? []);
|
|
276
|
+
if (insideObjectif && blockType === 'paragraph') {
|
|
277
|
+
goal = goal ? `${goal}\n${text}` : text;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Check if this is a Gherkin line
|
|
281
|
+
if (GHERKIN_PATTERN.test(text.trim())) {
|
|
282
|
+
currentGherkinBlock.push(text);
|
|
283
|
+
}
|
|
284
|
+
else if (currentGherkinBlock.length > 0) {
|
|
285
|
+
// Part of an ongoing gherkin block (continuation lines)
|
|
286
|
+
currentGherkinBlock.push(text);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (blockType === 'code') {
|
|
291
|
+
const codeData = block.code;
|
|
292
|
+
const codeText = extractTextFromRichText(codeData?.rich_text ?? []);
|
|
293
|
+
// Check if the code block contains Gherkin
|
|
294
|
+
if (GHERKIN_PATTERN.test(codeText.trim())) {
|
|
295
|
+
if (currentGherkinBlock.length > 0) {
|
|
296
|
+
gherkinFeatures.push(currentGherkinBlock.join('\n'));
|
|
297
|
+
currentGherkinBlock = [];
|
|
298
|
+
}
|
|
299
|
+
gherkinFeatures.push(codeText);
|
|
300
|
+
}
|
|
301
|
+
insideObjectif = false;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Any other block type resets objectif context
|
|
305
|
+
if (blockType !== 'paragraph') {
|
|
306
|
+
insideObjectif = false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Flush remaining gherkin block
|
|
310
|
+
if (currentGherkinBlock.length > 0) {
|
|
311
|
+
gherkinFeatures.push(currentGherkinBlock.join('\n'));
|
|
312
|
+
}
|
|
313
|
+
return { goal, todos, gherkinFeatures };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Extract content from a Notion page via MCP.
|
|
317
|
+
*/
|
|
318
|
+
export async function extractNotionPage(notionUrl) {
|
|
319
|
+
const pageId = parseNotionUrl(notionUrl);
|
|
320
|
+
const mcpProcess = spawnMcpProcess();
|
|
321
|
+
// Give the process a moment to start
|
|
322
|
+
await new Promise((resolve, reject) => {
|
|
323
|
+
const timeout = setTimeout(() => resolve(), 1000);
|
|
324
|
+
mcpProcess.on('error', (err) => {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
reject(new Error(`Failed to start MCP Notion server: ${err.message}`));
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
// Initialize the MCP server
|
|
331
|
+
await initializeMcp(mcpProcess);
|
|
332
|
+
// Retrieve the page metadata (title)
|
|
333
|
+
const pageRaw = await callMcpTool(mcpProcess, 'API-retrieve-a-page', { page_id: pageId });
|
|
334
|
+
const pageResult = unwrapMcpResult(pageRaw);
|
|
335
|
+
let title = '';
|
|
336
|
+
if (pageResult && typeof pageResult === 'object') {
|
|
337
|
+
const result = pageResult;
|
|
338
|
+
const properties = result.properties;
|
|
339
|
+
if (properties) {
|
|
340
|
+
for (const prop of Object.values(properties)) {
|
|
341
|
+
const propObj = prop;
|
|
342
|
+
if (propObj.type === 'title' && Array.isArray(propObj.title)) {
|
|
343
|
+
title = extractTextFromRichText(propObj.title);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Retrieve the page blocks (content)
|
|
350
|
+
const blocksRaw = await callMcpTool(mcpProcess, 'API-get-block-children', {
|
|
351
|
+
block_id: pageId,
|
|
352
|
+
});
|
|
353
|
+
const blocksResult = unwrapMcpResult(blocksRaw);
|
|
354
|
+
let blocks = [];
|
|
355
|
+
if (blocksResult && typeof blocksResult === 'object') {
|
|
356
|
+
const result = blocksResult;
|
|
357
|
+
if (Array.isArray(result.results)) {
|
|
358
|
+
blocks = result.results;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const { goal, todos, gherkinFeatures } = parseBlocks(blocks);
|
|
362
|
+
return { title, goal, todos, gherkinFeatures };
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
// Ensure the MCP process is terminated
|
|
366
|
+
mcpProcess.stdin?.end();
|
|
367
|
+
mcpProcess.kill();
|
|
368
|
+
}
|
|
369
|
+
}
|