mrmd-server 0.1.21 → 0.1.22

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/README.md CHANGED
@@ -1,17 +1,19 @@
1
- # mrmd-server
1
+ # MRMD Server
2
2
 
3
- Run mrmd in any browser. Access your notebooks from anywhere.
3
+ **Run MRMD in any browser.** Access your markdown notebooks from anywhere — your phone, tablet, or any machine with a browser.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/mrmd-server)](https://www.npmjs.com/package/mrmd-server) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
6
 
5
7
  ```
6
8
  ┌─────────────────────────────────────────────────────────────┐
7
- │ Your VPS / Cloud Server
9
+ │ Your Server / VPS / Cloud
8
10
  │ │
9
11
  │ ┌─────────────────────────────────────────────────────┐ │
10
12
  │ │ mrmd-server │ │
11
- │ │ • HTTP API (full electronAPI equivalent) │ │
12
- │ │ • Static file serving │ │
13
- │ │ • WebSocket for real-time events │ │
14
- │ │ • Token authentication │ │
13
+ │ │ • Full MRMD UI served over HTTP │ │
14
+ │ │ • Code execution (Python, JS, Bash, R, Julia) │ │
15
+ │ │ • Real-time collaboration via WebSocket │ │
16
+ │ │ • Token-based authentication │ │
15
17
  │ └─────────────────────────────────────────────────────┘ │
16
18
  │ │
17
19
  └─────────────────────────────────────────────────────────────┘
@@ -25,23 +27,65 @@ Run mrmd in any browser. Access your notebooks from anywhere.
25
27
  └───────┘ └─────────┘ └─────────┘
26
28
  ```
27
29
 
30
+ ---
31
+
32
+ ## What is MRMD Server?
33
+
34
+ MRMD Server is the headless/server version of [MRMD Electron](https://github.com/MaximeRivest/mrmd-electron). It provides the same markdown notebook experience without requiring a desktop app — just start the server and open it in any browser.
35
+
36
+ **Use cases:**
37
+ - Run notebooks on a remote GPU server, access from your laptop
38
+ - Host shared notebooks for a team
39
+ - Access your notebooks from your phone or tablet
40
+ - Deploy on a cloud VM for always-on compute
41
+
42
+ ---
43
+
28
44
  ## Features
29
45
 
30
- - **Same UI as Electron** - Uses the exact same index.html from mrmd-electron
31
- - **Access from anywhere** - Phone, tablet, any browser
32
- - **Real-time collaboration** - Yjs sync works over WebSocket
33
- - **Token authentication** - Secure access with shareable links
34
- - **Portable compute** - Move your disk to a GPU server when needed
46
+ ### Same UI as Desktop
47
+ The exact same editor, code execution, and collaboration features as MRMD Electron.
35
48
 
36
- ## Quick Start
49
+ ### Access from Anywhere
50
+ Open your notebooks in any browser — phone, tablet, another computer.
51
+
52
+ ### Real-Time Collaboration
53
+ Share the URL with teammates. Changes sync instantly via Yjs CRDT.
54
+
55
+ ### Token Authentication
56
+ Secure access with auto-generated or custom tokens. Share links safely.
57
+
58
+ ### Portable Compute
59
+ Start the server wherever your data lives. GPU machine? Local workstation? Cloud VM? Just run `mrmd-server`.
60
+
61
+ ---
62
+
63
+ ## Installation
64
+
65
+ ### npm (recommended)
37
66
 
38
67
  ```bash
39
- # Install
40
- cd mrmd-packages/mrmd-server
41
- npm install
68
+ npm install -g mrmd-server
69
+ ```
42
70
 
43
- # Start server in your project directory
71
+ ### npx (no install)
72
+
73
+ ```bash
44
74
  npx mrmd-server ./my-notebooks
75
+ ```
76
+
77
+ ### Requirements
78
+
79
+ - **Node.js 18+**
80
+ - **Python 3.11+** with [uv](https://github.com/astral-sh/uv) (for Python execution)
81
+
82
+ ---
83
+
84
+ ## Quick Start
85
+
86
+ ```bash
87
+ # Start server in your project directory
88
+ mrmd-server ./my-notebooks
45
89
 
46
90
  # Output:
47
91
  # mrmd-server
@@ -54,9 +98,26 @@ npx mrmd-server ./my-notebooks
54
98
  # http://localhost:8080?token=abc123xyz...
55
99
  ```
56
100
 
101
+ Open the Access URL in your browser. That's it.
102
+
103
+ ---
104
+
57
105
  ## Usage
58
106
 
59
- ### Basic Usage
107
+ ### Command Line Options
108
+
109
+ ```bash
110
+ mrmd-server [options] [project-dir]
111
+
112
+ Options:
113
+ -p, --port <port> HTTP port (default: 8080)
114
+ -h, --host <host> Bind address (default: 0.0.0.0)
115
+ -t, --token <token> Auth token (auto-generated if not provided)
116
+ --no-auth Disable authentication (local dev only!)
117
+ --help Show help
118
+ ```
119
+
120
+ ### Examples
60
121
 
61
122
  ```bash
62
123
  # Start in current directory
@@ -68,163 +129,185 @@ mrmd-server ./my-project
68
129
  # Custom port
69
130
  mrmd-server -p 3000 ./my-project
70
131
 
71
- # With specific token
132
+ # With specific token (for automation)
72
133
  mrmd-server -t my-secret-token ./my-project
73
134
 
74
135
  # No auth (local development only!)
75
- mrmd-server --no-auth ./my-project
136
+ mrmd-server --no-auth
76
137
  ```
77
138
 
78
- ### Remote Access
139
+ ---
79
140
 
80
- 1. Start mrmd-server on your VPS:
81
- ```bash
82
- mrmd-server -p 8080 /home/you/notebooks
83
- ```
141
+ ## Remote Access Setup
84
142
 
85
- 2. Set up HTTPS (recommended) with nginx or caddy:
86
- ```nginx
87
- server {
88
- listen 443 ssl;
89
- server_name notebooks.example.com;
143
+ ### 1. Start the server on your remote machine
90
144
 
91
- location / {
92
- proxy_pass http://localhost:8080;
93
- proxy_http_version 1.1;
94
- proxy_set_header Upgrade $http_upgrade;
95
- proxy_set_header Connection "upgrade";
96
- }
97
- }
98
- ```
145
+ ```bash
146
+ ssh your-server
147
+ cd /path/to/notebooks
148
+ mrmd-server -p 8080
149
+ ```
150
+
151
+ ### 2. Set up HTTPS with nginx (recommended)
152
+
153
+ ```nginx
154
+ server {
155
+ listen 443 ssl;
156
+ server_name notebooks.example.com;
99
157
 
100
- 3. Access from anywhere:
101
- ```
102
- https://notebooks.example.com?token=YOUR_TOKEN
103
- ```
158
+ ssl_certificate /path/to/cert.pem;
159
+ ssl_certificate_key /path/to/key.pem;
160
+
161
+ location / {
162
+ proxy_pass http://localhost:8080;
163
+ proxy_http_version 1.1;
164
+ proxy_set_header Upgrade $http_upgrade;
165
+ proxy_set_header Connection "upgrade";
166
+ proxy_set_header Host $host;
167
+ }
168
+ }
169
+ ```
104
170
 
105
- ### Share with Collaborators
171
+ ### 3. Access from anywhere
172
+
173
+ ```
174
+ https://notebooks.example.com?token=YOUR_TOKEN
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Sharing & Collaboration
180
+
181
+ Share the URL (including the token) with collaborators:
106
182
 
107
- Just share the URL with the token:
108
183
  ```
109
184
  https://your-server.com?token=abc123xyz
110
185
  ```
111
186
 
112
- Collaborators get:
113
- - Real-time collaborative editing (Yjs)
114
- - Code execution (via the server)
115
- - Same UI as local Electron app
187
+ Everyone with the URL gets:
188
+ - Real-time collaborative editing
189
+ - Code execution on your server
190
+ - Full MRMD features
191
+
192
+ ---
116
193
 
117
194
  ## Architecture
118
195
 
119
- mrmd-server provides an HTTP API that mirrors Electron's IPC interface:
196
+ MRMD Server mirrors the Electron app's IPC interface as an HTTP API:
120
197
 
121
- | Electron (IPC) | mrmd-server (HTTP) |
198
+ | Electron (IPC) | MRMD Server (HTTP) |
122
199
  |----------------|-------------------|
123
200
  | `electronAPI.project.get(path)` | `GET /api/project?path=...` |
124
201
  | `electronAPI.file.write(path, content)` | `POST /api/file/write` |
125
202
  | `electronAPI.session.forDocument(path)` | `POST /api/session/for-document` |
126
203
  | `ipcRenderer.on('project:changed', cb)` | WebSocket `/events` |
127
204
 
128
- The browser loads `http-shim.js` which creates a `window.electronAPI` object that makes HTTP calls instead of IPC calls. The existing UI code works unchanged.
205
+ The browser loads an HTTP shim that creates `window.electronAPI` making HTTP calls instead of IPC. The UI code works unchanged.
206
+
207
+ ```
208
+ ┌─────────────────────────────────────────────────────────────┐
209
+ │ mrmd-server │
210
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
211
+ │ │ Express │ │ mrmd-sync │ │ mrmd- │ │
212
+ │ │ HTTP API │ │ (Yjs) │ │ python/bash │ │
213
+ │ └─────────────┘ └─────────────┘ └─────────────┘ │
214
+ └─────────────────────────────────────────────────────────────┘
215
+ │ │ │
216
+ HTTP/REST WebSocket Execution
217
+ ```
218
+
219
+ ---
129
220
 
130
221
  ## API Reference
131
222
 
132
223
  ### Authentication
133
224
 
134
- All API endpoints (except `/health` and `/auth/validate`) require authentication.
135
-
136
- Provide token via:
225
+ All `/api/*` endpoints require authentication. Provide token via:
137
226
  - Query parameter: `?token=xxx`
138
227
  - Header: `Authorization: Bearer xxx`
139
228
  - Header: `X-Token: xxx`
140
229
 
141
- ### Endpoints
142
-
143
- #### System
144
- - `GET /api/system/home` - Get home directory
145
- - `GET /api/system/recent` - Get recent files/venvs
146
- - `GET /api/system/ai` - Get AI server info
147
- - `POST /api/system/discover-venvs` - Start venv discovery
148
-
149
- #### Project
150
- - `GET /api/project?path=...` - Get project info
151
- - `POST /api/project` - Create project
152
- - `GET /api/project/nav?root=...` - Get navigation tree
153
- - `POST /api/project/watch` - Watch for changes
154
- - `POST /api/project/unwatch` - Stop watching
155
-
156
- #### Session
157
- - `GET /api/session` - List sessions
158
- - `POST /api/session` - Start session
159
- - `DELETE /api/session/:name` - Stop session
160
- - `POST /api/session/for-document` - Get/create session for document
161
-
162
- #### Bash
163
- - Same as Session, at `/api/bash/*`
164
-
165
- #### File
166
- - `GET /api/file/scan` - Scan for files
167
- - `POST /api/file/create` - Create file
168
- - `POST /api/file/create-in-project` - Create with FSML ordering
169
- - `POST /api/file/move` - Move/rename
170
- - `POST /api/file/reorder` - Drag-drop reorder
171
- - `DELETE /api/file?path=...` - Delete file
172
- - `GET /api/file/read?path=...` - Read file
173
- - `POST /api/file/write` - Write file
174
-
175
- #### Asset
176
- - `GET /api/asset` - List assets
177
- - `POST /api/asset/save` - Upload asset
178
- - `GET /api/asset/relative-path` - Calculate relative path
179
- - `GET /api/asset/orphans` - Find orphaned assets
180
- - `DELETE /api/asset` - Delete asset
181
-
182
- #### Runtime
183
- - `GET /api/runtime` - List runtimes
184
- - `DELETE /api/runtime/:id` - Kill runtime
185
- - `POST /api/runtime/:id/attach` - Attach to runtime
230
+ ### Core Endpoints
231
+
232
+ | Endpoint | Description |
233
+ |----------|-------------|
234
+ | `GET /health` | Health check (no auth) |
235
+ | `GET /auth/validate?token=xxx` | Validate token (no auth) |
236
+ | `GET /api/project?path=...` | Get project info |
237
+ | `GET /api/file/read?path=...` | Read file |
238
+ | `POST /api/file/write` | Write file |
239
+ | `POST /api/session/for-document` | Get/create session for document |
240
+ | `GET /api/runtime` | List active runtimes |
241
+ | `DELETE /api/runtime/:id` | Kill a runtime |
186
242
 
187
243
  ### WebSocket Events
188
244
 
189
- Connect to `/events?token=xxx` to receive push events:
245
+ Connect to `/events?token=xxx` for real-time updates:
190
246
 
191
247
  ```javascript
192
248
  const ws = new WebSocket('wss://server.com/events?token=xxx');
193
249
  ws.onmessage = (e) => {
194
250
  const { event, data } = JSON.parse(e.data);
195
- // event: 'project:changed', 'venv-found', 'sync-server-died', etc.
251
+ // Events: 'project:changed', 'venv-found', 'sync-server-died', etc.
196
252
  };
197
253
  ```
198
254
 
199
- ## Security Considerations
255
+ ---
256
+
257
+ ## Security
258
+
259
+ 1. **Always use HTTPS** in production — use nginx, caddy, or a cloud load balancer
260
+ 2. **Keep tokens secret** — treat them like passwords
261
+ 3. **Never use `--no-auth` on public networks**
262
+ 4. **Rotate tokens** if you suspect they're compromised
200
263
 
201
- 1. **Always use HTTPS** in production (use nginx/caddy as reverse proxy)
202
- 2. **Keep tokens secret** - treat them like passwords
203
- 3. **Use `--no-auth` only for local development**
204
- 4. **Rotate tokens** if compromised
264
+ ---
205
265
 
206
266
  ## Limitations
207
267
 
208
- Some Electron features can't work in browser:
268
+ Some desktop features don't work in browser mode:
209
269
 
210
270
  | Feature | Browser Behavior |
211
271
  |---------|------------------|
212
- | `shell.showItemInFolder` | Returns path (can't open Finder) |
213
- | `shell.openPath` | Returns path (can't open local apps) |
214
- | Native titlebar | Standard browser chrome |
215
- | Offline | Requires server connection |
272
+ | "Show in Finder" | Returns path (can't open native file browser) |
273
+ | Native window controls | Standard browser chrome |
274
+ | Offline mode | Requires server connection |
275
+
276
+ ---
216
277
 
217
278
  ## Development
218
279
 
219
280
  ```bash
281
+ # Clone the monorepo
282
+ git clone https://github.com/MaximeRivest/mrmd-packages.git
283
+ cd mrmd-packages/mrmd-server
284
+
285
+ # Install dependencies
286
+ npm install
287
+
220
288
  # Run in dev mode
221
289
  npm run dev
222
-
223
- # The server will:
224
- # - Watch for file changes
225
- # - Auto-restart on changes
226
290
  ```
227
291
 
292
+ ---
293
+
294
+ ## Related Projects
295
+
296
+ | Project | Description |
297
+ |---------|-------------|
298
+ | [MRMD Electron](https://github.com/MaximeRivest/mrmd-electron) | Desktop app (macOS, Windows, Linux) |
299
+ | [mrmd-python](https://github.com/MaximeRivest/mrmd-python) | Python execution runtime |
300
+ | [mrmd-editor](https://github.com/MaximeRivest/mrmd-editor) | CodeMirror-based editor component |
301
+ | [mrmd-sync](https://github.com/MaximeRivest/mrmd-sync) | Yjs collaboration server |
302
+
303
+ ---
304
+
228
305
  ## License
229
306
 
230
- MIT
307
+ MIT License. See [LICENSE](LICENSE) for details.
308
+
309
+ ---
310
+
311
+ <p align="center">
312
+ <b>MRMD Server</b> — Your notebooks, anywhere.
313
+ </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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",
@@ -45,8 +45,8 @@
45
45
  "multer": "^1.4.5-lts.1",
46
46
  "chokidar": "^3.6.0",
47
47
  "fzf": "^0.5.2",
48
- "mrmd-project": "^0.1.1",
48
+ "mrmd-project": "^0.1.2",
49
49
  "mrmd-electron": "^0.3.4",
50
- "mrmd-sync": "^0.3.2"
50
+ "mrmd-sync": "^0.3.3"
51
51
  }
52
52
  }
package/src/api/file.js CHANGED
@@ -27,8 +27,8 @@ export function createFileRoutes(ctx) {
27
27
  const os = await import('os');
28
28
  const root = req.query.root || ctx.projectDir || process.cwd() || os.default.homedir();
29
29
  const options = {
30
- // Default to both .md and .ipynb (like Electron)
31
- extensions: req.query.extensions?.split(',') || ['.md', '.ipynb'],
30
+ // Default to markdown-like docs and .ipynb (like Electron)
31
+ extensions: req.query.extensions?.split(',') || ['.md', '.qmd', '.ipynb'],
32
32
  maxDepth: parseInt(req.query.maxDepth) || 10,
33
33
  includeHidden: req.query.includeHidden === 'true',
34
34
  };
package/src/api/julia.js CHANGED
@@ -1,17 +1,56 @@
1
1
  /**
2
2
  * Julia Session API routes
3
3
  *
4
- * Mirrors electronAPI.julia.*
4
+ * Mirrors electronAPI.julia.* using JuliaSessionService from mrmd-electron
5
5
  */
6
6
 
7
7
  import { Router } from 'express';
8
+ import { Project } from 'mrmd-project';
8
9
  import { spawn } from 'child_process';
10
+ import fs from 'fs';
9
11
  import path from 'path';
10
- import fs from 'fs/promises';
11
- import net from 'net';
12
12
 
13
- // Session registry: sessionName -> { port, process, cwd }
14
- const sessions = new Map();
13
+ /**
14
+ * Detect project from a file path
15
+ */
16
+ function detectProject(filePath) {
17
+ const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
18
+ if (!root) return null;
19
+
20
+ try {
21
+ const mrmdPath = path.join(root, 'mrmd.md');
22
+ const content = fs.readFileSync(mrmdPath, 'utf8');
23
+ const config = Project.parseConfig(content);
24
+ return { root, config };
25
+ } catch (e) {
26
+ return { root, config: {} };
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if Julia is available on the system
32
+ */
33
+ async function isJuliaAvailable() {
34
+ return new Promise((resolve) => {
35
+ const proc = spawn('julia', ['--version'], {
36
+ stdio: ['pipe', 'pipe', 'pipe'],
37
+ });
38
+
39
+ proc.on('close', (code) => {
40
+ resolve(code === 0);
41
+ });
42
+
43
+ proc.on('error', () => {
44
+ resolve(false);
45
+ });
46
+
47
+ // Timeout after 5 seconds
48
+ setTimeout(() => {
49
+ proc.kill();
50
+ resolve(false);
51
+ }, 5000);
52
+ });
53
+ }
15
54
 
16
55
  /**
17
56
  * Create Julia routes
@@ -19,6 +58,7 @@ const sessions = new Map();
19
58
  */
20
59
  export function createJuliaRoutes(ctx) {
21
60
  const router = Router();
61
+ const { juliaSessionService } = ctx;
22
62
 
23
63
  /**
24
64
  * GET /api/julia
@@ -27,15 +67,7 @@ export function createJuliaRoutes(ctx) {
27
67
  */
28
68
  router.get('/', async (req, res) => {
29
69
  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
- }
70
+ const list = juliaSessionService.list();
39
71
  res.json(list);
40
72
  } catch (err) {
41
73
  console.error('[julia:list]', err);
@@ -66,9 +98,8 @@ export function createJuliaRoutes(ctx) {
66
98
  router.post('/', async (req, res) => {
67
99
  try {
68
100
  const { config } = req.body;
69
- const { name, cwd } = config || {};
70
101
 
71
- if (!name) {
102
+ if (!config?.name) {
72
103
  return res.status(400).json({ error: 'config.name required' });
73
104
  }
74
105
 
@@ -77,57 +108,14 @@ export function createJuliaRoutes(ctx) {
77
108
  return res.status(503).json({ error: 'Julia is not available on this system' });
78
109
  }
79
110
 
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
- });
111
+ const result = await juliaSessionService.start(config);
125
112
 
126
113
  res.json({
127
- name,
128
- port,
129
- cwd: workDir,
130
- url: `http://localhost:${port}/mrp/v1`,
114
+ name: result.name,
115
+ port: result.port,
116
+ cwd: result.cwd,
117
+ pid: result.pid,
118
+ url: `http://localhost:${result.port}/mrp/v1`,
131
119
  });
132
120
  } catch (err) {
133
121
  console.error('[julia:start]', err);
@@ -143,21 +131,11 @@ export function createJuliaRoutes(ctx) {
143
131
  router.delete('/:name', async (req, res) => {
144
132
  try {
145
133
  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);
134
+ await juliaSessionService.stop(name);
157
135
  res.json({ success: true });
158
136
  } catch (err) {
159
137
  console.error('[julia:stop]', err);
160
- res.status(500).json({ error: err.message });
138
+ res.json({ success: true, message: err.message });
161
139
  }
162
140
  });
163
141
 
@@ -169,26 +147,15 @@ export function createJuliaRoutes(ctx) {
169
147
  router.post('/:name/restart', async (req, res) => {
170
148
  try {
171
149
  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);
150
+ const result = await juliaSessionService.restart(name);
186
151
 
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');
152
+ res.json({
153
+ name: result.name,
154
+ port: result.port,
155
+ cwd: result.cwd,
156
+ pid: result.pid,
157
+ url: `http://localhost:${result.port}/mrp/v1`,
158
+ });
192
159
  } catch (err) {
193
160
  console.error('[julia:restart]', err);
194
161
  res.status(500).json({ error: err.message });
@@ -199,10 +166,13 @@ export function createJuliaRoutes(ctx) {
199
166
  * POST /api/julia/for-document
200
167
  * Get or create Julia session for a document
201
168
  * Mirrors: electronAPI.julia.forDocument(documentPath)
169
+ *
170
+ * Automatically detects project if projectConfig/projectRoot not provided
202
171
  */
203
172
  router.post('/for-document', async (req, res) => {
204
173
  try {
205
- const { documentPath } = req.body;
174
+ let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
175
+
206
176
  if (!documentPath) {
207
177
  return res.status(400).json({ error: 'documentPath required' });
208
178
  }
@@ -212,134 +182,46 @@ export function createJuliaRoutes(ctx) {
212
182
  return res.json(null);
213
183
  }
214
184
 
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
- });
185
+ // Auto-detect project if not provided
186
+ if (!projectConfig || !projectRoot) {
187
+ const detected = detectProject(documentPath);
188
+ if (detected) {
189
+ projectRoot = projectRoot || detected.root;
190
+ projectConfig = projectConfig || detected.config;
191
+ } else {
192
+ projectRoot = projectRoot || (ctx.projectDir || process.cwd());
193
+ projectConfig = projectConfig || {};
194
+ }
226
195
  }
227
196
 
228
- // Create session
229
- const fullPath = path.resolve(ctx.projectDir, documentPath);
230
- const port = await findFreePort(9001, 9100);
231
- const workDir = path.dirname(fullPath);
197
+ // Auto-parse frontmatter if not provided
198
+ if (!frontmatter) {
199
+ try {
200
+ const content = fs.readFileSync(documentPath, 'utf8');
201
+ frontmatter = Project.parseFrontmatter(content);
202
+ } catch (e) {
203
+ frontmatter = null;
204
+ }
205
+ }
232
206
 
233
- const proc = spawn('julia', [
234
- '-e',
235
- `using MrmdJulia; MrmdJulia.serve(${port})`,
236
- ], {
237
- cwd: workDir,
238
- stdio: ['pipe', 'pipe', 'pipe'],
239
- });
207
+ const result = await juliaSessionService.getForDocument(
208
+ documentPath,
209
+ projectConfig,
210
+ frontmatter,
211
+ projectRoot
212
+ );
240
213
 
241
- try {
242
- await waitForPort(port, 15000);
243
- } catch (err) {
244
- proc.kill();
245
- return res.json(null);
214
+ // Add url if we have a port
215
+ if (result?.port) {
216
+ result.url = `http://localhost:${result.port}/mrp/v1`;
246
217
  }
247
218
 
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
- });
219
+ res.json(result);
260
220
  } catch (err) {
261
221
  console.error('[julia:forDocument]', err);
262
- res.status(500).json({ error: err.message });
222
+ res.json(null);
263
223
  }
264
224
  });
265
225
 
266
226
  return router;
267
227
  }
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
- }
@@ -12,6 +12,23 @@ import { existsSync, readFileSync } from 'fs';
12
12
  import { watch } from 'chokidar';
13
13
  import { Project } from 'mrmd-project';
14
14
 
15
+ const DOC_EXTENSIONS = ['.md', '.qmd'];
16
+
17
+ function isDocFile(filePath) {
18
+ if (!filePath) return false;
19
+ const lower = filePath.toLowerCase();
20
+ return DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
21
+ }
22
+
23
+ function stripDocExtension(fileName) {
24
+ if (!fileName) return '';
25
+ const lower = fileName.toLowerCase();
26
+ for (const ext of DOC_EXTENSIONS) {
27
+ if (lower.endsWith(ext)) return fileName.slice(0, -ext.length);
28
+ }
29
+ return fileName;
30
+ }
31
+
15
32
  /**
16
33
  * Detect project from a file path
17
34
  * Returns { root, config } or null if not in a project
@@ -226,7 +243,7 @@ export function createProjectRoutes(ctx) {
226
243
  });
227
244
 
228
245
  watcher.on('all', (event, filePath) => {
229
- if (filePath.endsWith('.md')) {
246
+ if (isDocFile(filePath)) {
230
247
  ctx.eventBus.projectChanged(watchPath);
231
248
  }
232
249
  });
@@ -411,7 +428,7 @@ async function buildNavTree(projectRoot, relativePath = '') {
411
428
 
412
429
  if (entry.isDirectory()) {
413
430
  const children = await buildNavTree(projectRoot, entryRelPath);
414
- // Only include directories that have .md files (directly or nested)
431
+ // Only include directories that have markdown-like doc files (directly or nested)
415
432
  if (children.length > 0 || await hasIndexFile(path.join(projectRoot, entryRelPath))) {
416
433
  nodes.push({
417
434
  isFolder: true,
@@ -420,10 +437,10 @@ async function buildNavTree(projectRoot, relativePath = '') {
420
437
  children,
421
438
  });
422
439
  }
423
- } else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
440
+ } else if (isDocFile(entry.name) && entry.name !== 'mrmd.md') {
424
441
  nodes.push({
425
442
  isFolder: false,
426
- title: cleanName(entry.name.replace(/\.md$/, '')),
443
+ title: cleanName(stripDocExtension(entry.name)),
427
444
  path: entryRelPath,
428
445
  });
429
446
  }
@@ -438,7 +455,7 @@ async function buildNavTree(projectRoot, relativePath = '') {
438
455
  async function hasIndexFile(dirPath) {
439
456
  try {
440
457
  const entries = await fs.readdir(dirPath);
441
- return entries.some(e => e.endsWith('.md'));
458
+ return entries.some(e => isDocFile(e));
442
459
  } catch {
443
460
  return false;
444
461
  }
package/src/api/r.js CHANGED
@@ -1,17 +1,56 @@
1
1
  /**
2
2
  * R Session API routes
3
3
  *
4
- * Mirrors electronAPI.r.* (if it existed in preload)
5
- * Provides R runtime management similar to Julia
4
+ * Mirrors electronAPI.r.* using RSessionService from mrmd-electron
6
5
  */
7
6
 
8
7
  import { Router } from 'express';
8
+ import { Project } from 'mrmd-project';
9
9
  import { spawn } from 'child_process';
10
+ import fs from 'fs';
10
11
  import path from 'path';
11
- import net from 'net';
12
12
 
13
- // Session registry: sessionName -> { port, process, cwd }
14
- const sessions = new Map();
13
+ /**
14
+ * Detect project from a file path
15
+ */
16
+ function detectProject(filePath) {
17
+ const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
18
+ if (!root) return null;
19
+
20
+ try {
21
+ const mrmdPath = path.join(root, 'mrmd.md');
22
+ const content = fs.readFileSync(mrmdPath, 'utf8');
23
+ const config = Project.parseConfig(content);
24
+ return { root, config };
25
+ } catch (e) {
26
+ return { root, config: {} };
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if R is available on the system
32
+ */
33
+ async function isRAvailable() {
34
+ return new Promise((resolve) => {
35
+ const proc = spawn('R', ['--version'], {
36
+ stdio: ['pipe', 'pipe', 'pipe'],
37
+ });
38
+
39
+ proc.on('close', (code) => {
40
+ resolve(code === 0);
41
+ });
42
+
43
+ proc.on('error', () => {
44
+ resolve(false);
45
+ });
46
+
47
+ // Timeout after 5 seconds
48
+ setTimeout(() => {
49
+ proc.kill();
50
+ resolve(false);
51
+ }, 5000);
52
+ });
53
+ }
15
54
 
16
55
  /**
17
56
  * Create R routes
@@ -19,22 +58,16 @@ const sessions = new Map();
19
58
  */
20
59
  export function createRRoutes(ctx) {
21
60
  const router = Router();
61
+ const { rSessionService } = ctx;
22
62
 
23
63
  /**
24
64
  * GET /api/r
25
65
  * List all running R sessions
66
+ * Mirrors: electronAPI.r.list()
26
67
  */
27
68
  router.get('/', async (req, res) => {
28
69
  try {
29
- const list = [];
30
- for (const [name, session] of sessions) {
31
- list.push({
32
- name,
33
- port: session.port,
34
- cwd: session.cwd,
35
- running: session.process && !session.process.killed,
36
- });
37
- }
70
+ const list = rSessionService.list();
38
71
  res.json(list);
39
72
  } catch (err) {
40
73
  console.error('[r:list]', err);
@@ -59,13 +92,13 @@ export function createRRoutes(ctx) {
59
92
  /**
60
93
  * POST /api/r
61
94
  * Start a new R session
95
+ * Mirrors: electronAPI.r.start(config)
62
96
  */
63
97
  router.post('/', async (req, res) => {
64
98
  try {
65
99
  const { config } = req.body;
66
- const { name, cwd } = config || {};
67
100
 
68
- if (!name) {
101
+ if (!config?.name) {
69
102
  return res.status(400).json({ error: 'config.name required' });
70
103
  }
71
104
 
@@ -74,57 +107,14 @@ export function createRRoutes(ctx) {
74
107
  return res.status(503).json({ error: 'R is not available on this system' });
75
108
  }
76
109
 
77
- // Check if session already exists
78
- if (sessions.has(name)) {
79
- const existing = sessions.get(name);
80
- if (existing.process && !existing.process.killed) {
81
- return res.json({
82
- name,
83
- port: existing.port,
84
- cwd: existing.cwd,
85
- reused: true,
86
- });
87
- }
88
- }
89
-
90
- // Find free port
91
- const port = await findFreePort(9101, 9200);
92
- const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
93
-
94
- // Start R MRP server
95
- // Note: This assumes mrmd-r is installed and provides an MRP-compatible server
96
- const proc = spawn('Rscript', [
97
- '-e',
98
- `mrmd.r::serve(${port})`,
99
- ], {
100
- cwd: workDir,
101
- stdio: ['pipe', 'pipe', 'pipe'],
102
- });
103
-
104
- // Wait for server to start (with timeout)
105
- try {
106
- await waitForPort(port, 15000);
107
- } catch (err) {
108
- proc.kill();
109
- return res.status(500).json({ error: `R server failed to start: ${err.message}` });
110
- }
111
-
112
- sessions.set(name, {
113
- port,
114
- process: proc,
115
- cwd: workDir,
116
- });
117
-
118
- proc.on('exit', (code) => {
119
- console.log(`[r] ${name} exited with code ${code}`);
120
- sessions.delete(name);
121
- });
110
+ const result = await rSessionService.start(config);
122
111
 
123
112
  res.json({
124
- name,
125
- port,
126
- cwd: workDir,
127
- url: `http://localhost:${port}/mrp/v1`,
113
+ name: result.name,
114
+ port: result.port,
115
+ cwd: result.cwd,
116
+ pid: result.pid,
117
+ url: `http://localhost:${result.port}/mrp/v1`,
128
118
  });
129
119
  } catch (err) {
130
120
  console.error('[r:start]', err);
@@ -135,53 +125,36 @@ export function createRRoutes(ctx) {
135
125
  /**
136
126
  * DELETE /api/r/:name
137
127
  * Stop an R session
128
+ * Mirrors: electronAPI.r.stop(sessionName)
138
129
  */
139
130
  router.delete('/:name', async (req, res) => {
140
131
  try {
141
132
  const { name } = req.params;
142
- const session = sessions.get(name);
143
-
144
- if (!session) {
145
- return res.json({ success: true, message: 'Session not found' });
146
- }
147
-
148
- if (session.process && !session.process.killed) {
149
- session.process.kill();
150
- }
151
-
152
- sessions.delete(name);
133
+ await rSessionService.stop(name);
153
134
  res.json({ success: true });
154
135
  } catch (err) {
155
136
  console.error('[r:stop]', err);
156
- res.status(500).json({ error: err.message });
137
+ res.json({ success: true, message: err.message });
157
138
  }
158
139
  });
159
140
 
160
141
  /**
161
142
  * POST /api/r/:name/restart
162
143
  * Restart an R session
144
+ * Mirrors: electronAPI.r.restart(sessionName)
163
145
  */
164
146
  router.post('/:name/restart', async (req, res) => {
165
147
  try {
166
148
  const { name } = req.params;
167
- const session = sessions.get(name);
168
-
169
- if (!session) {
170
- return res.status(404).json({ error: 'Session not found' });
171
- }
172
-
173
- // Kill existing
174
- if (session.process && !session.process.killed) {
175
- session.process.kill();
176
- }
177
-
178
- // Re-create
179
- const cwd = session.cwd;
180
- sessions.delete(name);
149
+ const result = await rSessionService.restart(name);
181
150
 
182
- // Forward to start handler
183
- req.body.config = { name, cwd };
184
- return res.redirect(307, '/api/r');
151
+ res.json({
152
+ name: result.name,
153
+ port: result.port,
154
+ cwd: result.cwd,
155
+ pid: result.pid,
156
+ url: `http://localhost:${result.port}/mrp/v1`,
157
+ });
185
158
  } catch (err) {
186
159
  console.error('[r:restart]', err);
187
160
  res.status(500).json({ error: err.message });
@@ -191,10 +164,14 @@ export function createRRoutes(ctx) {
191
164
  /**
192
165
  * POST /api/r/for-document
193
166
  * Get or create R session for a document
167
+ * Mirrors: electronAPI.r.forDocument(documentPath)
168
+ *
169
+ * Automatically detects project if projectConfig/projectRoot not provided
194
170
  */
195
171
  router.post('/for-document', async (req, res) => {
196
172
  try {
197
- const { documentPath } = req.body;
173
+ let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
174
+
198
175
  if (!documentPath) {
199
176
  return res.status(400).json({ error: 'documentPath required' });
200
177
  }
@@ -204,134 +181,46 @@ export function createRRoutes(ctx) {
204
181
  return res.json(null);
205
182
  }
206
183
 
207
- const docName = `r-${path.basename(documentPath, '.md')}`;
208
-
209
- // Check if session exists
210
- if (sessions.has(docName)) {
211
- const session = sessions.get(docName);
212
- return res.json({
213
- name: docName,
214
- port: session.port,
215
- cwd: session.cwd,
216
- url: `http://localhost:${session.port}/mrp/v1`,
217
- });
184
+ // Auto-detect project if not provided
185
+ if (!projectConfig || !projectRoot) {
186
+ const detected = detectProject(documentPath);
187
+ if (detected) {
188
+ projectRoot = projectRoot || detected.root;
189
+ projectConfig = projectConfig || detected.config;
190
+ } else {
191
+ projectRoot = projectRoot || (ctx.projectDir || process.cwd());
192
+ projectConfig = projectConfig || {};
193
+ }
218
194
  }
219
195
 
220
- // Create session
221
- const fullPath = path.resolve(ctx.projectDir, documentPath);
222
- const port = await findFreePort(9101, 9200);
223
- const workDir = path.dirname(fullPath);
196
+ // Auto-parse frontmatter if not provided
197
+ if (!frontmatter) {
198
+ try {
199
+ const content = fs.readFileSync(documentPath, 'utf8');
200
+ frontmatter = Project.parseFrontmatter(content);
201
+ } catch (e) {
202
+ frontmatter = null;
203
+ }
204
+ }
224
205
 
225
- const proc = spawn('Rscript', [
226
- '-e',
227
- `mrmd.r::serve(${port})`,
228
- ], {
229
- cwd: workDir,
230
- stdio: ['pipe', 'pipe', 'pipe'],
231
- });
206
+ const result = await rSessionService.getForDocument(
207
+ documentPath,
208
+ projectConfig,
209
+ frontmatter,
210
+ projectRoot
211
+ );
232
212
 
233
- try {
234
- await waitForPort(port, 15000);
235
- } catch (err) {
236
- proc.kill();
237
- return res.json(null);
213
+ // Add url if we have a port
214
+ if (result?.port) {
215
+ result.url = `http://localhost:${result.port}/mrp/v1`;
238
216
  }
239
217
 
240
- sessions.set(docName, {
241
- port,
242
- process: proc,
243
- cwd: workDir,
244
- });
245
-
246
- res.json({
247
- name: docName,
248
- port,
249
- cwd: workDir,
250
- url: `http://localhost:${port}/mrp/v1`,
251
- });
218
+ res.json(result);
252
219
  } catch (err) {
253
220
  console.error('[r:forDocument]', err);
254
- res.status(500).json({ error: err.message });
221
+ res.json(null);
255
222
  }
256
223
  });
257
224
 
258
225
  return router;
259
226
  }
260
-
261
- /**
262
- * Check if R is available
263
- */
264
- async function isRAvailable() {
265
- return new Promise((resolve) => {
266
- const proc = spawn('R', ['--version'], {
267
- stdio: ['pipe', 'pipe', 'pipe'],
268
- });
269
-
270
- proc.on('close', (code) => {
271
- resolve(code === 0);
272
- });
273
-
274
- proc.on('error', () => {
275
- resolve(false);
276
- });
277
-
278
- // Timeout after 5 seconds
279
- setTimeout(() => {
280
- proc.kill();
281
- resolve(false);
282
- }, 5000);
283
- });
284
- }
285
-
286
- /**
287
- * Find a free port in range
288
- */
289
- async function findFreePort(start, end) {
290
- for (let port = start; port <= end; port++) {
291
- if (await isPortFree(port)) {
292
- return port;
293
- }
294
- }
295
- throw new Error(`No free port found in range ${start}-${end}`);
296
- }
297
-
298
- /**
299
- * Check if port is free
300
- */
301
- function isPortFree(port) {
302
- return new Promise((resolve) => {
303
- const server = net.createServer();
304
- server.once('error', () => resolve(false));
305
- server.once('listening', () => {
306
- server.close();
307
- resolve(true);
308
- });
309
- server.listen(port, '127.0.0.1');
310
- });
311
- }
312
-
313
- /**
314
- * Wait for port to be open
315
- */
316
- function waitForPort(port, timeout = 10000) {
317
- return new Promise((resolve, reject) => {
318
- const start = Date.now();
319
-
320
- function check() {
321
- const socket = net.connect(port, '127.0.0.1');
322
- socket.once('connect', () => {
323
- socket.end();
324
- resolve();
325
- });
326
- socket.once('error', () => {
327
- if (Date.now() - start > timeout) {
328
- reject(new Error(`Timeout waiting for port ${port}`));
329
- } else {
330
- setTimeout(check, 200);
331
- }
332
- });
333
- }
334
-
335
- check();
336
- });
337
- }
@@ -299,7 +299,8 @@
299
299
 
300
300
  // Extract filename without extension for docName
301
301
  const fileName = filePath.split('/').pop();
302
- const docName = fileName.replace(/\.md$/, '');
302
+ const lower = fileName.toLowerCase();
303
+ const docName = lower.endsWith('.md') ? fileName.replace(/\.md$/i, '') : fileName;
303
304
 
304
305
  // Use syncPort from project response (dynamically assigned per-project)
305
306
  const syncPort = project?.syncPort || 4444;