mrmd-server 0.1.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/src/api/pty.js ADDED
@@ -0,0 +1,401 @@
1
+ /**
2
+ * PTY Session API routes (for ```term blocks)
3
+ *
4
+ * Mirrors electronAPI.pty.*
5
+ */
6
+
7
+ import { Router } from 'express';
8
+ import { spawn } from 'child_process';
9
+ import path from 'path';
10
+ import fs from 'fs/promises';
11
+ import net from 'net';
12
+
13
+ // Session registry: sessionName -> { port, process, cwd, venv, wsUrl }
14
+ const sessions = new Map();
15
+
16
+ /**
17
+ * Create PTY routes
18
+ * @param {import('../server.js').ServerContext} ctx
19
+ */
20
+ export function createPtyRoutes(ctx) {
21
+ const router = Router();
22
+
23
+ /**
24
+ * GET /api/pty
25
+ * List all running PTY sessions
26
+ * Mirrors: electronAPI.pty.list()
27
+ */
28
+ router.get('/', async (req, res) => {
29
+ try {
30
+ const list = [];
31
+ for (const [name, session] of sessions) {
32
+ list.push({
33
+ name,
34
+ port: session.port,
35
+ cwd: session.cwd,
36
+ venv: session.venv,
37
+ wsUrl: session.wsUrl,
38
+ running: session.process && !session.process.killed,
39
+ });
40
+ }
41
+ res.json(list);
42
+ } catch (err) {
43
+ console.error('[pty:list]', err);
44
+ res.status(500).json({ error: err.message });
45
+ }
46
+ });
47
+
48
+ /**
49
+ * POST /api/pty
50
+ * Start a new PTY session (mrmd-pty server)
51
+ * Mirrors: electronAPI.pty.start(config)
52
+ */
53
+ router.post('/', async (req, res) => {
54
+ try {
55
+ const { config } = req.body;
56
+ const { name, cwd, venv } = config || {};
57
+
58
+ if (!name) {
59
+ return res.status(400).json({ error: 'config.name required' });
60
+ }
61
+
62
+ // Check if session already exists
63
+ if (sessions.has(name)) {
64
+ const existing = sessions.get(name);
65
+ if (existing.process && !existing.process.killed) {
66
+ return res.json({
67
+ name,
68
+ port: existing.port,
69
+ cwd: existing.cwd,
70
+ venv: existing.venv,
71
+ wsUrl: existing.wsUrl,
72
+ reused: true,
73
+ alive: true,
74
+ });
75
+ }
76
+ }
77
+
78
+ // Find free port
79
+ const port = await findFreePort(7001, 7100);
80
+ const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
81
+
82
+ // Find mrmd-pty package
83
+ const mrmdPtyPaths = [
84
+ path.join(ctx.projectDir, '../mrmd-pty'),
85
+ path.join(process.cwd(), '../mrmd-pty'),
86
+ path.join(process.cwd(), 'mrmd-pty'),
87
+ path.join(__dirname, '../../../mrmd-pty'),
88
+ ];
89
+
90
+ let mrmdPtyPath = null;
91
+ for (const p of mrmdPtyPaths) {
92
+ try {
93
+ await fs.access(path.join(p, 'package.json'));
94
+ mrmdPtyPath = p;
95
+ break;
96
+ } catch {}
97
+ }
98
+
99
+ let proc;
100
+ const env = { ...process.env };
101
+
102
+ // If venv specified, activate it
103
+ if (venv) {
104
+ const venvPath = path.resolve(ctx.projectDir, venv);
105
+ env.VIRTUAL_ENV = venvPath;
106
+ env.PATH = `${path.join(venvPath, 'bin')}:${env.PATH}`;
107
+ }
108
+
109
+ if (mrmdPtyPath) {
110
+ // Run mrmd-pty server
111
+ proc = spawn('node', [
112
+ path.join(mrmdPtyPath, 'src', 'server.js'),
113
+ '--port', port.toString(),
114
+ '--cwd', workDir,
115
+ ], {
116
+ cwd: workDir,
117
+ stdio: ['pipe', 'pipe', 'pipe'],
118
+ env,
119
+ });
120
+ } else {
121
+ // Fallback: Try to use npx
122
+ proc = spawn('npx', [
123
+ 'mrmd-pty',
124
+ '--port', port.toString(),
125
+ '--cwd', workDir,
126
+ ], {
127
+ cwd: workDir,
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ env,
130
+ });
131
+ }
132
+
133
+ // Wait for server to start
134
+ try {
135
+ await waitForPort(port, 10000);
136
+ } catch (err) {
137
+ proc.kill();
138
+ return res.status(500).json({ error: `PTY server failed to start: ${err.message}` });
139
+ }
140
+
141
+ const wsUrl = `ws://localhost:${port}`;
142
+
143
+ sessions.set(name, {
144
+ port,
145
+ process: proc,
146
+ cwd: workDir,
147
+ venv,
148
+ wsUrl,
149
+ });
150
+
151
+ proc.on('exit', (code) => {
152
+ console.log(`[pty] ${name} exited with code ${code}`);
153
+ sessions.delete(name);
154
+ });
155
+
156
+ res.json({
157
+ name,
158
+ port,
159
+ cwd: workDir,
160
+ venv,
161
+ wsUrl,
162
+ alive: true,
163
+ });
164
+ } catch (err) {
165
+ console.error('[pty:start]', err);
166
+ res.status(500).json({ error: err.message });
167
+ }
168
+ });
169
+
170
+ /**
171
+ * DELETE /api/pty/:name
172
+ * Stop a PTY session
173
+ * Mirrors: electronAPI.pty.stop(sessionName)
174
+ */
175
+ router.delete('/:name', async (req, res) => {
176
+ try {
177
+ const { name } = req.params;
178
+ const session = sessions.get(name);
179
+
180
+ if (!session) {
181
+ return res.json({ success: true, message: 'Session not found' });
182
+ }
183
+
184
+ if (session.process && !session.process.killed) {
185
+ session.process.kill();
186
+ }
187
+
188
+ sessions.delete(name);
189
+ res.json({ success: true });
190
+ } catch (err) {
191
+ console.error('[pty:stop]', err);
192
+ res.status(500).json({ error: err.message });
193
+ }
194
+ });
195
+
196
+ /**
197
+ * POST /api/pty/:name/restart
198
+ * Restart a PTY session
199
+ * Mirrors: electronAPI.pty.restart(sessionName)
200
+ */
201
+ router.post('/:name/restart', async (req, res) => {
202
+ try {
203
+ const { name } = req.params;
204
+ const session = sessions.get(name);
205
+
206
+ if (!session) {
207
+ return res.status(404).json({ error: 'Session not found' });
208
+ }
209
+
210
+ const { cwd, venv } = session;
211
+
212
+ // Kill existing
213
+ if (session.process && !session.process.killed) {
214
+ session.process.kill();
215
+ }
216
+
217
+ sessions.delete(name);
218
+
219
+ // Create new session
220
+ req.body.config = { name, cwd, venv };
221
+ // Redirect to POST /
222
+ return res.redirect(307, '/api/pty');
223
+ } catch (err) {
224
+ console.error('[pty:restart]', err);
225
+ res.status(500).json({ error: err.message });
226
+ }
227
+ });
228
+
229
+ /**
230
+ * POST /api/pty/for-document
231
+ * Get or create PTY session for a document
232
+ * Returns session info including wsUrl for WebSocket connection
233
+ * Mirrors: electronAPI.pty.forDocument(documentPath)
234
+ */
235
+ router.post('/for-document', async (req, res) => {
236
+ try {
237
+ const { documentPath } = req.body;
238
+ if (!documentPath) {
239
+ return res.status(400).json({ error: 'documentPath required' });
240
+ }
241
+
242
+ const docName = `pty-${path.basename(documentPath, '.md')}`;
243
+
244
+ // Check if session exists
245
+ if (sessions.has(docName)) {
246
+ const session = sessions.get(docName);
247
+ const alive = session.process && !session.process.killed;
248
+ return res.json({
249
+ name: docName,
250
+ port: session.port,
251
+ cwd: session.cwd,
252
+ venv: session.venv,
253
+ wsUrl: session.wsUrl,
254
+ alive,
255
+ });
256
+ }
257
+
258
+ // Try to read venv from document frontmatter
259
+ const fullPath = path.resolve(ctx.projectDir, documentPath);
260
+ let venv = null;
261
+
262
+ try {
263
+ const content = await fs.readFile(fullPath, 'utf-8');
264
+ const match = content.match(/^---\n[\s\S]*?venv:\s*(.+?)[\n\r]/m);
265
+ if (match) {
266
+ venv = match[1].trim();
267
+ }
268
+ } catch {}
269
+
270
+ // Create session
271
+ const port = await findFreePort(7001, 7100);
272
+ const workDir = path.dirname(fullPath);
273
+ const env = { ...process.env };
274
+
275
+ if (venv) {
276
+ const venvPath = path.resolve(ctx.projectDir, venv);
277
+ env.VIRTUAL_ENV = venvPath;
278
+ env.PATH = `${path.join(venvPath, 'bin')}:${env.PATH}`;
279
+ }
280
+
281
+ // Try to start mrmd-pty
282
+ const mrmdPtyPaths = [
283
+ path.join(ctx.projectDir, '../mrmd-pty'),
284
+ path.join(process.cwd(), '../mrmd-pty'),
285
+ ];
286
+
287
+ let mrmdPtyPath = null;
288
+ for (const p of mrmdPtyPaths) {
289
+ try {
290
+ await fs.access(path.join(p, 'package.json'));
291
+ mrmdPtyPath = p;
292
+ break;
293
+ } catch {}
294
+ }
295
+
296
+ let proc;
297
+ if (mrmdPtyPath) {
298
+ proc = spawn('node', [
299
+ path.join(mrmdPtyPath, 'src', 'server.js'),
300
+ '--port', port.toString(),
301
+ '--cwd', workDir,
302
+ ], {
303
+ cwd: workDir,
304
+ stdio: ['pipe', 'pipe', 'pipe'],
305
+ env,
306
+ });
307
+ } else {
308
+ // mrmd-pty not found, return null
309
+ return res.json(null);
310
+ }
311
+
312
+ try {
313
+ await waitForPort(port, 10000);
314
+ } catch (err) {
315
+ proc.kill();
316
+ return res.json(null);
317
+ }
318
+
319
+ const wsUrl = `ws://localhost:${port}`;
320
+
321
+ sessions.set(docName, {
322
+ port,
323
+ process: proc,
324
+ cwd: workDir,
325
+ venv,
326
+ wsUrl,
327
+ });
328
+
329
+ proc.on('exit', () => {
330
+ sessions.delete(docName);
331
+ });
332
+
333
+ res.json({
334
+ name: docName,
335
+ port,
336
+ cwd: workDir,
337
+ venv,
338
+ wsUrl,
339
+ alive: true,
340
+ });
341
+ } catch (err) {
342
+ console.error('[pty:forDocument]', err);
343
+ res.status(500).json({ error: err.message });
344
+ }
345
+ });
346
+
347
+ return router;
348
+ }
349
+
350
+ /**
351
+ * Find a free port in range
352
+ */
353
+ async function findFreePort(start, end) {
354
+ for (let port = start; port <= end; port++) {
355
+ if (await isPortFree(port)) {
356
+ return port;
357
+ }
358
+ }
359
+ throw new Error(`No free port found in range ${start}-${end}`);
360
+ }
361
+
362
+ /**
363
+ * Check if port is free
364
+ */
365
+ function isPortFree(port) {
366
+ return new Promise((resolve) => {
367
+ const server = net.createServer();
368
+ server.once('error', () => resolve(false));
369
+ server.once('listening', () => {
370
+ server.close();
371
+ resolve(true);
372
+ });
373
+ server.listen(port, '127.0.0.1');
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Wait for port to be open
379
+ */
380
+ function waitForPort(port, timeout = 10000) {
381
+ return new Promise((resolve, reject) => {
382
+ const start = Date.now();
383
+
384
+ function check() {
385
+ const socket = net.connect(port, '127.0.0.1');
386
+ socket.once('connect', () => {
387
+ socket.end();
388
+ resolve();
389
+ });
390
+ socket.once('error', () => {
391
+ if (Date.now() - start > timeout) {
392
+ reject(new Error(`Timeout waiting for port ${port}`));
393
+ } else {
394
+ setTimeout(check, 200);
395
+ }
396
+ });
397
+ }
398
+
399
+ check();
400
+ });
401
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Runtime API routes
3
+ *
4
+ * Mirrors electronAPI runtime management functions
5
+ */
6
+
7
+ import { Router } from 'express';
8
+
9
+ // Global runtime registry (shared with session.js in a real impl)
10
+ const runtimes = new Map();
11
+
12
+ /**
13
+ * Create runtime routes
14
+ * @param {import('../server.js').ServerContext} ctx
15
+ */
16
+ export function createRuntimeRoutes(ctx) {
17
+ const router = Router();
18
+
19
+ /**
20
+ * GET /api/runtime
21
+ * List all runtimes
22
+ * Mirrors: electronAPI.listRuntimes()
23
+ */
24
+ router.get('/', async (req, res) => {
25
+ try {
26
+ const list = [];
27
+ for (const [id, runtime] of runtimes) {
28
+ list.push({
29
+ id,
30
+ type: runtime.type,
31
+ port: runtime.port,
32
+ venv: runtime.venv,
33
+ cwd: runtime.cwd,
34
+ running: runtime.process && !runtime.process.killed,
35
+ });
36
+ }
37
+ res.json(list);
38
+ } catch (err) {
39
+ console.error('[runtime:list]', err);
40
+ res.status(500).json({ error: err.message });
41
+ }
42
+ });
43
+
44
+ /**
45
+ * DELETE /api/runtime/:id
46
+ * Kill a runtime
47
+ * Mirrors: electronAPI.killRuntime(runtimeId)
48
+ */
49
+ router.delete('/:id', async (req, res) => {
50
+ try {
51
+ const { id } = req.params;
52
+ const runtime = runtimes.get(id);
53
+
54
+ if (!runtime) {
55
+ return res.json({ success: true, message: 'Runtime not found' });
56
+ }
57
+
58
+ if (runtime.process && !runtime.process.killed) {
59
+ runtime.process.kill();
60
+ }
61
+
62
+ runtimes.delete(id);
63
+ res.json({ success: true });
64
+ } catch (err) {
65
+ console.error('[runtime:kill]', err);
66
+ res.status(500).json({ error: err.message });
67
+ }
68
+ });
69
+
70
+ /**
71
+ * POST /api/runtime/:id/attach
72
+ * Attach to an existing runtime
73
+ * Mirrors: electronAPI.attachRuntime(runtimeId)
74
+ */
75
+ router.post('/:id/attach', async (req, res) => {
76
+ try {
77
+ const { id } = req.params;
78
+ const runtime = runtimes.get(id);
79
+
80
+ if (!runtime) {
81
+ return res.status(404).json({ error: 'Runtime not found' });
82
+ }
83
+
84
+ res.json({
85
+ id,
86
+ port: runtime.port,
87
+ url: `http://localhost:${runtime.port}/mrp/v1`,
88
+ attached: true,
89
+ });
90
+ } catch (err) {
91
+ console.error('[runtime:attach]', err);
92
+ res.status(500).json({ error: err.message });
93
+ }
94
+ });
95
+
96
+ /**
97
+ * POST /api/runtime/start-python
98
+ * Start a Python runtime
99
+ * Mirrors: electronAPI.startPython(venvPath, forceNew)
100
+ */
101
+ router.post('/start-python', async (req, res) => {
102
+ try {
103
+ const { venvPath, forceNew = false } = req.body;
104
+
105
+ // Generate a runtime ID
106
+ const id = `python-${Date.now()}`;
107
+
108
+ // Check if we can reuse an existing runtime
109
+ if (!forceNew && venvPath) {
110
+ for (const [existingId, runtime] of runtimes) {
111
+ if (runtime.type === 'python' && runtime.venv === venvPath) {
112
+ if (runtime.process && !runtime.process.killed) {
113
+ return res.json({
114
+ id: existingId,
115
+ port: runtime.port,
116
+ url: `http://localhost:${runtime.port}/mrp/v1`,
117
+ reused: true,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Start new runtime via session API (reuse that logic)
125
+ // For now, return a placeholder
126
+ res.json({
127
+ id,
128
+ message: 'Use /api/session to start runtimes',
129
+ });
130
+ } catch (err) {
131
+ console.error('[runtime:start-python]', err);
132
+ res.status(500).json({ error: err.message });
133
+ }
134
+ });
135
+
136
+ return router;
137
+ }
138
+
139
+ // Export for use by session.js
140
+ export { runtimes };