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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Maxime Rivest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/cli.js CHANGED
@@ -128,26 +128,10 @@ async function main() {
128
128
 
129
129
  await server.start();
130
130
 
131
- // Start mrmd-sync if available
132
- try {
133
- const syncPath = path.join(packageDir, '..', 'mrmd-sync', 'bin', 'cli.js');
134
- await fs.access(syncPath);
135
-
136
- console.log(' Starting mrmd-sync...');
137
- const syncProc = spawn('node', [syncPath, '--port', '4444', options.projectDir], {
138
- stdio: ['pipe', 'pipe', 'pipe'],
139
- });
140
-
141
- syncProc.stdout.on('data', (data) => {
142
- if (data.toString().includes('Server started')) {
143
- console.log(` Sync: ws://localhost:4444`);
144
- }
145
- });
146
-
147
- server.context.syncProcess = syncProc;
148
- } catch {
149
- console.log(' Sync: (mrmd-sync not found, start manually)');
150
- }
131
+ // Sync servers are now started dynamically per-project when files are opened
132
+ // via the sync-manager.js acquireSyncServer() function
133
+ console.log(' Sync: (dynamic per-project)');
134
+ console.log('');
151
135
 
152
136
  // Keep running
153
137
  console.log('');
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "mrmd-server": "./bin/cli.js"
9
9
  },
10
+ "files": [
11
+ "src",
12
+ "bin",
13
+ "static",
14
+ "README.md"
15
+ ],
10
16
  "scripts": {
11
17
  "start": "node bin/cli.js",
12
18
  "dev": "node bin/cli.js --dev"
@@ -17,9 +23,18 @@
17
23
  "notebook",
18
24
  "server",
19
25
  "remote",
20
- "collaboration"
26
+ "collaboration",
27
+ "literate-programming",
28
+ "code-execution"
21
29
  ],
30
+ "author": "Anthropic",
22
31
  "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/anthropics/mrmd.git",
35
+ "directory": "packages/mrmd-server"
36
+ },
37
+ "homepage": "https://github.com/anthropics/mrmd",
23
38
  "engines": {
24
39
  "node": ">=18.0.0"
25
40
  },
@@ -30,6 +45,8 @@
30
45
  "multer": "^1.4.5-lts.1",
31
46
  "chokidar": "^3.6.0",
32
47
  "fzf": "^0.5.2",
33
- "mrmd-project": "*"
48
+ "mrmd-project": "^0.1.1",
49
+ "mrmd-electron": "^0.3.1",
50
+ "mrmd-sync": "^0.3.2"
34
51
  }
35
52
  }
package/src/api/bash.js CHANGED
@@ -1,16 +1,30 @@
1
1
  /**
2
2
  * Bash Session API routes
3
3
  *
4
- * Mirrors electronAPI.bash.*
4
+ * Mirrors electronAPI.bash.* using BashSessionService from mrmd-electron
5
5
  */
6
6
 
7
7
  import { Router } from 'express';
8
- import { spawn } from 'child_process';
8
+ import { Project } from 'mrmd-project';
9
+ import fs from 'fs';
9
10
  import path from 'path';
10
- import net from 'net';
11
11
 
12
- // Bash session registry
13
- const bashSessions = new Map();
12
+ /**
13
+ * Detect project from a file path
14
+ */
15
+ function detectProject(filePath) {
16
+ const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
17
+ if (!root) return null;
18
+
19
+ try {
20
+ const mrmdPath = path.join(root, 'mrmd.md');
21
+ const content = fs.readFileSync(mrmdPath, 'utf8');
22
+ const config = Project.parseConfig(content);
23
+ return { root, config };
24
+ } catch (e) {
25
+ return { root, config: {} };
26
+ }
27
+ }
14
28
 
15
29
  /**
16
30
  * Create bash routes
@@ -18,6 +32,7 @@ const bashSessions = new Map();
18
32
  */
19
33
  export function createBashRoutes(ctx) {
20
34
  const router = Router();
35
+ const { bashSessionService } = ctx;
21
36
 
22
37
  /**
23
38
  * GET /api/bash
@@ -26,15 +41,7 @@ export function createBashRoutes(ctx) {
26
41
  */
27
42
  router.get('/', async (req, res) => {
28
43
  try {
29
- const list = [];
30
- for (const [name, session] of bashSessions) {
31
- list.push({
32
- name,
33
- port: session.port,
34
- cwd: session.cwd,
35
- running: session.process && !session.process.killed,
36
- });
37
- }
44
+ const list = bashSessionService.list();
38
45
  res.json(list);
39
46
  } catch (err) {
40
47
  console.error('[bash:list]', err);
@@ -50,86 +57,19 @@ export function createBashRoutes(ctx) {
50
57
  router.post('/', async (req, res) => {
51
58
  try {
52
59
  const { config } = req.body;
53
- const { name, cwd } = config || {};
54
60
 
55
- if (!name) {
61
+ if (!config?.name) {
56
62
  return res.status(400).json({ error: 'config.name required' });
57
63
  }
58
64
 
59
- // Check if session already exists
60
- if (bashSessions.has(name)) {
61
- const existing = bashSessions.get(name);
62
- if (existing.process && !existing.process.killed) {
63
- return res.json({
64
- name,
65
- port: existing.port,
66
- cwd: existing.cwd,
67
- reused: true,
68
- });
69
- }
70
- }
71
-
72
- // Find free port
73
- const port = await findFreePort(8101, 8200);
74
-
75
- const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
76
-
77
- // Try to find mrmd-bash package
78
- const mrmdBashPaths = [
79
- path.join(ctx.projectDir, '../mrmd-bash'),
80
- path.join(process.cwd(), '../mrmd-bash'),
81
- path.join(process.cwd(), 'mrmd-bash'),
82
- ];
83
-
84
- let mrmdBashPath = null;
85
- for (const p of mrmdBashPaths) {
86
- try {
87
- const fs = await import('fs/promises');
88
- await fs.access(path.join(p, 'pyproject.toml'));
89
- mrmdBashPath = p;
90
- break;
91
- } catch {}
92
- }
93
-
94
- let proc;
95
- if (mrmdBashPath) {
96
- proc = spawn('uv', [
97
- 'run', '--project', mrmdBashPath,
98
- 'mrmd-bash',
99
- '--port', port.toString(),
100
- ], {
101
- cwd: workDir,
102
- stdio: ['pipe', 'pipe', 'pipe'],
103
- });
104
- } else {
105
- // Fallback: assume mrmd-bash is installed
106
- proc = spawn('mrmd-bash', [
107
- '--port', port.toString(),
108
- ], {
109
- cwd: workDir,
110
- stdio: ['pipe', 'pipe', 'pipe'],
111
- });
112
- }
113
-
114
- // Wait for server to start
115
- await waitForPort(port, 15000);
116
-
117
- bashSessions.set(name, {
118
- port,
119
- process: proc,
120
- cwd: workDir,
121
- });
122
-
123
- proc.on('exit', (code) => {
124
- console.log(`[bash] ${name} exited with code ${code}`);
125
- bashSessions.delete(name);
126
- });
65
+ const result = await bashSessionService.start(config);
127
66
 
128
67
  res.json({
129
- name,
130
- port,
131
- cwd: workDir,
132
- url: `http://localhost:${port}/mrp/v1`,
68
+ name: result.name,
69
+ port: result.port,
70
+ cwd: result.cwd,
71
+ pid: result.pid,
72
+ url: `http://localhost:${result.port}/mrp/v1`,
133
73
  });
134
74
  } catch (err) {
135
75
  console.error('[bash:start]', err);
@@ -145,21 +85,11 @@ export function createBashRoutes(ctx) {
145
85
  router.delete('/:name', async (req, res) => {
146
86
  try {
147
87
  const { name } = req.params;
148
- const session = bashSessions.get(name);
149
-
150
- if (!session) {
151
- return res.json({ success: true, message: 'Session not found' });
152
- }
153
-
154
- if (session.process && !session.process.killed) {
155
- session.process.kill();
156
- }
157
-
158
- bashSessions.delete(name);
88
+ await bashSessionService.stop(name);
159
89
  res.json({ success: true });
160
90
  } catch (err) {
161
91
  console.error('[bash:stop]', err);
162
- res.status(500).json({ error: err.message });
92
+ res.json({ success: true, message: err.message });
163
93
  }
164
94
  });
165
95
 
@@ -171,29 +101,15 @@ export function createBashRoutes(ctx) {
171
101
  router.post('/:name/restart', async (req, res) => {
172
102
  try {
173
103
  const { name } = req.params;
174
- const session = bashSessions.get(name);
175
-
176
- if (!session) {
177
- return res.status(404).json({ error: 'Session not found' });
178
- }
179
-
180
- // Kill existing
181
- if (session.process && !session.process.killed) {
182
- session.process.kill();
183
- }
104
+ const result = await bashSessionService.restart(name);
184
105
 
185
- bashSessions.delete(name);
186
-
187
- // Re-create
188
- req.body.config = { name, cwd: session.cwd };
189
-
190
- // Use the POST handler
191
- const handler = router.stack.find(r => r.route?.path === '/' && r.route.methods.post);
192
- if (handler) {
193
- return handler.route.stack[0].handle(req, res);
194
- }
195
-
196
- res.status(500).json({ error: 'Could not restart' });
106
+ res.json({
107
+ name: result.name,
108
+ port: result.port,
109
+ cwd: result.cwd,
110
+ pid: result.pid,
111
+ url: `http://localhost:${result.port}/mrp/v1`,
112
+ });
197
113
  } catch (err) {
198
114
  console.error('[bash:restart]', err);
199
115
  res.status(500).json({ error: err.message });
@@ -204,90 +120,57 @@ export function createBashRoutes(ctx) {
204
120
  * POST /api/bash/for-document
205
121
  * Get or create bash session for a document
206
122
  * Mirrors: electronAPI.bash.forDocument(documentPath)
123
+ *
124
+ * Automatically detects project if projectConfig/projectRoot not provided
207
125
  */
208
126
  router.post('/for-document', async (req, res) => {
209
127
  try {
210
- const { documentPath } = req.body;
128
+ let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
129
+
211
130
  if (!documentPath) {
212
131
  return res.status(400).json({ error: 'documentPath required' });
213
132
  }
214
133
 
215
- const docName = `bash-${path.basename(documentPath, '.md')}`;
134
+ // Auto-detect project if not provided
135
+ if (!projectConfig || !projectRoot) {
136
+ const detected = detectProject(documentPath);
137
+ if (detected) {
138
+ projectRoot = projectRoot || detected.root;
139
+ projectConfig = projectConfig || detected.config;
140
+ } else {
141
+ projectRoot = projectRoot || (ctx.projectDir || process.cwd());
142
+ projectConfig = projectConfig || {};
143
+ }
144
+ }
216
145
 
217
- // Check if session exists
218
- if (bashSessions.has(docName)) {
219
- const session = bashSessions.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
- });
146
+ // Auto-parse frontmatter if not provided
147
+ if (!frontmatter) {
148
+ try {
149
+ const content = fs.readFileSync(documentPath, 'utf8');
150
+ frontmatter = Project.parseFrontmatter(content);
151
+ } catch (e) {
152
+ frontmatter = null;
153
+ }
226
154
  }
227
155
 
228
- // Create session in document's directory
229
- const fullPath = path.resolve(ctx.projectDir, documentPath);
230
- req.body.config = {
231
- name: docName,
232
- cwd: path.dirname(fullPath),
233
- };
156
+ const result = await bashSessionService.getForDocument(
157
+ documentPath,
158
+ projectConfig,
159
+ frontmatter,
160
+ projectRoot
161
+ );
234
162
 
235
- // Use the POST handler
236
- const handler = router.stack.find(r => r.route?.path === '/' && r.route.methods.post);
237
- if (handler) {
238
- return handler.route.stack[0].handle(req, res);
163
+ // Add url if we have a port
164
+ if (result?.port) {
165
+ result.url = `http://localhost:${result.port}/mrp/v1`;
239
166
  }
240
167
 
241
- res.status(500).json({ error: 'Could not create session' });
168
+ res.json(result);
242
169
  } catch (err) {
243
170
  console.error('[bash:forDocument]', err);
244
- res.status(500).json({ error: err.message });
171
+ res.json(null);
245
172
  }
246
173
  });
247
174
 
248
175
  return router;
249
176
  }
250
-
251
- async function findFreePort(start, end) {
252
- for (let port = start; port <= end; port++) {
253
- if (await isPortFree(port)) {
254
- return port;
255
- }
256
- }
257
- throw new Error(`No free port found in range ${start}-${end}`);
258
- }
259
-
260
- function isPortFree(port) {
261
- return new Promise((resolve) => {
262
- const server = net.createServer();
263
- server.once('error', () => resolve(false));
264
- server.once('listening', () => {
265
- server.close();
266
- resolve(true);
267
- });
268
- server.listen(port, '127.0.0.1');
269
- });
270
- }
271
-
272
- function waitForPort(port, timeout = 10000) {
273
- return new Promise((resolve, reject) => {
274
- const start = Date.now();
275
-
276
- function check() {
277
- const socket = net.connect(port, '127.0.0.1');
278
- socket.once('connect', () => {
279
- socket.end();
280
- resolve();
281
- });
282
- socket.once('error', () => {
283
- if (Date.now() - start > timeout) {
284
- reject(new Error(`Timeout waiting for port ${port}`));
285
- } else {
286
- setTimeout(check, 200);
287
- }
288
- });
289
- }
290
-
291
- check();
292
- });
293
- }
package/src/api/file.js CHANGED
@@ -48,7 +48,7 @@ export function createFileRoutes(ctx) {
48
48
  return res.status(400).json({ error: 'filePath required' });
49
49
  }
50
50
 
51
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
51
+ const fullPath = resolvePath(ctx.projectDir, filePath);
52
52
 
53
53
  // Create directory if needed
54
54
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
@@ -84,7 +84,7 @@ export function createFileRoutes(ctx) {
84
84
  }
85
85
 
86
86
  const root = projectRoot || ctx.projectDir;
87
- const fullPath = resolveSafePath(root, relativePath);
87
+ const fullPath = resolvePath(root, relativePath);
88
88
 
89
89
  // Create directory if needed
90
90
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
@@ -138,8 +138,8 @@ export function createFileRoutes(ctx) {
138
138
  }
139
139
 
140
140
  const root = projectRoot || ctx.projectDir;
141
- const fullFromPath = resolveSafePath(root, fromPath);
142
- const fullToPath = resolveSafePath(root, toPath);
141
+ const fullFromPath = resolvePath(root, fromPath);
142
+ const fullToPath = resolvePath(root, toPath);
143
143
 
144
144
  // Create destination directory if needed
145
145
  await fs.mkdir(path.dirname(fullToPath), { recursive: true });
@@ -187,15 +187,15 @@ export function createFileRoutes(ctx) {
187
187
  // 3. Renaming files with new prefixes
188
188
 
189
189
  // For now, just do a simple move
190
- const fullSourcePath = resolveSafePath(root, sourcePath);
190
+ const fullSourcePath = resolvePath(root, sourcePath);
191
191
  let fullTargetPath;
192
192
 
193
193
  if (position === 'inside') {
194
194
  // Move into target directory
195
- fullTargetPath = resolveSafePath(root, path.join(targetPath, path.basename(sourcePath)));
195
+ fullTargetPath = resolvePath(root, path.join(targetPath, path.basename(sourcePath)));
196
196
  } else {
197
197
  // Move to same directory as target
198
- fullTargetPath = resolveSafePath(root, path.join(path.dirname(targetPath), path.basename(sourcePath)));
198
+ fullTargetPath = resolvePath(root, path.join(path.dirname(targetPath), path.basename(sourcePath)));
199
199
  }
200
200
 
201
201
  await fs.mkdir(path.dirname(fullTargetPath), { recursive: true });
@@ -228,7 +228,7 @@ export function createFileRoutes(ctx) {
228
228
  return res.status(400).json({ error: 'path query parameter required' });
229
229
  }
230
230
 
231
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
231
+ const fullPath = resolvePath(ctx.projectDir, filePath);
232
232
 
233
233
  const stat = await fs.stat(fullPath);
234
234
  if (stat.isDirectory()) {
@@ -257,7 +257,7 @@ export function createFileRoutes(ctx) {
257
257
  return res.status(400).json({ error: 'path query parameter required' });
258
258
  }
259
259
 
260
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
260
+ const fullPath = resolvePath(ctx.projectDir, filePath);
261
261
  const content = await fs.readFile(fullPath, 'utf-8');
262
262
 
263
263
  res.json({ success: true, content });
@@ -282,7 +282,7 @@ export function createFileRoutes(ctx) {
282
282
  return res.status(400).json({ error: 'filePath required' });
283
283
  }
284
284
 
285
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
285
+ const fullPath = resolvePath(ctx.projectDir, filePath);
286
286
 
287
287
  // Create directory if needed
288
288
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
@@ -310,7 +310,7 @@ export function createFileRoutes(ctx) {
310
310
  return res.status(400).json({ error: 'path query parameter required' });
311
311
  }
312
312
 
313
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
313
+ const fullPath = resolvePath(ctx.projectDir, filePath);
314
314
  const content = await fs.readFile(fullPath, 'utf-8');
315
315
  const previewLines = content.split('\n').slice(0, lines).join('\n');
316
316
 
@@ -336,7 +336,7 @@ export function createFileRoutes(ctx) {
336
336
  return res.status(400).json({ error: 'path query parameter required' });
337
337
  }
338
338
 
339
- const fullPath = resolveSafePath(ctx.projectDir, filePath);
339
+ const fullPath = resolvePath(ctx.projectDir, filePath);
340
340
  const stat = await fs.stat(fullPath);
341
341
 
342
342
  res.json({
@@ -359,17 +359,23 @@ export function createFileRoutes(ctx) {
359
359
  }
360
360
 
361
361
  /**
362
- * Resolve path safely within project directory
362
+ * Resolve path - allows full filesystem access
363
+ *
364
+ * Security model: The server runs on the user's machine with their permissions.
365
+ * Access control is handled via auth token, not path restrictions.
366
+ *
367
+ * @param {string} basePath - Base path for relative paths (ignored for absolute)
368
+ * @param {string} inputPath - Path to resolve (absolute or relative)
369
+ * @returns {string} Resolved absolute path
363
370
  */
364
- function resolveSafePath(projectDir, relativePath) {
365
- const resolved = path.resolve(projectDir, relativePath);
366
-
367
- // Security: ensure resolved path is within project directory
368
- if (!resolved.startsWith(path.resolve(projectDir))) {
369
- throw new Error('Path traversal not allowed');
371
+ function resolvePath(basePath, inputPath) {
372
+ // If it's already absolute, use it directly
373
+ if (path.isAbsolute(inputPath)) {
374
+ return inputPath;
370
375
  }
371
376
 
372
- return resolved;
377
+ // Otherwise resolve relative to basePath
378
+ return path.resolve(basePath, inputPath);
373
379
  }
374
380
 
375
381
  /**
package/src/api/index.js CHANGED
@@ -9,3 +9,8 @@ export { createFileRoutes } from './file.js';
9
9
  export { createAssetRoutes } from './asset.js';
10
10
  export { createSystemRoutes } from './system.js';
11
11
  export { createRuntimeRoutes } from './runtime.js';
12
+ export { createJuliaRoutes } from './julia.js';
13
+ export { createPtyRoutes } from './pty.js';
14
+ export { createNotebookRoutes } from './notebook.js';
15
+ export { createSettingsRoutes } from './settings.js';
16
+ export { createRRoutes } from './r.js';