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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Session API routes
3
+ *
4
+ * Mirrors electronAPI.session.*
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, venv, cwd }
14
+ const sessions = new Map();
15
+
16
+ /**
17
+ * Create session routes
18
+ * @param {import('../server.js').ServerContext} ctx
19
+ */
20
+ export function createSessionRoutes(ctx) {
21
+ const router = Router();
22
+
23
+ /**
24
+ * GET /api/session
25
+ * List all running sessions
26
+ * Mirrors: electronAPI.session.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
+ venv: session.venv,
36
+ cwd: session.cwd,
37
+ running: session.process && !session.process.killed,
38
+ });
39
+ }
40
+ res.json(list);
41
+ } catch (err) {
42
+ console.error('[session:list]', err);
43
+ res.status(500).json({ error: err.message });
44
+ }
45
+ });
46
+
47
+ /**
48
+ * POST /api/session
49
+ * Start a new session
50
+ * Mirrors: electronAPI.session.start(config)
51
+ */
52
+ router.post('/', async (req, res) => {
53
+ try {
54
+ const { config } = req.body;
55
+ const { name, venv, cwd } = config || {};
56
+
57
+ if (!name) {
58
+ return res.status(400).json({ error: 'config.name required' });
59
+ }
60
+
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
+ });
149
+
150
+ res.json({
151
+ name,
152
+ port,
153
+ venv: resolvedVenv,
154
+ cwd: workDir,
155
+ url: `http://localhost:${port}/mrp/v1`,
156
+ });
157
+ } catch (err) {
158
+ console.error('[session:start]', err);
159
+ res.status(500).json({ error: err.message });
160
+ }
161
+ });
162
+
163
+ /**
164
+ * DELETE /api/session/:name
165
+ * Stop a session
166
+ * Mirrors: electronAPI.session.stop(sessionName)
167
+ */
168
+ router.delete('/:name', async (req, res) => {
169
+ try {
170
+ 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);
182
+ res.json({ success: true });
183
+ } catch (err) {
184
+ console.error('[session:stop]', err);
185
+ res.status(500).json({ error: err.message });
186
+ }
187
+ });
188
+
189
+ /**
190
+ * POST /api/session/:name/restart
191
+ * Restart a session
192
+ * Mirrors: electronAPI.session.restart(sessionName)
193
+ */
194
+ router.post('/:name/restart', async (req, res) => {
195
+ try {
196
+ const { name } = req.params;
197
+ const session = sessions.get(name);
198
+
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, () => {});
215
+ } catch (err) {
216
+ console.error('[session:restart]', err);
217
+ res.status(500).json({ error: err.message });
218
+ }
219
+ });
220
+
221
+ /**
222
+ * POST /api/session/for-document
223
+ * Get or create session for a document
224
+ * Mirrors: electronAPI.session.forDocument(documentPath)
225
+ */
226
+ router.post('/for-document', async (req, res) => {
227
+ try {
228
+ const { documentPath } = req.body;
229
+ if (!documentPath) {
230
+ return res.status(400).json({ error: 'documentPath required' });
231
+ }
232
+
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
+ });
246
+ }
247
+
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();
257
+ }
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
+ });
281
+
282
+ await waitForPort(port, 15000);
283
+
284
+ sessions.set(docName, {
285
+ port,
286
+ process: proc,
287
+ venv,
288
+ cwd: workDir,
289
+ });
290
+
291
+ res.json({
292
+ name: docName,
293
+ port,
294
+ venv,
295
+ cwd: workDir,
296
+ url: `http://localhost:${port}/mrp/v1`,
297
+ });
298
+ } catch (err) {
299
+ console.error('[session:forDocument]', err);
300
+ res.status(500).json({ error: err.message });
301
+ }
302
+ });
303
+
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
+ }
333
+
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();
340
+
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
+ });
354
+ }
355
+
356
+ check();
357
+ });
358
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * System API routes
3
+ *
4
+ * Mirrors various electronAPI system functions
5
+ */
6
+
7
+ import { Router } from 'express';
8
+ import os from 'os';
9
+ import path from 'path';
10
+ import fs from 'fs/promises';
11
+ import { spawn } from 'child_process';
12
+
13
+ /**
14
+ * Create system routes
15
+ * @param {import('../server.js').ServerContext} ctx
16
+ */
17
+ export function createSystemRoutes(ctx) {
18
+ const router = Router();
19
+
20
+ /**
21
+ * GET /api/system/home
22
+ * Get home directory
23
+ * Mirrors: electronAPI.getHomeDir()
24
+ */
25
+ router.get('/home', (req, res) => {
26
+ res.json({ homeDir: os.homedir() });
27
+ });
28
+
29
+ /**
30
+ * GET /api/system/recent
31
+ * Get recent files and venvs
32
+ * Mirrors: electronAPI.getRecent()
33
+ */
34
+ router.get('/recent', async (req, res) => {
35
+ try {
36
+ // Try to read from config file
37
+ const configDir = path.join(os.homedir(), '.config', 'mrmd');
38
+ const recentPath = path.join(configDir, 'recent.json');
39
+
40
+ try {
41
+ const content = await fs.readFile(recentPath, 'utf-8');
42
+ res.json(JSON.parse(content));
43
+ } catch {
44
+ res.json({ files: [], venvs: [] });
45
+ }
46
+ } catch (err) {
47
+ console.error('[system:recent]', err);
48
+ res.status(500).json({ error: err.message });
49
+ }
50
+ });
51
+
52
+ /**
53
+ * POST /api/system/recent
54
+ * Update recent files/venvs
55
+ */
56
+ router.post('/recent', async (req, res) => {
57
+ try {
58
+ const { files, venvs } = req.body;
59
+
60
+ const configDir = path.join(os.homedir(), '.config', 'mrmd');
61
+ await fs.mkdir(configDir, { recursive: true });
62
+
63
+ const recentPath = path.join(configDir, 'recent.json');
64
+
65
+ // Read existing
66
+ let existing = { files: [], venvs: [] };
67
+ try {
68
+ const content = await fs.readFile(recentPath, 'utf-8');
69
+ existing = JSON.parse(content);
70
+ } catch {}
71
+
72
+ // Merge
73
+ if (files) {
74
+ existing.files = [...new Set([...files, ...existing.files])].slice(0, 50);
75
+ }
76
+ if (venvs) {
77
+ existing.venvs = [...new Set([...venvs, ...existing.venvs])].slice(0, 20);
78
+ }
79
+
80
+ await fs.writeFile(recentPath, JSON.stringify(existing, null, 2));
81
+ res.json(existing);
82
+ } catch (err) {
83
+ console.error('[system:recent:update]', err);
84
+ res.status(500).json({ error: err.message });
85
+ }
86
+ });
87
+
88
+ /**
89
+ * GET /api/system/ai
90
+ * Get AI server info
91
+ * Mirrors: electronAPI.getAi()
92
+ */
93
+ router.get('/ai', (req, res) => {
94
+ res.json({
95
+ port: ctx.aiPort,
96
+ url: `http://localhost:${ctx.aiPort}`,
97
+ });
98
+ });
99
+
100
+ /**
101
+ * POST /api/system/discover-venvs
102
+ * Discover virtual environments
103
+ * Mirrors: electronAPI.discoverVenvs(projectDir)
104
+ */
105
+ router.post('/discover-venvs', async (req, res) => {
106
+ try {
107
+ const { projectDir } = req.body;
108
+ const searchDir = projectDir || ctx.projectDir;
109
+
110
+ // Start async discovery
111
+ discoverVenvs(searchDir, ctx.eventBus);
112
+
113
+ res.json({ started: true, searchDir });
114
+ } catch (err) {
115
+ console.error('[system:discover-venvs]', err);
116
+ res.status(500).json({ error: err.message });
117
+ }
118
+ });
119
+
120
+ /**
121
+ * POST /api/system/install-mrmd-python
122
+ * Install mrmd-python in a venv
123
+ * Mirrors: electronAPI.installMrmdPython(venvPath)
124
+ */
125
+ router.post('/install-mrmd-python', async (req, res) => {
126
+ try {
127
+ const { venvPath } = req.body;
128
+ if (!venvPath) {
129
+ return res.status(400).json({ error: 'venvPath required' });
130
+ }
131
+
132
+ const resolvedPath = path.resolve(ctx.projectDir, venvPath);
133
+ const pipPath = path.join(resolvedPath, 'bin', 'pip');
134
+
135
+ // Install dependencies
136
+ const deps = ['ipython', 'starlette', 'uvicorn', 'sse-starlette'];
137
+
138
+ const proc = spawn('uv', ['pip', 'install', '--python', path.join(resolvedPath, 'bin', 'python'), ...deps], {
139
+ stdio: ['pipe', 'pipe', 'pipe'],
140
+ });
141
+
142
+ let stdout = '';
143
+ let stderr = '';
144
+
145
+ proc.stdout.on('data', (data) => { stdout += data; });
146
+ proc.stderr.on('data', (data) => { stderr += data; });
147
+
148
+ await new Promise((resolve, reject) => {
149
+ proc.on('close', (code) => {
150
+ if (code === 0) resolve();
151
+ else reject(new Error(`Install failed: ${stderr}`));
152
+ });
153
+ });
154
+
155
+ res.json({ success: true, output: stdout });
156
+ } catch (err) {
157
+ console.error('[system:install-mrmd-python]', err);
158
+ res.status(500).json({ error: err.message });
159
+ }
160
+ });
161
+
162
+ /**
163
+ * Shell operations (stubs for browser)
164
+ */
165
+ router.post('/shell/show-in-folder', (req, res) => {
166
+ // Can't do this in browser - return the path so UI can display it
167
+ res.json({
168
+ success: false,
169
+ message: 'Not available in browser mode',
170
+ path: req.body.path,
171
+ });
172
+ });
173
+
174
+ router.post('/shell/open-external', (req, res) => {
175
+ // Return URL so browser can window.open() it
176
+ res.json({
177
+ success: true,
178
+ url: req.body.url,
179
+ action: 'window.open',
180
+ });
181
+ });
182
+
183
+ router.post('/shell/open-path', (req, res) => {
184
+ // Can't open local files from browser
185
+ res.json({
186
+ success: false,
187
+ message: 'Not available in browser mode',
188
+ path: req.body.path,
189
+ });
190
+ });
191
+
192
+ return router;
193
+ }
194
+
195
+ /**
196
+ * Async venv discovery
197
+ */
198
+ async function discoverVenvs(searchDir, eventBus, maxDepth = 4, currentDepth = 0) {
199
+ if (currentDepth > maxDepth) return;
200
+
201
+ try {
202
+ const entries = await fs.readdir(searchDir, { withFileTypes: true });
203
+
204
+ for (const entry of entries) {
205
+ if (!entry.isDirectory()) continue;
206
+ if (entry.name.startsWith('.') && entry.name !== '.venv') continue;
207
+ if (entry.name === 'node_modules') continue;
208
+ if (entry.name === '__pycache__') continue;
209
+
210
+ const fullPath = path.join(searchDir, entry.name);
211
+
212
+ // Check if this is a venv
213
+ const activatePath = path.join(fullPath, 'bin', 'activate');
214
+ try {
215
+ await fs.access(activatePath);
216
+
217
+ // Found a venv!
218
+ const pythonPath = path.join(fullPath, 'bin', 'python');
219
+ let version = 'unknown';
220
+
221
+ try {
222
+ const proc = spawn(pythonPath, ['--version'], {
223
+ stdio: ['pipe', 'pipe', 'pipe'],
224
+ });
225
+
226
+ let output = '';
227
+ proc.stdout.on('data', (data) => { output += data; });
228
+ proc.stderr.on('data', (data) => { output += data; });
229
+
230
+ await new Promise((resolve) => proc.on('close', resolve));
231
+ version = output.trim().replace('Python ', '');
232
+ } catch {}
233
+
234
+ eventBus.venvFound({
235
+ path: fullPath,
236
+ name: entry.name,
237
+ python: pythonPath,
238
+ version,
239
+ });
240
+
241
+ // Don't recurse into venvs
242
+ continue;
243
+ } catch {}
244
+
245
+ // Recurse into directory
246
+ await discoverVenvs(fullPath, eventBus, maxDepth, currentDepth + 1);
247
+ }
248
+ } catch (err) {
249
+ console.error('[discover-venvs]', err.message);
250
+ }
251
+
252
+ // If this is the root call, emit done
253
+ if (currentDepth === 0) {
254
+ eventBus.venvScanDone();
255
+ }
256
+ }
package/src/auth.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Token-based authentication
3
+ */
4
+
5
+ import crypto from 'crypto';
6
+
7
+ /**
8
+ * Generate a random token
9
+ * @returns {string}
10
+ */
11
+ export function generateToken() {
12
+ return crypto.randomBytes(24).toString('base64url');
13
+ }
14
+
15
+ /**
16
+ * Create authentication middleware
17
+ * @param {string} validToken - The valid token
18
+ * @param {boolean} noAuth - If true, skip auth
19
+ */
20
+ export function createAuthMiddleware(validToken, noAuth = false) {
21
+ return (req, res, next) => {
22
+ if (noAuth) {
23
+ return next();
24
+ }
25
+
26
+ // Check for token in query string, header, or cookie
27
+ const token =
28
+ req.query.token ||
29
+ req.headers.authorization?.replace('Bearer ', '') ||
30
+ req.headers['x-token'] ||
31
+ req.cookies?.token;
32
+
33
+ if (!token) {
34
+ return res.status(401).json({
35
+ error: 'Authentication required',
36
+ message: 'Provide token via ?token=, Authorization header, or X-Token header',
37
+ });
38
+ }
39
+
40
+ if (token !== validToken) {
41
+ return res.status(403).json({
42
+ error: 'Invalid token',
43
+ });
44
+ }
45
+
46
+ next();
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Validate token for WebSocket connections
52
+ * @param {string} providedToken
53
+ * @param {string} validToken
54
+ * @param {boolean} noAuth
55
+ * @returns {boolean}
56
+ */
57
+ export function validateWsToken(providedToken, validToken, noAuth) {
58
+ if (noAuth) return true;
59
+ return providedToken === validToken;
60
+ }