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/README.md +230 -0
- package/bin/cli.js +161 -0
- package/package.json +35 -0
- package/src/api/asset.js +283 -0
- package/src/api/bash.js +293 -0
- package/src/api/file.js +407 -0
- package/src/api/index.js +11 -0
- package/src/api/julia.js +345 -0
- package/src/api/project.js +296 -0
- package/src/api/pty.js +401 -0
- package/src/api/runtime.js +140 -0
- package/src/api/session.js +358 -0
- package/src/api/system.js +256 -0
- package/src/auth.js +60 -0
- package/src/events.js +50 -0
- package/src/index.js +9 -0
- package/src/server-v2.js +118 -0
- package/src/server.js +297 -0
- package/src/websocket.js +85 -0
- package/static/http-shim.js +371 -0
- package/static/index.html +171 -0
|
@@ -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
|
+
}
|