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/src/api/system.js CHANGED
@@ -8,7 +8,8 @@ import { Router } from 'express';
8
8
  import os from 'os';
9
9
  import path from 'path';
10
10
  import fs from 'fs/promises';
11
- import { spawn } from 'child_process';
11
+ import { existsSync } from 'fs';
12
+ import { spawn, execSync } from 'child_process';
12
13
 
13
14
  /**
14
15
  * Create system routes
@@ -26,6 +27,105 @@ export function createSystemRoutes(ctx) {
26
27
  res.json({ homeDir: os.homedir() });
27
28
  });
28
29
 
30
+ /**
31
+ * GET /api/system/info
32
+ * Get system and app info including uv status
33
+ * Mirrors: electronAPI.system.info()
34
+ */
35
+ router.get('/info', async (req, res) => {
36
+ try {
37
+ // Check uv availability
38
+ let uvInfo = { installed: false };
39
+ try {
40
+ const uvVersion = execSync('uv --version', { encoding: 'utf-8' }).trim();
41
+ const uvPath = execSync('which uv', { encoding: 'utf-8' }).trim();
42
+ uvInfo = {
43
+ installed: true,
44
+ version: uvVersion.replace('uv ', ''),
45
+ path: uvPath,
46
+ };
47
+ } catch {}
48
+
49
+ // Get Node.js version
50
+ const nodeVersion = process.version;
51
+
52
+ res.json({
53
+ appVersion: '0.1.0',
54
+ platform: os.platform(),
55
+ arch: os.arch(),
56
+ nodeVersion,
57
+ pythonDeps: ['ipython', 'starlette', 'uvicorn', 'sse-starlette'],
58
+ uv: uvInfo,
59
+ serverMode: true, // Indicates this is running in server mode, not Electron
60
+ });
61
+ } catch (err) {
62
+ console.error('[system:info]', err);
63
+ res.status(500).json({ error: err.message });
64
+ }
65
+ });
66
+
67
+ /**
68
+ * POST /api/system/ensure-uv
69
+ * Ensure uv is installed (auto-install if missing)
70
+ * Mirrors: electronAPI.system.ensureUv()
71
+ */
72
+ router.post('/ensure-uv', async (req, res) => {
73
+ try {
74
+ // Check if uv is already installed
75
+ try {
76
+ const uvVersion = execSync('uv --version', { encoding: 'utf-8' }).trim();
77
+ const uvPath = execSync('which uv', { encoding: 'utf-8' }).trim();
78
+ return res.json({
79
+ success: true,
80
+ path: uvPath,
81
+ version: uvVersion.replace('uv ', ''),
82
+ alreadyInstalled: true,
83
+ });
84
+ } catch {}
85
+
86
+ // Try to install uv using the official installer
87
+ const installScript = 'curl -LsSf https://astral.sh/uv/install.sh | sh';
88
+
89
+ const proc = spawn('sh', ['-c', installScript], {
90
+ stdio: ['pipe', 'pipe', 'pipe'],
91
+ });
92
+
93
+ let stdout = '';
94
+ let stderr = '';
95
+
96
+ proc.stdout.on('data', (data) => { stdout += data; });
97
+ proc.stderr.on('data', (data) => { stderr += data; });
98
+
99
+ await new Promise((resolve, reject) => {
100
+ proc.on('close', (code) => {
101
+ if (code === 0) resolve();
102
+ else reject(new Error(`Install failed with code ${code}: ${stderr}`));
103
+ });
104
+ });
105
+
106
+ // Verify installation
107
+ const uvPath = path.join(os.homedir(), '.local', 'bin', 'uv');
108
+ if (existsSync(uvPath)) {
109
+ try {
110
+ const uvVersion = execSync(`${uvPath} --version`, { encoding: 'utf-8' }).trim();
111
+ return res.json({
112
+ success: true,
113
+ path: uvPath,
114
+ version: uvVersion.replace('uv ', ''),
115
+ });
116
+ } catch {}
117
+ }
118
+
119
+ res.json({
120
+ success: false,
121
+ error: 'Installation completed but uv not found',
122
+ });
123
+ } catch (err) {
124
+ console.error('[system:ensureUv]', err);
125
+ res.status(500).json({ success: false, error: err.message });
126
+ }
127
+ });
128
+
29
129
  /**
30
130
  * GET /api/system/recent
31
131
  * Get recent files and venvs
@@ -117,6 +217,104 @@ export function createSystemRoutes(ctx) {
117
217
  }
118
218
  });
119
219
 
220
+ /**
221
+ * POST /api/system/create-venv
222
+ * Create a new Python virtual environment
223
+ * Mirrors: electronAPI.createVenv(venvPath)
224
+ */
225
+ router.post('/create-venv', async (req, res) => {
226
+ try {
227
+ const { venvPath } = req.body;
228
+ if (!venvPath) {
229
+ return res.status(400).json({ error: 'venvPath required' });
230
+ }
231
+
232
+ const resolvedPath = path.resolve(venvPath);
233
+
234
+ // Check if venv already exists
235
+ if (existsSync(path.join(resolvedPath, 'bin', 'activate'))) {
236
+ return res.json({
237
+ success: true,
238
+ path: resolvedPath,
239
+ message: 'Virtual environment already exists',
240
+ });
241
+ }
242
+
243
+ // Create parent directory if needed
244
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
245
+
246
+ // Try uv first (faster)
247
+ let uvPath = null;
248
+ try {
249
+ uvPath = execSync('which uv', { encoding: 'utf-8' }).trim();
250
+ } catch {
251
+ // Check common locations
252
+ const uvLocations = [
253
+ path.join(os.homedir(), '.local', 'bin', 'uv'),
254
+ '/usr/local/bin/uv',
255
+ '/usr/bin/uv',
256
+ ];
257
+ for (const loc of uvLocations) {
258
+ if (existsSync(loc)) {
259
+ uvPath = loc;
260
+ break;
261
+ }
262
+ }
263
+ }
264
+
265
+ if (uvPath) {
266
+ // Use uv to create venv
267
+ const proc = spawn(uvPath, ['venv', resolvedPath], {
268
+ stdio: ['pipe', 'pipe', 'pipe'],
269
+ });
270
+
271
+ let stderr = '';
272
+ proc.stderr.on('data', (data) => { stderr += data; });
273
+
274
+ await new Promise((resolve, reject) => {
275
+ proc.on('close', (code) => {
276
+ if (code === 0) resolve();
277
+ else reject(new Error(`uv venv failed: ${stderr}`));
278
+ });
279
+ proc.on('error', reject);
280
+ });
281
+ } else {
282
+ // Fallback to python3 -m venv
283
+ const proc = spawn('python3', ['-m', 'venv', resolvedPath], {
284
+ stdio: ['pipe', 'pipe', 'pipe'],
285
+ });
286
+
287
+ let stderr = '';
288
+ proc.stderr.on('data', (data) => { stderr += data; });
289
+
290
+ await new Promise((resolve, reject) => {
291
+ proc.on('close', (code) => {
292
+ if (code === 0) resolve();
293
+ else reject(new Error(`python3 -m venv failed (code ${code}): ${stderr}`));
294
+ });
295
+ proc.on('error', reject);
296
+ });
297
+ }
298
+
299
+ // Verify creation
300
+ if (existsSync(path.join(resolvedPath, 'bin', 'activate'))) {
301
+ res.json({
302
+ success: true,
303
+ path: resolvedPath,
304
+ method: uvPath ? 'uv' : 'python3',
305
+ });
306
+ } else {
307
+ res.status(500).json({
308
+ success: false,
309
+ error: 'Virtual environment creation completed but activation script not found',
310
+ });
311
+ }
312
+ } catch (err) {
313
+ console.error('[system:create-venv]', err);
314
+ res.status(500).json({ success: false, error: err.message });
315
+ }
316
+ });
317
+
120
318
  /**
121
319
  * POST /api/system/install-mrmd-python
122
320
  * Install mrmd-python in a venv
package/src/server.js CHANGED
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Express server that mirrors Electron's electronAPI
3
+ *
4
+ * Uses services from mrmd-electron for full feature parity.
3
5
  */
4
6
 
5
7
  import express from 'express';
@@ -9,7 +11,10 @@ import { WebSocketServer } from 'ws';
9
11
  import path from 'path';
10
12
  import fs from 'fs/promises';
11
13
  import { existsSync } from 'fs';
12
- import { fileURLToPath } from 'url';
14
+ import { fileURLToPath, pathToFileURL } from 'url';
15
+ import { createRequire } from 'module';
16
+
17
+ const require = createRequire(import.meta.url);
13
18
 
14
19
  import { createAuthMiddleware, generateToken } from './auth.js';
15
20
  import { EventBus } from './events.js';
@@ -23,8 +28,33 @@ import { createRuntimeRoutes } from './api/runtime.js';
23
28
  import { createJuliaRoutes } from './api/julia.js';
24
29
  import { createPtyRoutes } from './api/pty.js';
25
30
  import { createNotebookRoutes } from './api/notebook.js';
31
+ import { createSettingsRoutes } from './api/settings.js';
32
+ import { createRRoutes } from './api/r.js';
26
33
  import { setupWebSocket } from './websocket.js';
27
34
 
35
+ // Import services from mrmd-electron (pure Node.js, no Electron deps)
36
+ import {
37
+ ProjectService,
38
+ SessionService,
39
+ BashSessionService,
40
+ RSessionService,
41
+ JuliaSessionService,
42
+ PtySessionService,
43
+ FileService,
44
+ AssetService,
45
+ SettingsService,
46
+ } from './services.js';
47
+
48
+ // Import sync manager for dynamic project handling
49
+ import {
50
+ acquireSyncServer,
51
+ releaseSyncServer,
52
+ getSyncServer,
53
+ listSyncServers,
54
+ stopAllSyncServers,
55
+ onSyncDeath,
56
+ } from './sync-manager.js';
57
+
28
58
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
59
 
30
60
  /**
@@ -59,28 +89,65 @@ export async function createServer(config) {
59
89
  aiPort = 51790,
60
90
  } = config;
61
91
 
92
+ // projectDir is optional now - dynamic project detection is supported
62
93
  if (!projectDir) {
63
- throw new Error('projectDir is required');
94
+ console.log('[server] No projectDir specified - dynamic project detection enabled');
64
95
  }
65
96
 
66
97
  const app = express();
67
98
  const server = createHttpServer(app);
68
99
  const eventBus = new EventBus();
69
100
 
101
+ // Instantiate services from mrmd-electron
102
+ const projectService = new ProjectService();
103
+ const sessionService = new SessionService();
104
+ const bashSessionService = new BashSessionService();
105
+ const rSessionService = new RSessionService();
106
+ const juliaSessionService = new JuliaSessionService();
107
+ const ptySessionService = new PtySessionService();
108
+ const fileService = new FileService();
109
+ const assetService = new AssetService();
110
+ const settingsService = new SettingsService();
111
+
70
112
  // Service context passed to all route handlers
71
113
  const context = {
72
- projectDir: path.resolve(projectDir),
114
+ // Legacy: fixed project dir (for backwards compat, may be null)
115
+ projectDir: projectDir ? path.resolve(projectDir) : null,
73
116
  syncPort,
74
117
  pythonPort,
75
118
  aiPort,
76
119
  eventBus,
77
- // These will be populated by services
120
+
121
+ // Services from mrmd-electron
122
+ projectService,
123
+ sessionService,
124
+ bashSessionService,
125
+ rSessionService,
126
+ juliaSessionService,
127
+ ptySessionService,
128
+ fileService,
129
+ assetService,
130
+ settingsService,
131
+
132
+ // Sync server management (dynamic per-project)
133
+ acquireSyncServer,
134
+ releaseSyncServer,
135
+ getSyncServer,
136
+ listSyncServers,
137
+
138
+ // Legacy: process tracking (kept for backwards compat)
78
139
  syncProcess: null,
79
140
  pythonProcess: null,
80
141
  monitorProcesses: new Map(),
81
142
  watchers: new Map(),
143
+ pythonReady: false,
82
144
  };
83
145
 
146
+ // Register for sync death notifications and broadcast via WebSocket
147
+ onSyncDeath((message) => {
148
+ eventBus.emit('sync-server-died', message);
149
+ });
150
+
84
151
  // Middleware
85
152
  app.use(cors({
86
153
  origin: true,
@@ -118,6 +185,8 @@ export async function createServer(config) {
118
185
  app.use('/api/julia', createJuliaRoutes(context));
119
186
  app.use('/api/pty', createPtyRoutes(context));
120
187
  app.use('/api/notebook', createNotebookRoutes(context));
188
+ app.use('/api/settings', createSettingsRoutes(context));
189
+ app.use('/api/r', createRRoutes(context));
121
190
 
122
191
  // Serve http-shim.js
123
192
  app.get('/http-shim.js', (req, res) => {
@@ -131,9 +200,13 @@ export async function createServer(config) {
131
200
  // Serve mrmd-electron assets (fonts, icons)
132
201
  app.use('/assets', express.static(path.join(electronPath, 'assets')));
133
202
 
134
- // Serve mrmd-editor dist
203
+ // Serve mrmd-editor dist (referenced as ../mrmd-editor/dist/ in index.html)
135
204
  const editorDistPath = path.join(electronPath, '../mrmd-editor/dist');
136
- app.use('/dist', express.static(editorDistPath));
205
+ app.use('/mrmd-editor/dist', express.static(editorDistPath));
206
+ app.use('/dist', express.static(editorDistPath)); // Also at /dist for compatibility
207
+
208
+ // Serve node_modules from mrmd-electron (for xterm, etc.)
209
+ app.use('/node_modules', express.static(path.join(electronPath, 'node_modules')));
137
210
 
138
211
  // Serve transformed index.html at root
139
212
  app.get('/', async (req, res) => {
@@ -144,6 +217,7 @@ export async function createServer(config) {
144
217
  // Transform for browser mode:
145
218
  // 1. Inject http-shim.js as first script in head
146
219
  // 2. Update CSP to allow HTTP connections to this server
220
+ // 3. Fix relative paths for HTTP serving
147
221
  html = transformIndexHtml(html, host, port);
148
222
 
149
223
  res.type('html').send(html);
@@ -211,7 +285,33 @@ export async function createServer(config) {
211
285
  await watcher.close();
212
286
  }
213
287
 
214
- // Kill child processes
288
+ // Stop all sync servers
289
+ stopAllSyncServers();
290
+
291
+ // Stop all sessions via services (if they have shutdown methods)
292
+ try {
293
+ if (typeof sessionService.shutdown === 'function') {
294
+ await sessionService.shutdown();
295
+ }
296
+ } catch (e) {
297
+ console.warn('[server] Error stopping sessions:', e.message);
298
+ }
299
+ try {
300
+ if (typeof bashSessionService.shutdown === 'function') {
301
+ await bashSessionService.shutdown();
302
+ }
303
+ } catch (e) {
304
+ console.warn('[server] Error stopping bash sessions:', e.message);
305
+ }
306
+ try {
307
+ if (typeof ptySessionService.shutdown === 'function') {
308
+ await ptySessionService.shutdown();
309
+ }
310
+ } catch (e) {
311
+ console.warn('[server] Error stopping pty sessions:', e.message);
312
+ }
313
+
314
+ // Legacy: kill child processes
215
315
  if (context.syncProcess) {
216
316
  context.syncProcess.kill();
217
317
  }
@@ -238,15 +338,26 @@ export async function createServer(config) {
238
338
  */
239
339
  function findElectronDir(fromDir) {
240
340
  const candidates = [
341
+ // Development: sibling directories
241
342
  path.join(fromDir, '../../mrmd-electron'),
242
343
  path.join(fromDir, '../../../mrmd-electron'),
243
344
  path.join(process.cwd(), '../mrmd-electron'),
244
345
  path.join(process.cwd(), 'mrmd-electron'),
245
- // In npx/installed context, it might be in node_modules
346
+ // npm/npx: node_modules relative to mrmd-server package
347
+ path.join(fromDir, '../node_modules/mrmd-electron'),
246
348
  path.join(fromDir, '../../node_modules/mrmd-electron'),
349
+ // npm/npx: node_modules in cwd
247
350
  path.join(process.cwd(), 'node_modules/mrmd-electron'),
248
351
  ];
249
352
 
353
+ // Also try require.resolve to find the package
354
+ try {
355
+ const electronPkg = path.dirname(require.resolve('mrmd-electron/package.json'));
356
+ candidates.unshift(electronPkg);
357
+ } catch (e) {
358
+ // mrmd-electron not found via require, continue with path search
359
+ }
360
+
250
361
  for (const candidate of candidates) {
251
362
  const indexPath = path.join(candidate, 'index.html');
252
363
  if (existsSync(indexPath)) {
@@ -261,6 +372,7 @@ function findElectronDir(fromDir) {
261
372
  * Transform index.html for browser mode
262
373
  * - Inject http-shim.js as first script
263
374
  * - Update CSP to allow HTTP connections
375
+ * - Fix relative paths for HTTP serving
264
376
  */
265
377
  function transformIndexHtml(html, host, port) {
266
378
  // 1. Inject http-shim.js right after <head>
@@ -283,6 +395,19 @@ function transformIndexHtml(html, host, port) {
283
395
  html = html.replace(/-webkit-app-region:\s*drag;/g, '/* -webkit-app-region: drag; */');
284
396
  html = html.replace(/-webkit-app-region:\s*no-drag;/g, '/* -webkit-app-region: no-drag; */');
285
397
 
398
+ // 4. Fix relative paths for HTTP serving
399
+ // ../mrmd-editor/dist/ -> /mrmd-editor/dist/
400
+ html = html.replace(/src=["']\.\.\/mrmd-editor\//g, 'src="/mrmd-editor/');
401
+ html = html.replace(/href=["']\.\.\/mrmd-editor\//g, 'href="/mrmd-editor/');
402
+
403
+ // ./node_modules/ -> /node_modules/
404
+ html = html.replace(/src=["']\.\/node_modules\//g, 'src="/node_modules/');
405
+ html = html.replace(/href=["']\.\/node_modules\//g, 'href="/node_modules/');
406
+
407
+ // ./assets/ -> /assets/
408
+ html = html.replace(/src=["']\.\/assets\//g, 'src="/assets/');
409
+ html = html.replace(/href=["']\.\/assets\//g, 'href="/assets/');
410
+
286
411
  return html;
287
412
  }
288
413
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Re-export services from mrmd-electron
3
+ *
4
+ * These services are pure Node.js (no Electron dependencies)
5
+ * and can be used directly by mrmd-server.
6
+ */
7
+
8
+ export {
9
+ default as ProjectService,
10
+ } from 'mrmd-electron/src/services/project-service.js';
11
+
12
+ export {
13
+ default as SessionService,
14
+ } from 'mrmd-electron/src/services/session-service.js';
15
+
16
+ export {
17
+ default as BashSessionService,
18
+ } from 'mrmd-electron/src/services/bash-session-service.js';
19
+
20
+ export {
21
+ default as RSessionService,
22
+ } from 'mrmd-electron/src/services/r-session-service.js';
23
+
24
+ export {
25
+ default as JuliaSessionService,
26
+ } from 'mrmd-electron/src/services/julia-session-service.js';
27
+
28
+ export {
29
+ default as PtySessionService,
30
+ } from 'mrmd-electron/src/services/pty-session-service.js';
31
+
32
+ export {
33
+ default as FileService,
34
+ } from 'mrmd-electron/src/services/file-service.js';
35
+
36
+ export {
37
+ default as AssetService,
38
+ } from 'mrmd-electron/src/services/asset-service.js';
39
+
40
+ export {
41
+ default as SettingsService,
42
+ } from 'mrmd-electron/src/services/settings-service.js';