@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.
Files changed (59) hide show
  1. package/AGENTS.md +227 -0
  2. package/LICENSE +674 -0
  3. package/README.md +199 -0
  4. package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
  5. package/dist/mcp-server/kobo-tasks-server.js +116 -0
  6. package/dist/server/db/index.js +22 -0
  7. package/dist/server/db/migrations.js +20 -0
  8. package/dist/server/db/schema.js +49 -0
  9. package/dist/server/index.js +178 -0
  10. package/dist/server/routes/dev-server.js +74 -0
  11. package/dist/server/routes/git.js +20 -0
  12. package/dist/server/routes/notion.js +24 -0
  13. package/dist/server/routes/settings.js +92 -0
  14. package/dist/server/routes/workspaces.js +730 -0
  15. package/dist/server/services/agent-manager.js +435 -0
  16. package/dist/server/services/dev-server-service.js +298 -0
  17. package/dist/server/services/notion-service.js +369 -0
  18. package/dist/server/services/pr-template-service.js +38 -0
  19. package/dist/server/services/settings-service.js +205 -0
  20. package/dist/server/services/websocket-service.js +212 -0
  21. package/dist/server/services/workspace-service.js +208 -0
  22. package/dist/server/services/worktree-service.js +117 -0
  23. package/dist/server/utils/git-ops.js +117 -0
  24. package/dist/server/utils/paths.js +95 -0
  25. package/dist/server/utils/process-tracker.js +46 -0
  26. package/package.json +84 -0
  27. package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
  28. package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
  29. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
  30. package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
  31. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  32. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  33. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  34. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  35. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  36. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  37. package/src/client/dist/spa/assets/MainLayout-6hzaLlYO.js +1 -0
  38. package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
  39. package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
  40. package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
  41. package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
  42. package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
  43. package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
  44. package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
  45. package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
  46. package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
  47. package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
  48. package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
  49. package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
  50. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
  51. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  52. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
  53. package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
  54. package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
  55. package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
  56. package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
  57. package/src/client/dist/spa/index.html +4 -0
  58. package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
  59. 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
+ }