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/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';
@@ -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
- throw new Error('projectDir is required');
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
- projectDir: path.resolve(projectDir),
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
- // These will be populated by services
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
- // Kill child processes
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
 
@@ -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