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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Julia Session API routes
3
+ *
4
+ * Mirrors electronAPI.julia.*
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, cwd }
14
+ const sessions = new Map();
15
+
16
+ /**
17
+ * Create Julia routes
18
+ * @param {import('../server.js').ServerContext} ctx
19
+ */
20
+ export function createJuliaRoutes(ctx) {
21
+ const router = Router();
22
+
23
+ /**
24
+ * GET /api/julia
25
+ * List all running Julia sessions
26
+ * Mirrors: electronAPI.julia.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
+ cwd: session.cwd,
36
+ running: session.process && !session.process.killed,
37
+ });
38
+ }
39
+ res.json(list);
40
+ } catch (err) {
41
+ console.error('[julia:list]', err);
42
+ res.status(500).json({ error: err.message });
43
+ }
44
+ });
45
+
46
+ /**
47
+ * GET /api/julia/available
48
+ * Check if Julia is available on the system
49
+ * Mirrors: electronAPI.julia.isAvailable()
50
+ */
51
+ router.get('/available', async (req, res) => {
52
+ try {
53
+ const available = await isJuliaAvailable();
54
+ res.json({ available });
55
+ } catch (err) {
56
+ console.error('[julia:available]', err);
57
+ res.json({ available: false });
58
+ }
59
+ });
60
+
61
+ /**
62
+ * POST /api/julia
63
+ * Start a new Julia session
64
+ * Mirrors: electronAPI.julia.start(config)
65
+ */
66
+ router.post('/', async (req, res) => {
67
+ try {
68
+ const { config } = req.body;
69
+ const { name, cwd } = config || {};
70
+
71
+ if (!name) {
72
+ return res.status(400).json({ error: 'config.name required' });
73
+ }
74
+
75
+ // Check if Julia is available
76
+ if (!await isJuliaAvailable()) {
77
+ return res.status(503).json({ error: 'Julia is not available on this system' });
78
+ }
79
+
80
+ // Check if session already exists
81
+ if (sessions.has(name)) {
82
+ const existing = sessions.get(name);
83
+ if (existing.process && !existing.process.killed) {
84
+ return res.json({
85
+ name,
86
+ port: existing.port,
87
+ cwd: existing.cwd,
88
+ reused: true,
89
+ });
90
+ }
91
+ }
92
+
93
+ // Find free port
94
+ const port = await findFreePort(9001, 9100);
95
+ const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
96
+
97
+ // Start Julia MRP server
98
+ // Note: This assumes mrmd-julia is installed and provides an MRP-compatible server
99
+ const proc = spawn('julia', [
100
+ '-e',
101
+ `using MrmdJulia; MrmdJulia.serve(${port})`,
102
+ ], {
103
+ cwd: workDir,
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ });
106
+
107
+ // Wait for server to start (with timeout)
108
+ try {
109
+ await waitForPort(port, 15000);
110
+ } catch (err) {
111
+ proc.kill();
112
+ return res.status(500).json({ error: `Julia server failed to start: ${err.message}` });
113
+ }
114
+
115
+ sessions.set(name, {
116
+ port,
117
+ process: proc,
118
+ cwd: workDir,
119
+ });
120
+
121
+ proc.on('exit', (code) => {
122
+ console.log(`[julia] ${name} exited with code ${code}`);
123
+ sessions.delete(name);
124
+ });
125
+
126
+ res.json({
127
+ name,
128
+ port,
129
+ cwd: workDir,
130
+ url: `http://localhost:${port}/mrp/v1`,
131
+ });
132
+ } catch (err) {
133
+ console.error('[julia:start]', err);
134
+ res.status(500).json({ error: err.message });
135
+ }
136
+ });
137
+
138
+ /**
139
+ * DELETE /api/julia/:name
140
+ * Stop a Julia session
141
+ * Mirrors: electronAPI.julia.stop(sessionName)
142
+ */
143
+ router.delete('/:name', async (req, res) => {
144
+ try {
145
+ const { name } = req.params;
146
+ const session = sessions.get(name);
147
+
148
+ if (!session) {
149
+ return res.json({ success: true, message: 'Session not found' });
150
+ }
151
+
152
+ if (session.process && !session.process.killed) {
153
+ session.process.kill();
154
+ }
155
+
156
+ sessions.delete(name);
157
+ res.json({ success: true });
158
+ } catch (err) {
159
+ console.error('[julia:stop]', err);
160
+ res.status(500).json({ error: err.message });
161
+ }
162
+ });
163
+
164
+ /**
165
+ * POST /api/julia/:name/restart
166
+ * Restart a Julia session
167
+ * Mirrors: electronAPI.julia.restart(sessionName)
168
+ */
169
+ router.post('/:name/restart', async (req, res) => {
170
+ try {
171
+ const { name } = req.params;
172
+ const session = sessions.get(name);
173
+
174
+ if (!session) {
175
+ return res.status(404).json({ error: 'Session not found' });
176
+ }
177
+
178
+ // Kill existing
179
+ if (session.process && !session.process.killed) {
180
+ session.process.kill();
181
+ }
182
+
183
+ // Re-create
184
+ const cwd = session.cwd;
185
+ sessions.delete(name);
186
+
187
+ // Forward to start handler
188
+ req.body.config = { name, cwd };
189
+ // Recursively call POST /
190
+ // In production, extract logic to shared function
191
+ return res.redirect(307, '/api/julia');
192
+ } catch (err) {
193
+ console.error('[julia:restart]', err);
194
+ res.status(500).json({ error: err.message });
195
+ }
196
+ });
197
+
198
+ /**
199
+ * POST /api/julia/for-document
200
+ * Get or create Julia session for a document
201
+ * Mirrors: electronAPI.julia.forDocument(documentPath)
202
+ */
203
+ router.post('/for-document', async (req, res) => {
204
+ try {
205
+ const { documentPath } = req.body;
206
+ if (!documentPath) {
207
+ return res.status(400).json({ error: 'documentPath required' });
208
+ }
209
+
210
+ // Check if Julia is available
211
+ if (!await isJuliaAvailable()) {
212
+ return res.json(null);
213
+ }
214
+
215
+ const docName = `julia-${path.basename(documentPath, '.md')}`;
216
+
217
+ // Check if session exists
218
+ if (sessions.has(docName)) {
219
+ const session = sessions.get(docName);
220
+ return res.json({
221
+ name: docName,
222
+ port: session.port,
223
+ cwd: session.cwd,
224
+ url: `http://localhost:${session.port}/mrp/v1`,
225
+ });
226
+ }
227
+
228
+ // Create session
229
+ const fullPath = path.resolve(ctx.projectDir, documentPath);
230
+ const port = await findFreePort(9001, 9100);
231
+ const workDir = path.dirname(fullPath);
232
+
233
+ const proc = spawn('julia', [
234
+ '-e',
235
+ `using MrmdJulia; MrmdJulia.serve(${port})`,
236
+ ], {
237
+ cwd: workDir,
238
+ stdio: ['pipe', 'pipe', 'pipe'],
239
+ });
240
+
241
+ try {
242
+ await waitForPort(port, 15000);
243
+ } catch (err) {
244
+ proc.kill();
245
+ return res.json(null);
246
+ }
247
+
248
+ sessions.set(docName, {
249
+ port,
250
+ process: proc,
251
+ cwd: workDir,
252
+ });
253
+
254
+ res.json({
255
+ name: docName,
256
+ port,
257
+ cwd: workDir,
258
+ url: `http://localhost:${port}/mrp/v1`,
259
+ });
260
+ } catch (err) {
261
+ console.error('[julia:forDocument]', err);
262
+ res.status(500).json({ error: err.message });
263
+ }
264
+ });
265
+
266
+ return router;
267
+ }
268
+
269
+ /**
270
+ * Check if Julia is available
271
+ */
272
+ async function isJuliaAvailable() {
273
+ return new Promise((resolve) => {
274
+ const proc = spawn('julia', ['--version'], {
275
+ stdio: ['pipe', 'pipe', 'pipe'],
276
+ });
277
+
278
+ proc.on('close', (code) => {
279
+ resolve(code === 0);
280
+ });
281
+
282
+ proc.on('error', () => {
283
+ resolve(false);
284
+ });
285
+
286
+ // Timeout after 5 seconds
287
+ setTimeout(() => {
288
+ proc.kill();
289
+ resolve(false);
290
+ }, 5000);
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Find a free port in range
296
+ */
297
+ async function findFreePort(start, end) {
298
+ for (let port = start; port <= end; port++) {
299
+ if (await isPortFree(port)) {
300
+ return port;
301
+ }
302
+ }
303
+ throw new Error(`No free port found in range ${start}-${end}`);
304
+ }
305
+
306
+ /**
307
+ * Check if port is free
308
+ */
309
+ function isPortFree(port) {
310
+ return new Promise((resolve) => {
311
+ const server = net.createServer();
312
+ server.once('error', () => resolve(false));
313
+ server.once('listening', () => {
314
+ server.close();
315
+ resolve(true);
316
+ });
317
+ server.listen(port, '127.0.0.1');
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Wait for port to be open
323
+ */
324
+ function waitForPort(port, timeout = 10000) {
325
+ return new Promise((resolve, reject) => {
326
+ const start = Date.now();
327
+
328
+ function check() {
329
+ const socket = net.connect(port, '127.0.0.1');
330
+ socket.once('connect', () => {
331
+ socket.end();
332
+ resolve();
333
+ });
334
+ socket.once('error', () => {
335
+ if (Date.now() - start > timeout) {
336
+ reject(new Error(`Timeout waiting for port ${port}`));
337
+ } else {
338
+ setTimeout(check, 200);
339
+ }
340
+ });
341
+ }
342
+
343
+ check();
344
+ });
345
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Project API routes
3
+ *
4
+ * Mirrors electronAPI.project.*
5
+ */
6
+
7
+ import { Router } from 'express';
8
+ import path from 'path';
9
+ import fs from 'fs/promises';
10
+ import { watch } from 'chokidar';
11
+
12
+ /**
13
+ * Create project routes
14
+ * @param {import('../server.js').ServerContext} ctx
15
+ */
16
+ export function createProjectRoutes(ctx) {
17
+ const router = Router();
18
+
19
+ /**
20
+ * GET /api/project?path=...
21
+ * Get project info for a file path
22
+ * Mirrors: electronAPI.project.get(filePath)
23
+ */
24
+ router.get('/', async (req, res) => {
25
+ try {
26
+ const filePath = req.query.path;
27
+ if (!filePath) {
28
+ return res.status(400).json({ error: 'path query parameter required' });
29
+ }
30
+
31
+ const projectInfo = await getProjectInfo(filePath, ctx.projectDir);
32
+ res.json(projectInfo);
33
+ } catch (err) {
34
+ console.error('[project:get]', err);
35
+ res.status(500).json({ error: err.message });
36
+ }
37
+ });
38
+
39
+ /**
40
+ * POST /api/project
41
+ * Create a new mrmd project
42
+ * Mirrors: electronAPI.project.create(targetPath)
43
+ */
44
+ router.post('/', async (req, res) => {
45
+ try {
46
+ const { targetPath } = req.body;
47
+ if (!targetPath) {
48
+ return res.status(400).json({ error: 'targetPath required' });
49
+ }
50
+
51
+ const resolvedPath = path.resolve(ctx.projectDir, targetPath);
52
+
53
+ // Create directory if it doesn't exist
54
+ await fs.mkdir(resolvedPath, { recursive: true });
55
+
56
+ // Create mrmd.md config file
57
+ const mrmdPath = path.join(resolvedPath, 'mrmd.md');
58
+ const mrmdContent = `# ${path.basename(resolvedPath)}
59
+
60
+ A new mrmd project.
61
+
62
+ ---
63
+ venv: .venv
64
+ ---
65
+ `;
66
+ await fs.writeFile(mrmdPath, mrmdContent);
67
+
68
+ // Get and return project info
69
+ const projectInfo = await getProjectInfo(mrmdPath, ctx.projectDir);
70
+ res.json(projectInfo);
71
+ } catch (err) {
72
+ console.error('[project:create]', err);
73
+ res.status(500).json({ error: err.message });
74
+ }
75
+ });
76
+
77
+ /**
78
+ * GET /api/project/nav?root=...
79
+ * Get navigation tree for a project
80
+ * Mirrors: electronAPI.project.nav(projectRoot)
81
+ */
82
+ router.get('/nav', async (req, res) => {
83
+ try {
84
+ const projectRoot = req.query.root || ctx.projectDir;
85
+ const navTree = await buildNavTree(projectRoot);
86
+ res.json(navTree);
87
+ } catch (err) {
88
+ console.error('[project:nav]', err);
89
+ res.status(500).json({ error: err.message });
90
+ }
91
+ });
92
+
93
+ /**
94
+ * POST /api/project/invalidate
95
+ * Invalidate cached project info
96
+ * Mirrors: electronAPI.project.invalidate(projectRoot)
97
+ */
98
+ router.post('/invalidate', async (req, res) => {
99
+ try {
100
+ const { projectRoot } = req.body;
101
+ // In this implementation we don't cache, so this is a no-op
102
+ // but we emit an event so the UI can refresh
103
+ ctx.eventBus.projectChanged(projectRoot || ctx.projectDir);
104
+ res.json({ success: true });
105
+ } catch (err) {
106
+ console.error('[project:invalidate]', err);
107
+ res.status(500).json({ error: err.message });
108
+ }
109
+ });
110
+
111
+ /**
112
+ * POST /api/project/watch
113
+ * Watch project for file changes
114
+ * Mirrors: electronAPI.project.watch(projectRoot)
115
+ */
116
+ router.post('/watch', async (req, res) => {
117
+ try {
118
+ const { projectRoot } = req.body;
119
+ const watchPath = projectRoot || ctx.projectDir;
120
+
121
+ // Close existing watcher if any
122
+ if (ctx.watchers.has(watchPath)) {
123
+ await ctx.watchers.get(watchPath).close();
124
+ }
125
+
126
+ // Create new watcher
127
+ const watcher = watch(watchPath, {
128
+ ignored: /(^|[\/\\])\.|node_modules|\.git|__pycache__|\.mrmd-sync/,
129
+ persistent: true,
130
+ ignoreInitial: true,
131
+ });
132
+
133
+ watcher.on('all', (event, filePath) => {
134
+ if (filePath.endsWith('.md')) {
135
+ ctx.eventBus.projectChanged(watchPath);
136
+ }
137
+ });
138
+
139
+ ctx.watchers.set(watchPath, watcher);
140
+ res.json({ success: true, watching: watchPath });
141
+ } catch (err) {
142
+ console.error('[project:watch]', err);
143
+ res.status(500).json({ error: err.message });
144
+ }
145
+ });
146
+
147
+ /**
148
+ * POST /api/project/unwatch
149
+ * Stop watching project
150
+ * Mirrors: electronAPI.project.unwatch()
151
+ */
152
+ router.post('/unwatch', async (req, res) => {
153
+ try {
154
+ // Close all watchers
155
+ for (const [watchPath, watcher] of ctx.watchers) {
156
+ await watcher.close();
157
+ ctx.watchers.delete(watchPath);
158
+ }
159
+ res.json({ success: true });
160
+ } catch (err) {
161
+ console.error('[project:unwatch]', err);
162
+ res.status(500).json({ error: err.message });
163
+ }
164
+ });
165
+
166
+ return router;
167
+ }
168
+
169
+ /**
170
+ * Get project info for a file path
171
+ */
172
+ async function getProjectInfo(filePath, defaultRoot) {
173
+ const resolvedPath = path.resolve(defaultRoot, filePath);
174
+
175
+ // Find project root by walking up to find mrmd.md
176
+ let projectRoot = path.dirname(resolvedPath);
177
+ let mrmdConfig = null;
178
+
179
+ for (let i = 0; i < 10; i++) {
180
+ const mrmdPath = path.join(projectRoot, 'mrmd.md');
181
+ try {
182
+ const content = await fs.readFile(mrmdPath, 'utf-8');
183
+ mrmdConfig = parseMrmdConfig(content);
184
+ break;
185
+ } catch {
186
+ const parent = path.dirname(projectRoot);
187
+ if (parent === projectRoot) break;
188
+ projectRoot = parent;
189
+ }
190
+ }
191
+
192
+ // If no mrmd.md found, use the provided directory
193
+ if (!mrmdConfig) {
194
+ projectRoot = defaultRoot;
195
+ mrmdConfig = { venv: '.venv' };
196
+ }
197
+
198
+ // Build nav tree
199
+ const navTree = await buildNavTree(projectRoot);
200
+
201
+ return {
202
+ root: projectRoot,
203
+ config: mrmdConfig,
204
+ navTree,
205
+ currentFile: resolvedPath,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Parse mrmd.md config (frontmatter)
211
+ */
212
+ function parseMrmdConfig(content) {
213
+ const config = { venv: '.venv' };
214
+
215
+ // Simple YAML frontmatter parsing
216
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
217
+ if (match) {
218
+ const yaml = match[1];
219
+ const venvMatch = yaml.match(/venv:\s*(.+)/);
220
+ if (venvMatch) {
221
+ config.venv = venvMatch[1].trim();
222
+ }
223
+ }
224
+
225
+ return config;
226
+ }
227
+
228
+ /**
229
+ * Build navigation tree for a project
230
+ */
231
+ async function buildNavTree(projectRoot, relativePath = '') {
232
+ const fullPath = path.join(projectRoot, relativePath);
233
+ const entries = await fs.readdir(fullPath, { withFileTypes: true });
234
+ const nodes = [];
235
+
236
+ // Sort entries: directories first, then files, alphabetically
237
+ entries.sort((a, b) => {
238
+ if (a.isDirectory() && !b.isDirectory()) return -1;
239
+ if (!a.isDirectory() && b.isDirectory()) return 1;
240
+ // Handle FSML ordering (numeric prefixes)
241
+ const aNum = parseInt(a.name.match(/^(\d+)/)?.[1] || '999');
242
+ const bNum = parseInt(b.name.match(/^(\d+)/)?.[1] || '999');
243
+ if (aNum !== bNum) return aNum - bNum;
244
+ return a.name.localeCompare(b.name);
245
+ });
246
+
247
+ for (const entry of entries) {
248
+ // Skip hidden files and special directories
249
+ if (entry.name.startsWith('.')) continue;
250
+ if (entry.name === 'node_modules') continue;
251
+ if (entry.name === '__pycache__') continue;
252
+ if (entry.name === '_assets') continue;
253
+
254
+ const entryRelPath = path.join(relativePath, entry.name);
255
+
256
+ if (entry.isDirectory()) {
257
+ const children = await buildNavTree(projectRoot, entryRelPath);
258
+ // Only include directories that have .md files (directly or nested)
259
+ if (children.length > 0 || await hasIndexFile(path.join(projectRoot, entryRelPath))) {
260
+ nodes.push({
261
+ type: 'folder',
262
+ name: cleanName(entry.name),
263
+ path: entryRelPath,
264
+ children,
265
+ });
266
+ }
267
+ } else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
268
+ nodes.push({
269
+ type: 'file',
270
+ name: cleanName(entry.name.replace(/\.md$/, '')),
271
+ path: entryRelPath,
272
+ });
273
+ }
274
+ }
275
+
276
+ return nodes;
277
+ }
278
+
279
+ /**
280
+ * Check if directory has an index file
281
+ */
282
+ async function hasIndexFile(dirPath) {
283
+ try {
284
+ const entries = await fs.readdir(dirPath);
285
+ return entries.some(e => e.endsWith('.md'));
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Clean FSML numeric prefix from name
293
+ */
294
+ function cleanName(name) {
295
+ return name.replace(/^\d+[-_.\s]*/, '');
296
+ }