mrmd-server 0.1.0 → 0.1.1
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 +117 -6
- 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/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 {
|
|
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';
|
|
@@ -23,8 +25,33 @@ import { createRuntimeRoutes } from './api/runtime.js';
|
|
|
23
25
|
import { createJuliaRoutes } from './api/julia.js';
|
|
24
26
|
import { createPtyRoutes } from './api/pty.js';
|
|
25
27
|
import { createNotebookRoutes } from './api/notebook.js';
|
|
28
|
+
import { createSettingsRoutes } from './api/settings.js';
|
|
29
|
+
import { createRRoutes } from './api/r.js';
|
|
26
30
|
import { setupWebSocket } from './websocket.js';
|
|
27
31
|
|
|
32
|
+
// Import services from mrmd-electron (pure Node.js, no Electron deps)
|
|
33
|
+
import {
|
|
34
|
+
ProjectService,
|
|
35
|
+
SessionService,
|
|
36
|
+
BashSessionService,
|
|
37
|
+
RSessionService,
|
|
38
|
+
JuliaSessionService,
|
|
39
|
+
PtySessionService,
|
|
40
|
+
FileService,
|
|
41
|
+
AssetService,
|
|
42
|
+
SettingsService,
|
|
43
|
+
} from './services.js';
|
|
44
|
+
|
|
45
|
+
// Import sync manager for dynamic project handling
|
|
46
|
+
import {
|
|
47
|
+
acquireSyncServer,
|
|
48
|
+
releaseSyncServer,
|
|
49
|
+
getSyncServer,
|
|
50
|
+
listSyncServers,
|
|
51
|
+
stopAllSyncServers,
|
|
52
|
+
onSyncDeath,
|
|
53
|
+
} from './sync-manager.js';
|
|
54
|
+
|
|
28
55
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
56
|
|
|
30
57
|
/**
|
|
@@ -59,28 +86,65 @@ export async function createServer(config) {
|
|
|
59
86
|
aiPort = 51790,
|
|
60
87
|
} = config;
|
|
61
88
|
|
|
89
|
+
// projectDir is optional now - dynamic project detection is supported
|
|
62
90
|
if (!projectDir) {
|
|
63
|
-
|
|
91
|
+
console.log('[server] No projectDir specified - dynamic project detection enabled');
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
const app = express();
|
|
67
95
|
const server = createHttpServer(app);
|
|
68
96
|
const eventBus = new EventBus();
|
|
69
97
|
|
|
98
|
+
// Instantiate services from mrmd-electron
|
|
99
|
+
const projectService = new ProjectService();
|
|
100
|
+
const sessionService = new SessionService();
|
|
101
|
+
const bashSessionService = new BashSessionService();
|
|
102
|
+
const rSessionService = new RSessionService();
|
|
103
|
+
const juliaSessionService = new JuliaSessionService();
|
|
104
|
+
const ptySessionService = new PtySessionService();
|
|
105
|
+
const fileService = new FileService();
|
|
106
|
+
const assetService = new AssetService();
|
|
107
|
+
const settingsService = new SettingsService();
|
|
108
|
+
|
|
70
109
|
// Service context passed to all route handlers
|
|
71
110
|
const context = {
|
|
72
|
-
|
|
111
|
+
// Legacy: fixed project dir (for backwards compat, may be null)
|
|
112
|
+
projectDir: projectDir ? path.resolve(projectDir) : null,
|
|
73
113
|
syncPort,
|
|
74
114
|
pythonPort,
|
|
75
115
|
aiPort,
|
|
76
116
|
eventBus,
|
|
77
|
-
|
|
117
|
+
|
|
118
|
+
// Services from mrmd-electron
|
|
119
|
+
projectService,
|
|
120
|
+
sessionService,
|
|
121
|
+
bashSessionService,
|
|
122
|
+
rSessionService,
|
|
123
|
+
juliaSessionService,
|
|
124
|
+
ptySessionService,
|
|
125
|
+
fileService,
|
|
126
|
+
assetService,
|
|
127
|
+
settingsService,
|
|
128
|
+
|
|
129
|
+
// Sync server management (dynamic per-project)
|
|
130
|
+
acquireSyncServer,
|
|
131
|
+
releaseSyncServer,
|
|
132
|
+
getSyncServer,
|
|
133
|
+
listSyncServers,
|
|
134
|
+
|
|
135
|
+
// Legacy: process tracking (kept for backwards compat)
|
|
78
136
|
syncProcess: null,
|
|
79
137
|
pythonProcess: null,
|
|
80
138
|
monitorProcesses: new Map(),
|
|
81
139
|
watchers: new Map(),
|
|
140
|
+
pythonReady: false,
|
|
82
141
|
};
|
|
83
142
|
|
|
143
|
+
// Register for sync death notifications and broadcast via WebSocket
|
|
144
|
+
onSyncDeath((message) => {
|
|
145
|
+
eventBus.emit('sync-server-died', message);
|
|
146
|
+
});
|
|
147
|
+
|
|
84
148
|
// Middleware
|
|
85
149
|
app.use(cors({
|
|
86
150
|
origin: true,
|
|
@@ -118,6 +182,8 @@ export async function createServer(config) {
|
|
|
118
182
|
app.use('/api/julia', createJuliaRoutes(context));
|
|
119
183
|
app.use('/api/pty', createPtyRoutes(context));
|
|
120
184
|
app.use('/api/notebook', createNotebookRoutes(context));
|
|
185
|
+
app.use('/api/settings', createSettingsRoutes(context));
|
|
186
|
+
app.use('/api/r', createRRoutes(context));
|
|
121
187
|
|
|
122
188
|
// Serve http-shim.js
|
|
123
189
|
app.get('/http-shim.js', (req, res) => {
|
|
@@ -131,9 +197,13 @@ export async function createServer(config) {
|
|
|
131
197
|
// Serve mrmd-electron assets (fonts, icons)
|
|
132
198
|
app.use('/assets', express.static(path.join(electronPath, 'assets')));
|
|
133
199
|
|
|
134
|
-
// Serve mrmd-editor dist
|
|
200
|
+
// Serve mrmd-editor dist (referenced as ../mrmd-editor/dist/ in index.html)
|
|
135
201
|
const editorDistPath = path.join(electronPath, '../mrmd-editor/dist');
|
|
136
|
-
app.use('/dist', express.static(editorDistPath));
|
|
202
|
+
app.use('/mrmd-editor/dist', express.static(editorDistPath));
|
|
203
|
+
app.use('/dist', express.static(editorDistPath)); // Also at /dist for compatibility
|
|
204
|
+
|
|
205
|
+
// Serve node_modules from mrmd-electron (for xterm, etc.)
|
|
206
|
+
app.use('/node_modules', express.static(path.join(electronPath, 'node_modules')));
|
|
137
207
|
|
|
138
208
|
// Serve transformed index.html at root
|
|
139
209
|
app.get('/', async (req, res) => {
|
|
@@ -144,6 +214,7 @@ export async function createServer(config) {
|
|
|
144
214
|
// Transform for browser mode:
|
|
145
215
|
// 1. Inject http-shim.js as first script in head
|
|
146
216
|
// 2. Update CSP to allow HTTP connections to this server
|
|
217
|
+
// 3. Fix relative paths for HTTP serving
|
|
147
218
|
html = transformIndexHtml(html, host, port);
|
|
148
219
|
|
|
149
220
|
res.type('html').send(html);
|
|
@@ -211,7 +282,33 @@ export async function createServer(config) {
|
|
|
211
282
|
await watcher.close();
|
|
212
283
|
}
|
|
213
284
|
|
|
214
|
-
//
|
|
285
|
+
// Stop all sync servers
|
|
286
|
+
stopAllSyncServers();
|
|
287
|
+
|
|
288
|
+
// Stop all sessions via services (if they have shutdown methods)
|
|
289
|
+
try {
|
|
290
|
+
if (typeof sessionService.shutdown === 'function') {
|
|
291
|
+
await sessionService.shutdown();
|
|
292
|
+
}
|
|
293
|
+
} catch (e) {
|
|
294
|
+
console.warn('[server] Error stopping sessions:', e.message);
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
if (typeof bashSessionService.shutdown === 'function') {
|
|
298
|
+
await bashSessionService.shutdown();
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
console.warn('[server] Error stopping bash sessions:', e.message);
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
if (typeof ptySessionService.shutdown === 'function') {
|
|
305
|
+
await ptySessionService.shutdown();
|
|
306
|
+
}
|
|
307
|
+
} catch (e) {
|
|
308
|
+
console.warn('[server] Error stopping pty sessions:', e.message);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Legacy: kill child processes
|
|
215
312
|
if (context.syncProcess) {
|
|
216
313
|
context.syncProcess.kill();
|
|
217
314
|
}
|
|
@@ -261,6 +358,7 @@ function findElectronDir(fromDir) {
|
|
|
261
358
|
* Transform index.html for browser mode
|
|
262
359
|
* - Inject http-shim.js as first script
|
|
263
360
|
* - Update CSP to allow HTTP connections
|
|
361
|
+
* - Fix relative paths for HTTP serving
|
|
264
362
|
*/
|
|
265
363
|
function transformIndexHtml(html, host, port) {
|
|
266
364
|
// 1. Inject http-shim.js right after <head>
|
|
@@ -283,6 +381,19 @@ function transformIndexHtml(html, host, port) {
|
|
|
283
381
|
html = html.replace(/-webkit-app-region:\s*drag;/g, '/* -webkit-app-region: drag; */');
|
|
284
382
|
html = html.replace(/-webkit-app-region:\s*no-drag;/g, '/* -webkit-app-region: no-drag; */');
|
|
285
383
|
|
|
384
|
+
// 4. Fix relative paths for HTTP serving
|
|
385
|
+
// ../mrmd-editor/dist/ -> /mrmd-editor/dist/
|
|
386
|
+
html = html.replace(/src=["']\.\.\/mrmd-editor\//g, 'src="/mrmd-editor/');
|
|
387
|
+
html = html.replace(/href=["']\.\.\/mrmd-editor\//g, 'href="/mrmd-editor/');
|
|
388
|
+
|
|
389
|
+
// ./node_modules/ -> /node_modules/
|
|
390
|
+
html = html.replace(/src=["']\.\/node_modules\//g, 'src="/node_modules/');
|
|
391
|
+
html = html.replace(/href=["']\.\/node_modules\//g, 'href="/node_modules/');
|
|
392
|
+
|
|
393
|
+
// ./assets/ -> /assets/
|
|
394
|
+
html = html.replace(/src=["']\.\/assets\//g, 'src="/assets/');
|
|
395
|
+
html = html.replace(/href=["']\.\/assets\//g, 'href="/assets/');
|
|
396
|
+
|
|
286
397
|
return html;
|
|
287
398
|
}
|
|
288
399
|
|
package/src/services.js
ADDED
|
@@ -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';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Server Manager for mrmd-server
|
|
3
|
+
*
|
|
4
|
+
* Ported from mrmd-electron/main.js to provide dynamic per-project sync servers.
|
|
5
|
+
* Allows mrmd-server to handle files from any project, not just a fixed projectDir.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
// Import utilities from mrmd-electron
|
|
16
|
+
import { findFreePort, waitForPort, isProcessAlive } from 'mrmd-electron/src/utils/index.js';
|
|
17
|
+
import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from 'mrmd-electron/src/config.js';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
|
|
22
|
+
// Track active sync servers by directory hash
|
|
23
|
+
const syncServers = new Map();
|
|
24
|
+
|
|
25
|
+
// Event listeners for sync death notifications
|
|
26
|
+
const syncDeathListeners = new Set();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hash a directory path to a short, filesystem-safe string
|
|
30
|
+
*/
|
|
31
|
+
function computeDirHash(dir) {
|
|
32
|
+
return crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, DIR_HASH_LENGTH);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the path to an mrmd package's CLI script
|
|
37
|
+
* In dev mode: Returns path to source CLI in sibling directory
|
|
38
|
+
*/
|
|
39
|
+
function resolvePackageBin(packageName, binPath) {
|
|
40
|
+
// Try sibling directory (for monorepo development)
|
|
41
|
+
const siblingPath = path.join(path.dirname(path.dirname(__dirname)), packageName, binPath);
|
|
42
|
+
if (fs.existsSync(siblingPath)) {
|
|
43
|
+
return siblingPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try node_modules
|
|
47
|
+
try {
|
|
48
|
+
const packageJson = path.dirname(require.resolve(`${packageName}/package.json`));
|
|
49
|
+
return path.join(packageJson, binPath);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Fallback for ESM - look relative to mrmd-server
|
|
52
|
+
const nmPath = path.join(__dirname, '..', 'node_modules', packageName, binPath);
|
|
53
|
+
if (fs.existsSync(nmPath)) {
|
|
54
|
+
return nmPath;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Cannot resolve ${packageName}: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Notify all registered listeners that a sync server died
|
|
62
|
+
*/
|
|
63
|
+
function notifySyncDied(projectDir, exitCode, signal) {
|
|
64
|
+
const message = {
|
|
65
|
+
projectDir,
|
|
66
|
+
exitCode,
|
|
67
|
+
signal,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
reason: exitCode === null ? 'crashed (likely OOM)' : `exited with code ${exitCode}`,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
console.error(`[sync] CRITICAL: Sync server died for ${projectDir}:`, message.reason);
|
|
73
|
+
|
|
74
|
+
for (const listener of syncDeathListeners) {
|
|
75
|
+
try {
|
|
76
|
+
listener(message);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('[sync] Error in death listener:', e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Register a listener to be notified when any sync server dies
|
|
85
|
+
* @param {Function} listener - Called with {projectDir, exitCode, signal, timestamp, reason}
|
|
86
|
+
* @returns {Function} Unsubscribe function
|
|
87
|
+
*/
|
|
88
|
+
export function onSyncDeath(listener) {
|
|
89
|
+
syncDeathListeners.add(listener);
|
|
90
|
+
return () => syncDeathListeners.delete(listener);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get or start a sync server for a project directory
|
|
95
|
+
* Uses reference counting so multiple documents can share a sync server
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectDir - The project directory to sync
|
|
98
|
+
* @returns {Promise<{port: number, dir: string, refCount: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export async function acquireSyncServer(projectDir) {
|
|
101
|
+
const dirHash = computeDirHash(projectDir);
|
|
102
|
+
|
|
103
|
+
// Reuse existing server if available
|
|
104
|
+
if (syncServers.has(dirHash)) {
|
|
105
|
+
const server = syncServers.get(dirHash);
|
|
106
|
+
server.refCount++;
|
|
107
|
+
console.log(`[sync] Reusing server for ${projectDir} on port ${server.port} (refCount: ${server.refCount})`);
|
|
108
|
+
return server;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for existing server from a PID file (in case of restart)
|
|
112
|
+
const syncStatePath = path.join(os.tmpdir(), `mrmd-sync-${dirHash}`, 'server.pid');
|
|
113
|
+
try {
|
|
114
|
+
if (fs.existsSync(syncStatePath)) {
|
|
115
|
+
const pidData = JSON.parse(fs.readFileSync(syncStatePath, 'utf8'));
|
|
116
|
+
if (isProcessAlive(pidData.pid)) {
|
|
117
|
+
console.log(`[sync] Found existing server on port ${pidData.port}`);
|
|
118
|
+
const server = { proc: null, port: pidData.port, dir: projectDir, refCount: 1, owned: false };
|
|
119
|
+
syncServers.set(dirHash, server);
|
|
120
|
+
return server;
|
|
121
|
+
} else {
|
|
122
|
+
fs.unlinkSync(syncStatePath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// Ignore errors reading PID file
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Start a new sync server
|
|
130
|
+
const port = await findFreePort();
|
|
131
|
+
console.log(`[sync] Starting server for ${projectDir} on port ${port}...`);
|
|
132
|
+
|
|
133
|
+
const syncCliPath = resolvePackageBin('mrmd-sync', 'bin/cli.js');
|
|
134
|
+
const nodeArgs = [
|
|
135
|
+
`--max-old-space-size=${SYNC_SERVER_MEMORY_MB}`,
|
|
136
|
+
syncCliPath,
|
|
137
|
+
'--port', port.toString(),
|
|
138
|
+
'--i-know-what-i-am-doing',
|
|
139
|
+
projectDir,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const proc = spawn('node', nodeArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
143
|
+
proc.expectedExit = false;
|
|
144
|
+
|
|
145
|
+
proc.stdout.on('data', (d) => console.log(`[sync:${port}]`, d.toString().trim()));
|
|
146
|
+
proc.stderr.on('data', (d) => console.error(`[sync:${port}]`, d.toString().trim()));
|
|
147
|
+
|
|
148
|
+
// Handle unexpected exits (data loss prevention)
|
|
149
|
+
proc.on('exit', (code, signal) => {
|
|
150
|
+
console.log(`[sync:${port}] Exited with code ${code}, signal ${signal}`);
|
|
151
|
+
syncServers.delete(dirHash);
|
|
152
|
+
|
|
153
|
+
if (!proc.expectedExit) {
|
|
154
|
+
notifySyncDied(projectDir, code, signal);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitForPort(port);
|
|
159
|
+
|
|
160
|
+
const server = { proc, port, dir: projectDir, refCount: 1, owned: true };
|
|
161
|
+
syncServers.set(dirHash, server);
|
|
162
|
+
return server;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Release a sync server reference
|
|
167
|
+
* If refCount reaches 0, the server is stopped
|
|
168
|
+
*
|
|
169
|
+
* @param {string} projectDir - The project directory
|
|
170
|
+
*/
|
|
171
|
+
export function releaseSyncServer(projectDir) {
|
|
172
|
+
const dirHash = computeDirHash(projectDir);
|
|
173
|
+
const server = syncServers.get(dirHash);
|
|
174
|
+
if (!server) return;
|
|
175
|
+
|
|
176
|
+
server.refCount--;
|
|
177
|
+
console.log(`[sync] Released server for ${projectDir} (refCount: ${server.refCount})`);
|
|
178
|
+
|
|
179
|
+
if (server.refCount <= 0 && server.owned && server.proc) {
|
|
180
|
+
console.log(`[sync] Stopping server for ${projectDir}`);
|
|
181
|
+
server.proc.expectedExit = true;
|
|
182
|
+
server.proc.kill('SIGTERM');
|
|
183
|
+
syncServers.delete(dirHash);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the sync server for a project if one is running
|
|
189
|
+
*
|
|
190
|
+
* @param {string} projectDir - The project directory
|
|
191
|
+
* @returns {Object|null} The server info or null
|
|
192
|
+
*/
|
|
193
|
+
export function getSyncServer(projectDir) {
|
|
194
|
+
const dirHash = computeDirHash(projectDir);
|
|
195
|
+
return syncServers.get(dirHash) || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List all active sync servers
|
|
200
|
+
* @returns {Array<{dir: string, port: number, refCount: number, owned: boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
export function listSyncServers() {
|
|
203
|
+
return Array.from(syncServers.values()).map(s => ({
|
|
204
|
+
dir: s.dir,
|
|
205
|
+
port: s.port,
|
|
206
|
+
refCount: s.refCount,
|
|
207
|
+
owned: s.owned,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stop all sync servers (for shutdown)
|
|
213
|
+
*/
|
|
214
|
+
export function stopAllSyncServers() {
|
|
215
|
+
for (const [hash, server] of syncServers) {
|
|
216
|
+
if (server.owned && server.proc) {
|
|
217
|
+
console.log(`[sync] Stopping server for ${server.dir}`);
|
|
218
|
+
server.proc.expectedExit = true;
|
|
219
|
+
server.proc.kill('SIGTERM');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
syncServers.clear();
|
|
223
|
+
}
|
|
Binary file
|