mrmd-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -0
- package/bin/cli.js +161 -0
- package/package.json +35 -0
- package/src/api/asset.js +283 -0
- package/src/api/bash.js +293 -0
- package/src/api/file.js +407 -0
- package/src/api/index.js +11 -0
- package/src/api/julia.js +345 -0
- package/src/api/project.js +296 -0
- package/src/api/pty.js +401 -0
- package/src/api/runtime.js +140 -0
- package/src/api/session.js +358 -0
- package/src/api/system.js +256 -0
- package/src/auth.js +60 -0
- package/src/events.js +50 -0
- package/src/index.js +9 -0
- package/src/server-v2.js +118 -0
- package/src/server.js +297 -0
- package/src/websocket.js +85 -0
- package/static/http-shim.js +371 -0
- package/static/index.html +171 -0
package/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# mrmd-server
|
|
2
|
+
|
|
3
|
+
Run mrmd in any browser. Access your notebooks from anywhere.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
7
|
+
│ Your VPS / Cloud Server │
|
|
8
|
+
│ │
|
|
9
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
10
|
+
│ │ mrmd-server │ │
|
|
11
|
+
│ │ • HTTP API (full electronAPI equivalent) │ │
|
|
12
|
+
│ │ • Static file serving │ │
|
|
13
|
+
│ │ • WebSocket for real-time events │ │
|
|
14
|
+
│ │ • Token authentication │ │
|
|
15
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
16
|
+
│ │
|
|
17
|
+
└─────────────────────────────────────────────────────────────┘
|
|
18
|
+
│
|
|
19
|
+
│ https://your-server.com?token=xxx
|
|
20
|
+
│
|
|
21
|
+
┌───────────┴───────────┬─────────────────┐
|
|
22
|
+
│ │ │
|
|
23
|
+
┌───▼───┐ ┌────▼────┐ ┌────▼────┐
|
|
24
|
+
│Laptop │ │ Phone │ │ Collab │
|
|
25
|
+
└───────┘ └─────────┘ └─────────┘
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
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
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Install
|
|
40
|
+
cd mrmd-packages/mrmd-server
|
|
41
|
+
npm install
|
|
42
|
+
|
|
43
|
+
# Start server in your project directory
|
|
44
|
+
npx mrmd-server ./my-notebooks
|
|
45
|
+
|
|
46
|
+
# Output:
|
|
47
|
+
# mrmd-server
|
|
48
|
+
# ──────────────────────────────────────────────────────
|
|
49
|
+
# Server: http://0.0.0.0:8080
|
|
50
|
+
# Project: /home/you/my-notebooks
|
|
51
|
+
# Token: abc123xyz...
|
|
52
|
+
#
|
|
53
|
+
# Access URL:
|
|
54
|
+
# http://localhost:8080?token=abc123xyz...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
### Basic Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Start in current directory
|
|
63
|
+
mrmd-server
|
|
64
|
+
|
|
65
|
+
# Start in specific directory
|
|
66
|
+
mrmd-server ./my-project
|
|
67
|
+
|
|
68
|
+
# Custom port
|
|
69
|
+
mrmd-server -p 3000 ./my-project
|
|
70
|
+
|
|
71
|
+
# With specific token
|
|
72
|
+
mrmd-server -t my-secret-token ./my-project
|
|
73
|
+
|
|
74
|
+
# No auth (local development only!)
|
|
75
|
+
mrmd-server --no-auth ./my-project
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Remote Access
|
|
79
|
+
|
|
80
|
+
1. Start mrmd-server on your VPS:
|
|
81
|
+
```bash
|
|
82
|
+
mrmd-server -p 8080 /home/you/notebooks
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
2. Set up HTTPS (recommended) with nginx or caddy:
|
|
86
|
+
```nginx
|
|
87
|
+
server {
|
|
88
|
+
listen 443 ssl;
|
|
89
|
+
server_name notebooks.example.com;
|
|
90
|
+
|
|
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
|
+
```
|
|
99
|
+
|
|
100
|
+
3. Access from anywhere:
|
|
101
|
+
```
|
|
102
|
+
https://notebooks.example.com?token=YOUR_TOKEN
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Share with Collaborators
|
|
106
|
+
|
|
107
|
+
Just share the URL with the token:
|
|
108
|
+
```
|
|
109
|
+
https://your-server.com?token=abc123xyz
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Collaborators get:
|
|
113
|
+
- Real-time collaborative editing (Yjs)
|
|
114
|
+
- Code execution (via the server)
|
|
115
|
+
- Same UI as local Electron app
|
|
116
|
+
|
|
117
|
+
## Architecture
|
|
118
|
+
|
|
119
|
+
mrmd-server provides an HTTP API that mirrors Electron's IPC interface:
|
|
120
|
+
|
|
121
|
+
| Electron (IPC) | mrmd-server (HTTP) |
|
|
122
|
+
|----------------|-------------------|
|
|
123
|
+
| `electronAPI.project.get(path)` | `GET /api/project?path=...` |
|
|
124
|
+
| `electronAPI.file.write(path, content)` | `POST /api/file/write` |
|
|
125
|
+
| `electronAPI.session.forDocument(path)` | `POST /api/session/for-document` |
|
|
126
|
+
| `ipcRenderer.on('project:changed', cb)` | WebSocket `/events` |
|
|
127
|
+
|
|
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.
|
|
129
|
+
|
|
130
|
+
## API Reference
|
|
131
|
+
|
|
132
|
+
### Authentication
|
|
133
|
+
|
|
134
|
+
All API endpoints (except `/health` and `/auth/validate`) require authentication.
|
|
135
|
+
|
|
136
|
+
Provide token via:
|
|
137
|
+
- Query parameter: `?token=xxx`
|
|
138
|
+
- Header: `Authorization: Bearer xxx`
|
|
139
|
+
- Header: `X-Token: xxx`
|
|
140
|
+
|
|
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
|
|
186
|
+
|
|
187
|
+
### WebSocket Events
|
|
188
|
+
|
|
189
|
+
Connect to `/events?token=xxx` to receive push events:
|
|
190
|
+
|
|
191
|
+
```javascript
|
|
192
|
+
const ws = new WebSocket('wss://server.com/events?token=xxx');
|
|
193
|
+
ws.onmessage = (e) => {
|
|
194
|
+
const { event, data } = JSON.parse(e.data);
|
|
195
|
+
// event: 'project:changed', 'venv-found', 'sync-server-died', etc.
|
|
196
|
+
};
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Security Considerations
|
|
200
|
+
|
|
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
|
|
205
|
+
|
|
206
|
+
## Limitations
|
|
207
|
+
|
|
208
|
+
Some Electron features can't work in browser:
|
|
209
|
+
|
|
210
|
+
| Feature | Browser Behavior |
|
|
211
|
+
|---------|------------------|
|
|
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 |
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# Run in dev mode
|
|
221
|
+
npm run dev
|
|
222
|
+
|
|
223
|
+
# The server will:
|
|
224
|
+
# - Watch for file changes
|
|
225
|
+
# - Auto-restart on changes
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mrmd-server CLI
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* mrmd-server [options] [project-dir]
|
|
8
|
+
*
|
|
9
|
+
* Options:
|
|
10
|
+
* -p, --port <port> HTTP port (default: 8080)
|
|
11
|
+
* -h, --host <host> Bind host (default: 0.0.0.0)
|
|
12
|
+
* -t, --token <token> Auth token (auto-generated if not provided)
|
|
13
|
+
* --no-auth Disable authentication (dangerous!)
|
|
14
|
+
* --help Show help
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createServer } from '../src/server.js';
|
|
18
|
+
import { spawn } from 'child_process';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
|
|
22
|
+
function parseArgs(args) {
|
|
23
|
+
const options = {
|
|
24
|
+
port: 8080,
|
|
25
|
+
host: '0.0.0.0',
|
|
26
|
+
token: null,
|
|
27
|
+
noAuth: false,
|
|
28
|
+
projectDir: '.',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
|
|
34
|
+
if (arg === '-p' || arg === '--port') {
|
|
35
|
+
options.port = parseInt(args[++i], 10);
|
|
36
|
+
} else if (arg === '-h' || arg === '--host') {
|
|
37
|
+
options.host = args[++i];
|
|
38
|
+
} else if (arg === '-t' || arg === '--token') {
|
|
39
|
+
options.token = args[++i];
|
|
40
|
+
} else if (arg === '--no-auth') {
|
|
41
|
+
options.noAuth = true;
|
|
42
|
+
} else if (arg === '--help') {
|
|
43
|
+
printHelp();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
} else if (!arg.startsWith('-')) {
|
|
46
|
+
options.projectDir = arg;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return options;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
console.log(`
|
|
55
|
+
mrmd-server - Run mrmd in any browser
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
mrmd-server [options] [project-dir]
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
-p, --port <port> HTTP port (default: 8080)
|
|
62
|
+
-h, --host <host> Bind host (default: 0.0.0.0)
|
|
63
|
+
-t, --token <token> Auth token (auto-generated if not provided)
|
|
64
|
+
--no-auth Disable authentication (DANGEROUS - local dev only)
|
|
65
|
+
--help Show this help
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
mrmd-server Start in current directory
|
|
69
|
+
mrmd-server ./my-project Start in specific directory
|
|
70
|
+
mrmd-server -p 3000 ./notebooks Custom port
|
|
71
|
+
mrmd-server --no-auth No auth (local dev only)
|
|
72
|
+
|
|
73
|
+
Access:
|
|
74
|
+
Once started, access via the URL shown (includes token).
|
|
75
|
+
Share the URL with collaborators for real-time editing.
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
const args = process.argv.slice(2);
|
|
81
|
+
const options = parseArgs(args);
|
|
82
|
+
|
|
83
|
+
// Resolve project directory
|
|
84
|
+
options.projectDir = path.resolve(options.projectDir);
|
|
85
|
+
|
|
86
|
+
// Verify directory exists
|
|
87
|
+
try {
|
|
88
|
+
await fs.access(options.projectDir);
|
|
89
|
+
} catch {
|
|
90
|
+
console.error(`Error: Directory not found: ${options.projectDir}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Find mrmd-electron for the UI
|
|
95
|
+
const packageDir = path.dirname(path.dirname(import.meta.url.replace('file://', '')));
|
|
96
|
+
const possibleElectronPaths = [
|
|
97
|
+
path.join(packageDir, '..', 'mrmd-electron'),
|
|
98
|
+
path.join(process.cwd(), '..', 'mrmd-electron'),
|
|
99
|
+
path.join(process.cwd(), 'mrmd-electron'),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
let electronDir = null;
|
|
103
|
+
for (const p of possibleElectronPaths) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.access(path.join(p, 'index.html'));
|
|
106
|
+
electronDir = p;
|
|
107
|
+
break;
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Create and start server
|
|
112
|
+
const server = await createServer({
|
|
113
|
+
...options,
|
|
114
|
+
electronDir,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Handle shutdown
|
|
118
|
+
process.on('SIGINT', async () => {
|
|
119
|
+
console.log('\nShutting down...');
|
|
120
|
+
await server.stop();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
process.on('SIGTERM', async () => {
|
|
125
|
+
await server.stop();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await server.start();
|
|
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
|
+
}
|
|
151
|
+
|
|
152
|
+
// Keep running
|
|
153
|
+
console.log('');
|
|
154
|
+
console.log(' Press Ctrl+C to stop');
|
|
155
|
+
console.log('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main().catch((err) => {
|
|
159
|
+
console.error('Fatal error:', err);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mrmd-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mrmd-server": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/cli.js",
|
|
12
|
+
"dev": "node bin/cli.js --dev"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mrmd",
|
|
16
|
+
"markdown",
|
|
17
|
+
"notebook",
|
|
18
|
+
"server",
|
|
19
|
+
"remote",
|
|
20
|
+
"collaboration"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"express": "^4.18.2",
|
|
28
|
+
"ws": "^8.16.0",
|
|
29
|
+
"cors": "^2.8.5",
|
|
30
|
+
"multer": "^1.4.5-lts.1",
|
|
31
|
+
"chokidar": "^3.6.0",
|
|
32
|
+
"fzf": "^0.5.2",
|
|
33
|
+
"mrmd-project": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/api/asset.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.asset.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import multer from 'multer';
|
|
12
|
+
|
|
13
|
+
// Configure multer for file uploads
|
|
14
|
+
const upload = multer({
|
|
15
|
+
storage: multer.memoryStorage(),
|
|
16
|
+
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create asset routes
|
|
21
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
22
|
+
*/
|
|
23
|
+
export function createAssetRoutes(ctx) {
|
|
24
|
+
const router = Router();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* GET /api/asset?projectRoot=...
|
|
28
|
+
* List all assets in a project
|
|
29
|
+
* Mirrors: electronAPI.asset.list(projectRoot)
|
|
30
|
+
*/
|
|
31
|
+
router.get('/', async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const projectRoot = req.query.projectRoot || ctx.projectDir;
|
|
34
|
+
const assetsDir = path.join(projectRoot, '_assets');
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const files = await fs.readdir(assetsDir);
|
|
38
|
+
const assets = [];
|
|
39
|
+
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (file.startsWith('.')) continue;
|
|
42
|
+
|
|
43
|
+
const filePath = path.join(assetsDir, file);
|
|
44
|
+
const stat = await fs.stat(filePath);
|
|
45
|
+
|
|
46
|
+
assets.push({
|
|
47
|
+
name: file,
|
|
48
|
+
path: `_assets/${file}`,
|
|
49
|
+
size: stat.size,
|
|
50
|
+
modified: stat.mtime.toISOString(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
res.json(assets);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code === 'ENOENT') {
|
|
57
|
+
return res.json([]);
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[asset:list]', err);
|
|
63
|
+
res.status(500).json({ error: err.message });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* POST /api/asset/save
|
|
69
|
+
* Save an asset (handles deduplication)
|
|
70
|
+
* Mirrors: electronAPI.asset.save(projectRoot, file, filename)
|
|
71
|
+
*
|
|
72
|
+
* Accepts multipart form data with 'file' field
|
|
73
|
+
* or JSON with 'file' as base64 or array of bytes
|
|
74
|
+
*/
|
|
75
|
+
router.post('/save', upload.single('file'), async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const projectRoot = req.body.projectRoot || ctx.projectDir;
|
|
78
|
+
let filename = req.body.filename || req.file?.originalname || 'untitled';
|
|
79
|
+
let fileBuffer;
|
|
80
|
+
|
|
81
|
+
if (req.file) {
|
|
82
|
+
// Multipart upload
|
|
83
|
+
fileBuffer = req.file.buffer;
|
|
84
|
+
} else if (req.body.file) {
|
|
85
|
+
// JSON with base64 or array
|
|
86
|
+
if (typeof req.body.file === 'string') {
|
|
87
|
+
fileBuffer = Buffer.from(req.body.file, 'base64');
|
|
88
|
+
} else if (Array.isArray(req.body.file)) {
|
|
89
|
+
fileBuffer = Buffer.from(req.body.file);
|
|
90
|
+
} else {
|
|
91
|
+
return res.status(400).json({ error: 'Invalid file format' });
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
return res.status(400).json({ error: 'No file provided' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const assetsDir = path.join(projectRoot, '_assets');
|
|
98
|
+
await fs.mkdir(assetsDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
// Compute hash for deduplication
|
|
101
|
+
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex').slice(0, 8);
|
|
102
|
+
const ext = path.extname(filename);
|
|
103
|
+
const base = path.basename(filename, ext);
|
|
104
|
+
|
|
105
|
+
// Check if identical file already exists
|
|
106
|
+
const existingFiles = await fs.readdir(assetsDir).catch(() => []);
|
|
107
|
+
for (const existing of existingFiles) {
|
|
108
|
+
if (existing.startsWith(hash + '-')) {
|
|
109
|
+
// Found duplicate
|
|
110
|
+
return res.json({
|
|
111
|
+
path: `_assets/${existing}`,
|
|
112
|
+
deduplicated: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Save with hash prefix
|
|
118
|
+
const finalName = `${hash}-${base}${ext}`;
|
|
119
|
+
const finalPath = path.join(assetsDir, finalName);
|
|
120
|
+
|
|
121
|
+
await fs.writeFile(finalPath, fileBuffer);
|
|
122
|
+
|
|
123
|
+
res.json({
|
|
124
|
+
path: `_assets/${finalName}`,
|
|
125
|
+
deduplicated: false,
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error('[asset:save]', err);
|
|
129
|
+
res.status(500).json({ error: err.message });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* GET /api/asset/relative-path?assetPath=...&documentPath=...
|
|
135
|
+
* Get relative path from document to asset
|
|
136
|
+
* Mirrors: electronAPI.asset.relativePath(assetPath, documentPath)
|
|
137
|
+
*/
|
|
138
|
+
router.get('/relative-path', async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const { assetPath, documentPath } = req.query;
|
|
141
|
+
|
|
142
|
+
if (!assetPath || !documentPath) {
|
|
143
|
+
return res.status(400).json({ error: 'assetPath and documentPath required' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Calculate relative path from document to asset
|
|
147
|
+
const docDir = path.dirname(documentPath);
|
|
148
|
+
const relativePath = path.relative(docDir, assetPath);
|
|
149
|
+
|
|
150
|
+
res.json({ relativePath });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error('[asset:relativePath]', err);
|
|
153
|
+
res.status(500).json({ error: err.message });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* GET /api/asset/orphans?projectRoot=...
|
|
159
|
+
* Find orphaned assets
|
|
160
|
+
* Mirrors: electronAPI.asset.orphans(projectRoot)
|
|
161
|
+
*/
|
|
162
|
+
router.get('/orphans', async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
const projectRoot = req.query.projectRoot || ctx.projectDir;
|
|
165
|
+
const assetsDir = path.join(projectRoot, '_assets');
|
|
166
|
+
|
|
167
|
+
// Get all assets
|
|
168
|
+
let assetFiles;
|
|
169
|
+
try {
|
|
170
|
+
assetFiles = await fs.readdir(assetsDir);
|
|
171
|
+
} catch {
|
|
172
|
+
return res.json([]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Get all markdown files
|
|
176
|
+
const mdFiles = await scanMarkdownFiles(projectRoot);
|
|
177
|
+
|
|
178
|
+
// Read all markdown content and find referenced assets
|
|
179
|
+
const referencedAssets = new Set();
|
|
180
|
+
for (const mdFile of mdFiles) {
|
|
181
|
+
try {
|
|
182
|
+
const content = await fs.readFile(mdFile, 'utf-8');
|
|
183
|
+
// Find asset references (images, links)
|
|
184
|
+
const matches = content.matchAll(/!\[.*?\]\(([^)]+)\)|href="([^"]+)"/g);
|
|
185
|
+
for (const match of matches) {
|
|
186
|
+
const ref = match[1] || match[2];
|
|
187
|
+
if (ref && ref.includes('_assets/')) {
|
|
188
|
+
const assetName = path.basename(ref);
|
|
189
|
+
referencedAssets.add(assetName);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Find orphans
|
|
196
|
+
const orphans = assetFiles.filter(f => !f.startsWith('.') && !referencedAssets.has(f));
|
|
197
|
+
|
|
198
|
+
res.json(orphans.map(f => `_assets/${f}`));
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error('[asset:orphans]', err);
|
|
201
|
+
res.status(500).json({ error: err.message });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* DELETE /api/asset?projectRoot=...&assetPath=...
|
|
207
|
+
* Delete an asset
|
|
208
|
+
* Mirrors: electronAPI.asset.delete(projectRoot, assetPath)
|
|
209
|
+
*/
|
|
210
|
+
router.delete('/', async (req, res) => {
|
|
211
|
+
try {
|
|
212
|
+
const projectRoot = req.query.projectRoot || ctx.projectDir;
|
|
213
|
+
const assetPath = req.query.assetPath;
|
|
214
|
+
|
|
215
|
+
if (!assetPath) {
|
|
216
|
+
return res.status(400).json({ error: 'assetPath required' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const fullPath = path.join(projectRoot, assetPath);
|
|
220
|
+
|
|
221
|
+
// Security check
|
|
222
|
+
if (!fullPath.startsWith(path.resolve(projectRoot))) {
|
|
223
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await fs.unlink(fullPath);
|
|
227
|
+
res.json({ success: true });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error('[asset:delete]', err);
|
|
230
|
+
res.status(500).json({ error: err.message });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* GET /api/asset/file/*
|
|
236
|
+
* Serve asset files (for image preview in browser)
|
|
237
|
+
*/
|
|
238
|
+
router.get('/file/*', async (req, res) => {
|
|
239
|
+
try {
|
|
240
|
+
const assetPath = req.params[0];
|
|
241
|
+
const fullPath = path.join(ctx.projectDir, '_assets', assetPath);
|
|
242
|
+
|
|
243
|
+
// Security check
|
|
244
|
+
if (!fullPath.startsWith(path.resolve(ctx.projectDir))) {
|
|
245
|
+
return res.status(400).json({ error: 'Invalid path' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
res.sendFile(fullPath);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('[asset:file]', err);
|
|
251
|
+
res.status(500).json({ error: err.message });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return router;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Recursively scan for markdown files
|
|
260
|
+
*/
|
|
261
|
+
async function scanMarkdownFiles(dir, maxDepth = 6, currentDepth = 0) {
|
|
262
|
+
if (currentDepth > maxDepth) return [];
|
|
263
|
+
|
|
264
|
+
const files = [];
|
|
265
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
266
|
+
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
if (entry.name.startsWith('.')) continue;
|
|
269
|
+
if (entry.name === 'node_modules') continue;
|
|
270
|
+
if (entry.name === '_assets') continue;
|
|
271
|
+
|
|
272
|
+
const fullPath = path.join(dir, entry.name);
|
|
273
|
+
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
const subFiles = await scanMarkdownFiles(fullPath, maxDepth, currentDepth + 1);
|
|
276
|
+
files.push(...subFiles);
|
|
277
|
+
} else if (entry.name.endsWith('.md')) {
|
|
278
|
+
files.push(fullPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return files;
|
|
283
|
+
}
|