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.
- 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 +133 -8
- 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/pty.js
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PTY Session API routes (for ```term blocks)
|
|
3
3
|
*
|
|
4
|
-
* Mirrors electronAPI.pty.*
|
|
4
|
+
* Mirrors electronAPI.pty.* using PtySessionService 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
|
+
*/
|
|
15
|
+
function detectProject(filePath) {
|
|
16
|
+
const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
|
|
17
|
+
if (!root) return null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const mrmdPath = path.join(root, 'mrmd.md');
|
|
21
|
+
const content = fs.readFileSync(mrmdPath, 'utf8');
|
|
22
|
+
const config = Project.parseConfig(content);
|
|
23
|
+
return { root, config };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return { root, config: {} };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
30
|
* Create PTY routes
|
|
@@ -19,6 +32,7 @@ const sessions = new Map();
|
|
|
19
32
|
*/
|
|
20
33
|
export function createPtyRoutes(ctx) {
|
|
21
34
|
const router = Router();
|
|
35
|
+
const { ptySessionService } = ctx;
|
|
22
36
|
|
|
23
37
|
/**
|
|
24
38
|
* GET /api/pty
|
|
@@ -27,17 +41,7 @@ export function createPtyRoutes(ctx) {
|
|
|
27
41
|
*/
|
|
28
42
|
router.get('/', async (req, res) => {
|
|
29
43
|
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
|
-
}
|
|
44
|
+
const list = ptySessionService.list();
|
|
41
45
|
res.json(list);
|
|
42
46
|
} catch (err) {
|
|
43
47
|
console.error('[pty:list]', err);
|
|
@@ -53,113 +57,21 @@ export function createPtyRoutes(ctx) {
|
|
|
53
57
|
router.post('/', async (req, res) => {
|
|
54
58
|
try {
|
|
55
59
|
const { config } = req.body;
|
|
56
|
-
const { name, cwd, venv } = config || {};
|
|
57
60
|
|
|
58
|
-
if (!name) {
|
|
61
|
+
if (!config?.name) {
|
|
59
62
|
return res.status(400).json({ error: 'config.name required' });
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
|
|
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
|
-
});
|
|
65
|
+
const result = await ptySessionService.start(config);
|
|
155
66
|
|
|
156
67
|
res.json({
|
|
157
|
-
name,
|
|
158
|
-
port,
|
|
159
|
-
cwd:
|
|
160
|
-
venv,
|
|
161
|
-
|
|
162
|
-
|
|
68
|
+
name: result.name,
|
|
69
|
+
port: result.port,
|
|
70
|
+
cwd: result.cwd,
|
|
71
|
+
venv: result.venv,
|
|
72
|
+
pid: result.pid,
|
|
73
|
+
wsUrl: `ws://localhost:${result.port}`,
|
|
74
|
+
alive: result.alive,
|
|
163
75
|
});
|
|
164
76
|
} catch (err) {
|
|
165
77
|
console.error('[pty:start]', err);
|
|
@@ -175,21 +87,11 @@ export function createPtyRoutes(ctx) {
|
|
|
175
87
|
router.delete('/:name', async (req, res) => {
|
|
176
88
|
try {
|
|
177
89
|
const { name } = req.params;
|
|
178
|
-
|
|
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);
|
|
90
|
+
await ptySessionService.stop(name);
|
|
189
91
|
res.json({ success: true });
|
|
190
92
|
} catch (err) {
|
|
191
93
|
console.error('[pty:stop]', err);
|
|
192
|
-
res.
|
|
94
|
+
res.json({ success: true, message: err.message });
|
|
193
95
|
}
|
|
194
96
|
});
|
|
195
97
|
|
|
@@ -201,25 +103,17 @@ export function createPtyRoutes(ctx) {
|
|
|
201
103
|
router.post('/:name/restart', async (req, res) => {
|
|
202
104
|
try {
|
|
203
105
|
const { name } = req.params;
|
|
204
|
-
const
|
|
106
|
+
const result = await ptySessionService.restart(name);
|
|
205
107
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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');
|
|
108
|
+
res.json({
|
|
109
|
+
name: result.name,
|
|
110
|
+
port: result.port,
|
|
111
|
+
cwd: result.cwd,
|
|
112
|
+
venv: result.venv,
|
|
113
|
+
pid: result.pid,
|
|
114
|
+
wsUrl: `ws://localhost:${result.port}`,
|
|
115
|
+
alive: result.alive,
|
|
116
|
+
});
|
|
223
117
|
} catch (err) {
|
|
224
118
|
console.error('[pty:restart]', err);
|
|
225
119
|
res.status(500).json({ error: err.message });
|
|
@@ -231,171 +125,57 @@ export function createPtyRoutes(ctx) {
|
|
|
231
125
|
* Get or create PTY session for a document
|
|
232
126
|
* Returns session info including wsUrl for WebSocket connection
|
|
233
127
|
* Mirrors: electronAPI.pty.forDocument(documentPath)
|
|
128
|
+
*
|
|
129
|
+
* Automatically detects project if projectConfig/projectRoot not provided
|
|
234
130
|
*/
|
|
235
131
|
router.post('/for-document', async (req, res) => {
|
|
236
132
|
try {
|
|
237
|
-
|
|
133
|
+
let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
|
|
134
|
+
|
|
238
135
|
if (!documentPath) {
|
|
239
136
|
return res.status(400).json({ error: 'documentPath required' });
|
|
240
137
|
}
|
|
241
138
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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();
|
|
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 || {};
|
|
267
148
|
}
|
|
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
149
|
}
|
|
280
150
|
|
|
281
|
-
//
|
|
282
|
-
|
|
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) {
|
|
151
|
+
// Auto-parse frontmatter if not provided
|
|
152
|
+
if (!frontmatter) {
|
|
289
153
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
154
|
+
const content = fs.readFileSync(documentPath, 'utf8');
|
|
155
|
+
frontmatter = Project.parseFrontmatter(content);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
frontmatter = null;
|
|
158
|
+
}
|
|
294
159
|
}
|
|
295
160
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
}
|
|
161
|
+
const result = await ptySessionService.getForDocument(
|
|
162
|
+
documentPath,
|
|
163
|
+
projectConfig,
|
|
164
|
+
frontmatter,
|
|
165
|
+
projectRoot
|
|
166
|
+
);
|
|
311
167
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
proc.kill();
|
|
316
|
-
return res.json(null);
|
|
168
|
+
// Add wsUrl if we have a port
|
|
169
|
+
if (result?.port) {
|
|
170
|
+
result.wsUrl = `ws://localhost:${result.port}`;
|
|
317
171
|
}
|
|
318
172
|
|
|
319
|
-
|
|
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
|
-
});
|
|
173
|
+
res.json(result);
|
|
341
174
|
} catch (err) {
|
|
342
175
|
console.error('[pty:forDocument]', err);
|
|
343
|
-
res.
|
|
176
|
+
res.json(null);
|
|
344
177
|
}
|
|
345
178
|
});
|
|
346
179
|
|
|
347
180
|
return router;
|
|
348
181
|
}
|
|
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
|
-
}
|