mrmd-server 0.1.0 → 0.1.2

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.
@@ -1,17 +1,31 @@
1
1
  /**
2
2
  * Session API routes
3
3
  *
4
- * Mirrors electronAPI.session.*
4
+ * Mirrors electronAPI.session.* using SessionService from mrmd-electron
5
5
  */
6
6
 
7
7
  import { Router } from 'express';
8
- import { spawn } from 'child_process';
8
+ import { Project } from 'mrmd-project';
9
+ import fs from 'fs';
9
10
  import path from 'path';
10
- import fs from 'fs/promises';
11
- import net from 'net';
12
11
 
13
- // Session registry: sessionName -> { port, process, venv, cwd }
14
- const sessions = new Map();
12
+ /**
13
+ * Detect project from a file path
14
+ * Returns { root, config } or null if not in a project
15
+ */
16
+ function detectProject(filePath) {
17
+ const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
18
+ if (!root) return null;
19
+
20
+ try {
21
+ const mrmdPath = path.join(root, 'mrmd.md');
22
+ const content = fs.readFileSync(mrmdPath, 'utf8');
23
+ const config = Project.parseConfig(content);
24
+ return { root, config };
25
+ } catch (e) {
26
+ return { root, config: {} };
27
+ }
28
+ }
15
29
 
16
30
  /**
17
31
  * Create session routes
@@ -19,6 +33,7 @@ const sessions = new Map();
19
33
  */
20
34
  export function createSessionRoutes(ctx) {
21
35
  const router = Router();
36
+ const { sessionService } = ctx;
22
37
 
23
38
  /**
24
39
  * GET /api/session
@@ -27,16 +42,7 @@ export function createSessionRoutes(ctx) {
27
42
  */
28
43
  router.get('/', async (req, res) => {
29
44
  try {
30
- const list = [];
31
- for (const [name, session] of sessions) {
32
- list.push({
33
- name,
34
- port: session.port,
35
- venv: session.venv,
36
- cwd: session.cwd,
37
- running: session.process && !session.process.killed,
38
- });
39
- }
45
+ const list = sessionService.list();
40
46
  res.json(list);
41
47
  } catch (err) {
42
48
  console.error('[session:list]', err);
@@ -52,107 +58,21 @@ export function createSessionRoutes(ctx) {
52
58
  router.post('/', async (req, res) => {
53
59
  try {
54
60
  const { config } = req.body;
55
- const { name, venv, cwd } = config || {};
56
61
 
57
- if (!name) {
62
+ if (!config?.name) {
58
63
  return res.status(400).json({ error: 'config.name required' });
59
64
  }
60
65
 
61
- // Check if session already exists
62
- if (sessions.has(name)) {
63
- const existing = sessions.get(name);
64
- if (existing.process && !existing.process.killed) {
65
- return res.json({
66
- name,
67
- port: existing.port,
68
- venv: existing.venv,
69
- cwd: existing.cwd,
70
- reused: true,
71
- });
72
- }
73
- }
74
-
75
- // Find free port
76
- const port = await findFreePort(8001, 8100);
77
-
78
- // Determine Python path
79
- let pythonPath = 'python3';
80
- let resolvedVenv = null;
81
-
82
- if (venv) {
83
- resolvedVenv = path.resolve(ctx.projectDir, venv);
84
- const venvPython = path.join(resolvedVenv, 'bin', 'python');
85
- try {
86
- await fs.access(venvPython);
87
- pythonPath = venvPython;
88
- } catch {
89
- console.warn(`[session] Venv python not found: ${venvPython}`);
90
- }
91
- }
92
-
93
- // Start mrmd-python
94
- const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
95
-
96
- // Try to find mrmd-python package
97
- const mrmdPythonPaths = [
98
- path.join(ctx.projectDir, '../mrmd-python'),
99
- path.join(process.cwd(), '../mrmd-python'),
100
- path.join(process.cwd(), 'mrmd-python'),
101
- ];
102
-
103
- let mrmdPythonPath = null;
104
- for (const p of mrmdPythonPaths) {
105
- try {
106
- await fs.access(path.join(p, 'src', 'mrmd_python'));
107
- mrmdPythonPath = p;
108
- break;
109
- } catch {}
110
- }
111
-
112
- let proc;
113
- if (mrmdPythonPath) {
114
- // Run with uv
115
- proc = spawn('uv', [
116
- 'run', '--project', mrmdPythonPath,
117
- 'python', '-m', 'mrmd_python.cli',
118
- '--port', port.toString(),
119
- ], {
120
- cwd: workDir,
121
- stdio: ['pipe', 'pipe', 'pipe'],
122
- env: { ...process.env, PYTHONPATH: path.join(mrmdPythonPath, 'src') },
123
- });
124
- } else {
125
- // Fallback: assume mrmd-python is installed
126
- proc = spawn(pythonPath, [
127
- '-m', 'mrmd_python.cli',
128
- '--port', port.toString(),
129
- ], {
130
- cwd: workDir,
131
- stdio: ['pipe', 'pipe', 'pipe'],
132
- });
133
- }
134
-
135
- // Wait for server to start
136
- await waitForPort(port, 15000);
137
-
138
- sessions.set(name, {
139
- port,
140
- process: proc,
141
- venv: resolvedVenv,
142
- cwd: workDir,
143
- });
144
-
145
- proc.on('exit', (code) => {
146
- console.log(`[session] ${name} exited with code ${code}`);
147
- sessions.delete(name);
148
- });
66
+ const result = await sessionService.start(config);
149
67
 
68
+ // Return in expected format
150
69
  res.json({
151
- name,
152
- port,
153
- venv: resolvedVenv,
154
- cwd: workDir,
155
- url: `http://localhost:${port}/mrp/v1`,
70
+ name: result.name,
71
+ port: result.port,
72
+ venv: result.venv,
73
+ cwd: result.cwd,
74
+ pid: result.pid,
75
+ url: `http://localhost:${result.port}/mrp/v1`,
156
76
  });
157
77
  } catch (err) {
158
78
  console.error('[session:start]', err);
@@ -168,21 +88,12 @@ export function createSessionRoutes(ctx) {
168
88
  router.delete('/:name', async (req, res) => {
169
89
  try {
170
90
  const { name } = req.params;
171
- const session = sessions.get(name);
172
-
173
- if (!session) {
174
- return res.json({ success: true, message: 'Session not found' });
175
- }
176
-
177
- if (session.process && !session.process.killed) {
178
- session.process.kill();
179
- }
180
-
181
- sessions.delete(name);
91
+ await sessionService.stop(name);
182
92
  res.json({ success: true });
183
93
  } catch (err) {
184
94
  console.error('[session:stop]', err);
185
- res.status(500).json({ error: err.message });
95
+ // Even if session not found, return success (idempotent)
96
+ res.json({ success: true, message: err.message });
186
97
  }
187
98
  });
188
99
 
@@ -194,24 +105,16 @@ export function createSessionRoutes(ctx) {
194
105
  router.post('/:name/restart', async (req, res) => {
195
106
  try {
196
107
  const { name } = req.params;
197
- const session = sessions.get(name);
108
+ const result = await sessionService.restart(name);
198
109
 
199
- if (!session) {
200
- return res.status(404).json({ error: 'Session not found' });
201
- }
202
-
203
- // Kill existing
204
- if (session.process && !session.process.killed) {
205
- session.process.kill();
206
- }
207
-
208
- // Re-create with same config
209
- req.body.config = { name, venv: session.venv, cwd: session.cwd };
210
- sessions.delete(name);
211
-
212
- // Forward to start handler
213
- // (In a real implementation, extract the logic to a shared function)
214
- return router.handle(req, res, () => {});
110
+ res.json({
111
+ name: result.name,
112
+ port: result.port,
113
+ venv: result.venv,
114
+ cwd: result.cwd,
115
+ pid: result.pid,
116
+ url: `http://localhost:${result.port}/mrp/v1`,
117
+ });
215
118
  } catch (err) {
216
119
  console.error('[session:restart]', err);
217
120
  res.status(500).json({ error: err.message });
@@ -222,137 +125,79 @@ export function createSessionRoutes(ctx) {
222
125
  * POST /api/session/for-document
223
126
  * Get or create session for a document
224
127
  * Mirrors: electronAPI.session.forDocument(documentPath)
128
+ *
129
+ * Automatically detects project if projectConfig/projectRoot not provided
225
130
  */
226
131
  router.post('/for-document', async (req, res) => {
227
132
  try {
228
- const { documentPath } = req.body;
133
+ let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
134
+
229
135
  if (!documentPath) {
230
136
  return res.status(400).json({ error: 'documentPath required' });
231
137
  }
232
138
 
233
- // Use document name as session name
234
- const docName = path.basename(documentPath, '.md');
235
-
236
- // Check if session exists
237
- if (sessions.has(docName)) {
238
- const session = sessions.get(docName);
239
- return res.json({
240
- name: docName,
241
- port: session.port,
242
- venv: session.venv,
243
- cwd: session.cwd,
244
- url: `http://localhost:${session.port}/mrp/v1`,
245
- });
139
+ // Auto-detect project if not provided
140
+ if (!projectConfig || !projectRoot) {
141
+ const detected = detectProject(documentPath);
142
+ if (detected) {
143
+ projectRoot = projectRoot || detected.root;
144
+ projectConfig = projectConfig || detected.config;
145
+ } else {
146
+ projectRoot = projectRoot || (ctx.projectDir || process.cwd());
147
+ projectConfig = projectConfig || {};
148
+ }
246
149
  }
247
150
 
248
- // Try to read venv from document frontmatter or project config
249
- const fullPath = path.resolve(ctx.projectDir, documentPath);
250
- let venv = null;
251
-
252
- try {
253
- const content = await fs.readFile(fullPath, 'utf-8');
254
- const match = content.match(/^---\n[\s\S]*?venv:\s*(.+?)[\n\r]/m);
255
- if (match) {
256
- venv = match[1].trim();
151
+ // Auto-parse frontmatter if not provided
152
+ if (!frontmatter) {
153
+ try {
154
+ const content = fs.readFileSync(documentPath, 'utf8');
155
+ frontmatter = Project.parseFrontmatter(content);
156
+ } catch (e) {
157
+ frontmatter = null;
257
158
  }
258
- } catch {}
259
-
260
- // Create session
261
- req.body.config = {
262
- name: docName,
263
- venv,
264
- cwd: path.dirname(fullPath),
265
- };
266
-
267
- // Re-use the POST / handler logic
268
- // (simplified - in production extract to shared function)
269
- const { config } = req.body;
270
- const port = await findFreePort(8001, 8100);
271
- const workDir = config.cwd || ctx.projectDir;
272
-
273
- // Start with default Python for now
274
- const proc = spawn('uv', [
275
- 'run', 'python', '-m', 'mrmd_python.cli',
276
- '--port', port.toString(),
277
- ], {
278
- cwd: workDir,
279
- stdio: ['pipe', 'pipe', 'pipe'],
280
- });
159
+ }
281
160
 
282
- await waitForPort(port, 15000);
161
+ const result = await sessionService.getForDocument(
162
+ documentPath,
163
+ projectConfig,
164
+ frontmatter,
165
+ projectRoot
166
+ );
283
167
 
284
- sessions.set(docName, {
285
- port,
286
- process: proc,
287
- venv,
288
- cwd: workDir,
289
- });
168
+ // Add url if we have a port
169
+ if (result?.port) {
170
+ result.url = `http://localhost:${result.port}/mrp/v1`;
171
+ }
290
172
 
291
- res.json({
292
- name: docName,
293
- port,
294
- venv,
295
- cwd: workDir,
296
- url: `http://localhost:${port}/mrp/v1`,
297
- });
173
+ res.json(result);
298
174
  } catch (err) {
299
175
  console.error('[session:forDocument]', err);
300
- res.status(500).json({ error: err.message });
176
+ // Return null on error (non-blocking - editor works without Python)
177
+ res.json(null);
301
178
  }
302
179
  });
303
180
 
304
- return router;
305
- }
306
-
307
- /**
308
- * Find a free port in range
309
- */
310
- async function findFreePort(start, end) {
311
- for (let port = start; port <= end; port++) {
312
- if (await isPortFree(port)) {
313
- return port;
314
- }
315
- }
316
- throw new Error(`No free port found in range ${start}-${end}`);
317
- }
318
-
319
- /**
320
- * Check if port is free
321
- */
322
- function isPortFree(port) {
323
- return new Promise((resolve) => {
324
- const server = net.createServer();
325
- server.once('error', () => resolve(false));
326
- server.once('listening', () => {
327
- server.close();
328
- resolve(true);
329
- });
330
- server.listen(port, '127.0.0.1');
331
- });
332
- }
181
+ /**
182
+ * POST /api/session/attach
183
+ * Attach to an existing session
184
+ * Mirrors: electronAPI.session.attach(sessionName)
185
+ */
186
+ router.post('/attach', async (req, res) => {
187
+ try {
188
+ const { sessionName } = req.body;
333
189
 
334
- /**
335
- * Wait for port to be open
336
- */
337
- function waitForPort(port, timeout = 10000) {
338
- return new Promise((resolve, reject) => {
339
- const start = Date.now();
190
+ if (!sessionName) {
191
+ return res.status(400).json({ error: 'sessionName required' });
192
+ }
340
193
 
341
- function check() {
342
- const socket = net.connect(port, '127.0.0.1');
343
- socket.once('connect', () => {
344
- socket.end();
345
- resolve();
346
- });
347
- socket.once('error', () => {
348
- if (Date.now() - start > timeout) {
349
- reject(new Error(`Timeout waiting for port ${port}`));
350
- } else {
351
- setTimeout(check, 200);
352
- }
353
- });
194
+ const result = sessionService.attach(sessionName);
195
+ res.json(result);
196
+ } catch (err) {
197
+ console.error('[session:attach]', err);
198
+ res.status(500).json({ error: err.message });
354
199
  }
355
-
356
- check();
357
200
  });
201
+
202
+ return router;
358
203
  }