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.
@@ -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
@@ -130,6 +130,12 @@
130
130
 
131
131
  getAi: () => GET('/api/system/ai'),
132
132
 
133
+ // System info and uv management
134
+ system: {
135
+ info: () => GET('/api/system/info'),
136
+ ensureUv: () => POST('/api/system/ensure-uv', {}),
137
+ },
138
+
133
139
  // ========================================================================
134
140
  // Shell (stubs for browser)
135
141
  // ========================================================================
@@ -191,6 +197,9 @@
191
197
  // Python management
192
198
  // ========================================================================
193
199
 
200
+ createVenv: (venvPath) =>
201
+ POST('/api/system/create-venv', { venvPath }),
202
+
194
203
  installMrmdPython: (venvPath) =>
195
204
  POST('/api/system/install-mrmd-python', { venvPath }),
196
205
 
@@ -212,11 +221,24 @@
212
221
  // ========================================================================
213
222
 
214
223
  openFile: async (filePath) => {
215
- // This was used to open a file and get session info
216
- // We'll get project info and session info separately
224
+ // Get project info and session info
217
225
  const project = await window.electronAPI.project.get(filePath);
218
226
  const session = await window.electronAPI.session.forDocument(filePath);
219
- return { project, session };
227
+
228
+ // Extract filename without extension for docName
229
+ const fileName = filePath.split('/').pop();
230
+ const docName = fileName.replace(/\.md$/, '');
231
+
232
+ // Use syncPort from project response (dynamically assigned per-project)
233
+ const syncPort = project?.syncPort || 4444;
234
+
235
+ return {
236
+ success: true,
237
+ syncPort,
238
+ docName,
239
+ projectDir: project?.root || filePath.split('/').slice(0, -1).join('/'),
240
+ pythonPort: session?.pythonPort || null,
241
+ };
220
242
  },
221
243
 
222
244
  // ========================================================================
@@ -280,6 +302,144 @@
280
302
  forDocument: (documentPath) => POST('/api/bash/for-document', { documentPath }),
281
303
  },
282
304
 
305
+ // ========================================================================
306
+ // JULIA SESSION SERVICE
307
+ // ========================================================================
308
+
309
+ julia: {
310
+ list: () => GET('/api/julia'),
311
+
312
+ start: (config) => POST('/api/julia', { config }),
313
+
314
+ stop: (sessionName) => DELETE(`/api/julia/${encodeURIComponent(sessionName)}`),
315
+
316
+ restart: (sessionName) => POST(`/api/julia/${encodeURIComponent(sessionName)}/restart`, {}),
317
+
318
+ forDocument: (documentPath) => POST('/api/julia/for-document', { documentPath }),
319
+
320
+ isAvailable: () => GET('/api/julia/available').then(r => r.available),
321
+ },
322
+
323
+ // ========================================================================
324
+ // PTY SESSION SERVICE (for ```term blocks)
325
+ // ========================================================================
326
+
327
+ pty: {
328
+ list: () => GET('/api/pty'),
329
+
330
+ start: (config) => POST('/api/pty', { config }),
331
+
332
+ stop: (sessionName) => DELETE(`/api/pty/${encodeURIComponent(sessionName)}`),
333
+
334
+ restart: (sessionName) => POST(`/api/pty/${encodeURIComponent(sessionName)}/restart`, {}),
335
+
336
+ forDocument: (documentPath) => POST('/api/pty/for-document', { documentPath }),
337
+ },
338
+
339
+ // ========================================================================
340
+ // NOTEBOOK (JUPYTER) SERVICE
341
+ // ========================================================================
342
+
343
+ notebook: {
344
+ convert: (ipynbPath) => POST('/api/notebook/convert', { ipynbPath }),
345
+
346
+ startSync: (ipynbPath) => POST('/api/notebook/start-sync', { ipynbPath }),
347
+
348
+ stopSync: (ipynbPath) => POST('/api/notebook/stop-sync', { ipynbPath }),
349
+ },
350
+
351
+ // ========================================================================
352
+ // R SESSION SERVICE
353
+ // ========================================================================
354
+
355
+ r: {
356
+ list: () => GET('/api/r'),
357
+
358
+ start: (config) => POST('/api/r', { config }),
359
+
360
+ stop: (sessionName) => DELETE(`/api/r/${encodeURIComponent(sessionName)}`),
361
+
362
+ restart: (sessionName) => POST(`/api/r/${encodeURIComponent(sessionName)}/restart`, {}),
363
+
364
+ forDocument: (documentPath) => POST('/api/r/for-document', { documentPath }),
365
+
366
+ isAvailable: () => GET('/api/r/available').then(r => r.available),
367
+ },
368
+
369
+ // ========================================================================
370
+ // SETTINGS SERVICE
371
+ // ========================================================================
372
+
373
+ settings: {
374
+ getAll: () => GET('/api/settings'),
375
+
376
+ get: (key, defaultValue) =>
377
+ GET(`/api/settings/key?path=${encodeURIComponent(key)}${defaultValue !== undefined ? `&default=${encodeURIComponent(defaultValue)}` : ''}`).then(r => r.value),
378
+
379
+ set: (key, value) =>
380
+ POST('/api/settings/key', { key, value }).then(r => r.success),
381
+
382
+ update: (updates) =>
383
+ POST('/api/settings/update', { updates }).then(r => r.success),
384
+
385
+ reset: () =>
386
+ POST('/api/settings/reset', {}).then(r => r.success),
387
+
388
+ getApiKeys: (masked = true) =>
389
+ GET(`/api/settings/api-keys?masked=${masked}`),
390
+
391
+ setApiKey: (provider, key) =>
392
+ POST('/api/settings/api-key', { provider, key }).then(r => r.success),
393
+
394
+ getApiKey: (provider) =>
395
+ GET(`/api/settings/api-key/${encodeURIComponent(provider)}`).then(r => r.key),
396
+
397
+ hasApiKey: (provider) =>
398
+ GET(`/api/settings/api-key/${encodeURIComponent(provider)}/exists`).then(r => r.hasKey),
399
+
400
+ getApiProviders: () =>
401
+ GET('/api/settings/api-providers'),
402
+
403
+ getQualityLevels: () =>
404
+ GET('/api/settings/quality-levels'),
405
+
406
+ setQualityLevelModel: (level, model) =>
407
+ POST(`/api/settings/quality-level/${level}/model`, { model }).then(r => r.success),
408
+
409
+ getCustomSections: () =>
410
+ GET('/api/settings/custom-sections'),
411
+
412
+ addCustomSection: (name) =>
413
+ POST('/api/settings/custom-section', { name }),
414
+
415
+ removeCustomSection: (sectionId) =>
416
+ DELETE(`/api/settings/custom-section/${encodeURIComponent(sectionId)}`).then(r => r.success),
417
+
418
+ addCustomCommand: (sectionId, command) =>
419
+ POST('/api/settings/custom-command', { sectionId, command }),
420
+
421
+ updateCustomCommand: (sectionId, commandId, updates) =>
422
+ apiCall('PUT', '/api/settings/custom-command', { sectionId, commandId, updates }).then(r => r.success),
423
+
424
+ removeCustomCommand: (sectionId, commandId) =>
425
+ apiCall('DELETE', '/api/settings/custom-command', { sectionId, commandId }).then(r => r.success),
426
+
427
+ getAllCustomCommands: () =>
428
+ GET('/api/settings/custom-commands'),
429
+
430
+ getDefaults: () =>
431
+ GET('/api/settings/defaults'),
432
+
433
+ setDefaults: (defaults) =>
434
+ POST('/api/settings/defaults', defaults).then(r => r.success),
435
+
436
+ export: (includeKeys = false) =>
437
+ GET(`/api/settings/export?includeKeys=${includeKeys}`).then(r => r.json),
438
+
439
+ import: (json, mergeKeys = false) =>
440
+ POST('/api/settings/import', { json, mergeKeys }).then(r => r.success),
441
+ },
442
+
283
443
  // ========================================================================
284
444
  // FILE SERVICE
285
445
  // ========================================================================
@@ -353,6 +513,15 @@
353
513
  // Remove existing handlers to prevent duplicates
354
514
  eventHandlers['sync-server-died'] = [callback];
355
515
  },
516
+
517
+ /**
518
+ * Register callback for OS "open with" events.
519
+ * In browser mode, this will never be called (no OS integration).
520
+ */
521
+ onOpenWithFile: (callback) => {
522
+ // No-op in browser mode - OS file associations don't exist
523
+ // Could potentially be triggered via URL parameters in the future
524
+ },
356
525
  };
357
526
 
358
527
  // ==========================================================================
package/static/index.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>mrmd</title>
7
+ <link rel="icon" type="image/png" href="/favicon.png">
7
8
 
8
9
  <!-- Load the HTTP shim BEFORE anything else -->
9
10
  <script src="/http-shim.js"></script>