mrmd-server 0.1.0 → 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/LICENSE +21 -0
- package/bin/cli.js +4 -20
- package/package.json +20 -3
- package/src/api/bash.js +72 -189
- package/src/api/file.js +26 -20
- package/src/api/index.js +5 -0
- package/src/api/notebook.js +290 -0
- package/src/api/project.js +178 -12
- package/src/api/pty.js +73 -293
- package/src/api/r.js +337 -0
- package/src/api/session.js +96 -251
- package/src/api/settings.js +782 -0
- package/src/api/system.js +199 -1
- package/src/server.js +117 -6
- package/src/services.js +42 -0
- package/src/sync-manager.js +223 -0
- package/static/favicon.png +0 -0
- package/static/http-shim.js +172 -3
- package/static/index.html +1 -0
package/src/api/session.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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:
|
|
154
|
-
cwd:
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
+
const result = await sessionService.restart(name);
|
|
198
109
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
}
|
|
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
|
|
161
|
+
const result = await sessionService.getForDocument(
|
|
162
|
+
documentPath,
|
|
163
|
+
projectConfig,
|
|
164
|
+
frontmatter,
|
|
165
|
+
projectRoot
|
|
166
|
+
);
|
|
283
167
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
176
|
+
// Return null on error (non-blocking - editor works without Python)
|
|
177
|
+
res.json(null);
|
|
301
178
|
}
|
|
302
179
|
});
|
|
303
180
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
}
|