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 +199 -116
- package/package.json +3 -3
- package/src/api/file.js +2 -2
- package/src/api/julia.js +97 -215
- package/src/api/project.js +22 -5
- package/src/api/r.js +102 -213
- package/static/http-shim.js +2 -1
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MRMD Server
|
|
2
2
|
|
|
3
|
-
Run
|
|
3
|
+
**Run MRMD in any browser.** Access your markdown notebooks from anywhere — your phone, tablet, or any machine with a browser.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/mrmd-server) [](LICENSE)
|
|
4
6
|
|
|
5
7
|
```
|
|
6
8
|
┌─────────────────────────────────────────────────────────────┐
|
|
7
|
-
│ Your VPS / Cloud
|
|
9
|
+
│ Your Server / VPS / Cloud │
|
|
8
10
|
│ │
|
|
9
11
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
10
12
|
│ │ mrmd-server │ │
|
|
11
|
-
│ │ •
|
|
12
|
-
│ │ •
|
|
13
|
-
│ │ •
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
npm install
|
|
68
|
+
npm install -g mrmd-server
|
|
69
|
+
```
|
|
42
70
|
|
|
43
|
-
|
|
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
|
-
###
|
|
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
|
|
136
|
+
mrmd-server --no-auth
|
|
76
137
|
```
|
|
77
138
|
|
|
78
|
-
|
|
139
|
+
---
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
mrmd-server -p 8080 /home/you/notebooks
|
|
83
|
-
```
|
|
141
|
+
## Remote Access Setup
|
|
84
142
|
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
113
|
-
- Real-time collaborative editing
|
|
114
|
-
- Code execution
|
|
115
|
-
-
|
|
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
|
-
|
|
196
|
+
MRMD Server mirrors the Electron app's IPC interface as an HTTP API:
|
|
120
197
|
|
|
121
|
-
| Electron (IPC) |
|
|
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
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`
|
|
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
|
-
//
|
|
251
|
+
// Events: 'project:changed', 'venv-found', 'sync-server-died', etc.
|
|
196
252
|
};
|
|
197
253
|
```
|
|
198
254
|
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
268
|
+
Some desktop features don't work in browser mode:
|
|
209
269
|
|
|
210
270
|
| Feature | Browser Behavior |
|
|
211
271
|
|---------|------------------|
|
|
212
|
-
|
|
|
213
|
-
|
|
|
214
|
-
|
|
|
215
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"mrmd-project": "^0.1.2",
|
|
49
49
|
"mrmd-electron": "^0.3.4",
|
|
50
|
-
"mrmd-sync": "^0.3.
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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:
|
|
130
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
});
|
|
207
|
+
const result = await juliaSessionService.getForDocument(
|
|
208
|
+
documentPath,
|
|
209
|
+
projectConfig,
|
|
210
|
+
frontmatter,
|
|
211
|
+
projectRoot
|
|
212
|
+
);
|
|
240
213
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|
package/src/api/project.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
440
|
+
} else if (isDocFile(entry.name) && entry.name !== 'mrmd.md') {
|
|
424
441
|
nodes.push({
|
|
425
442
|
isFolder: false,
|
|
426
|
-
title: cleanName(entry.name
|
|
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
|
|
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.*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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:
|
|
127
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
});
|
|
206
|
+
const result = await rSessionService.getForDocument(
|
|
207
|
+
documentPath,
|
|
208
|
+
projectConfig,
|
|
209
|
+
frontmatter,
|
|
210
|
+
projectRoot
|
|
211
|
+
);
|
|
232
212
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|
package/static/http-shim.js
CHANGED
|
@@ -299,7 +299,8 @@
|
|
|
299
299
|
|
|
300
300
|
// Extract filename without extension for docName
|
|
301
301
|
const fileName = filePath.split('/').pop();
|
|
302
|
-
const
|
|
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;
|