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/src/api/bash.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Session API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.bash.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import net from 'net';
|
|
11
|
+
|
|
12
|
+
// Bash session registry
|
|
13
|
+
const bashSessions = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create bash routes
|
|
17
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
18
|
+
*/
|
|
19
|
+
export function createBashRoutes(ctx) {
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /api/bash
|
|
24
|
+
* List all running bash sessions
|
|
25
|
+
* Mirrors: electronAPI.bash.list()
|
|
26
|
+
*/
|
|
27
|
+
router.get('/', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const list = [];
|
|
30
|
+
for (const [name, session] of bashSessions) {
|
|
31
|
+
list.push({
|
|
32
|
+
name,
|
|
33
|
+
port: session.port,
|
|
34
|
+
cwd: session.cwd,
|
|
35
|
+
running: session.process && !session.process.killed,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
res.json(list);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('[bash:list]', err);
|
|
41
|
+
res.status(500).json({ error: err.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POST /api/bash
|
|
47
|
+
* Start a new bash session
|
|
48
|
+
* Mirrors: electronAPI.bash.start(config)
|
|
49
|
+
*/
|
|
50
|
+
router.post('/', async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { config } = req.body;
|
|
53
|
+
const { name, cwd } = config || {};
|
|
54
|
+
|
|
55
|
+
if (!name) {
|
|
56
|
+
return res.status(400).json({ error: 'config.name required' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if session already exists
|
|
60
|
+
if (bashSessions.has(name)) {
|
|
61
|
+
const existing = bashSessions.get(name);
|
|
62
|
+
if (existing.process && !existing.process.killed) {
|
|
63
|
+
return res.json({
|
|
64
|
+
name,
|
|
65
|
+
port: existing.port,
|
|
66
|
+
cwd: existing.cwd,
|
|
67
|
+
reused: true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Find free port
|
|
73
|
+
const port = await findFreePort(8101, 8200);
|
|
74
|
+
|
|
75
|
+
const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
|
|
76
|
+
|
|
77
|
+
// Try to find mrmd-bash package
|
|
78
|
+
const mrmdBashPaths = [
|
|
79
|
+
path.join(ctx.projectDir, '../mrmd-bash'),
|
|
80
|
+
path.join(process.cwd(), '../mrmd-bash'),
|
|
81
|
+
path.join(process.cwd(), 'mrmd-bash'),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
let mrmdBashPath = null;
|
|
85
|
+
for (const p of mrmdBashPaths) {
|
|
86
|
+
try {
|
|
87
|
+
const fs = await import('fs/promises');
|
|
88
|
+
await fs.access(path.join(p, 'pyproject.toml'));
|
|
89
|
+
mrmdBashPath = p;
|
|
90
|
+
break;
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let proc;
|
|
95
|
+
if (mrmdBashPath) {
|
|
96
|
+
proc = spawn('uv', [
|
|
97
|
+
'run', '--project', mrmdBashPath,
|
|
98
|
+
'mrmd-bash',
|
|
99
|
+
'--port', port.toString(),
|
|
100
|
+
], {
|
|
101
|
+
cwd: workDir,
|
|
102
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
// Fallback: assume mrmd-bash is installed
|
|
106
|
+
proc = spawn('mrmd-bash', [
|
|
107
|
+
'--port', port.toString(),
|
|
108
|
+
], {
|
|
109
|
+
cwd: workDir,
|
|
110
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wait for server to start
|
|
115
|
+
await waitForPort(port, 15000);
|
|
116
|
+
|
|
117
|
+
bashSessions.set(name, {
|
|
118
|
+
port,
|
|
119
|
+
process: proc,
|
|
120
|
+
cwd: workDir,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
proc.on('exit', (code) => {
|
|
124
|
+
console.log(`[bash] ${name} exited with code ${code}`);
|
|
125
|
+
bashSessions.delete(name);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
res.json({
|
|
129
|
+
name,
|
|
130
|
+
port,
|
|
131
|
+
cwd: workDir,
|
|
132
|
+
url: `http://localhost:${port}/mrp/v1`,
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('[bash:start]', err);
|
|
136
|
+
res.status(500).json({ error: err.message });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* DELETE /api/bash/:name
|
|
142
|
+
* Stop a bash session
|
|
143
|
+
* Mirrors: electronAPI.bash.stop(sessionName)
|
|
144
|
+
*/
|
|
145
|
+
router.delete('/:name', async (req, res) => {
|
|
146
|
+
try {
|
|
147
|
+
const { name } = req.params;
|
|
148
|
+
const session = bashSessions.get(name);
|
|
149
|
+
|
|
150
|
+
if (!session) {
|
|
151
|
+
return res.json({ success: true, message: 'Session not found' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (session.process && !session.process.killed) {
|
|
155
|
+
session.process.kill();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
bashSessions.delete(name);
|
|
159
|
+
res.json({ success: true });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[bash:stop]', err);
|
|
162
|
+
res.status(500).json({ error: err.message });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* POST /api/bash/:name/restart
|
|
168
|
+
* Restart a bash session
|
|
169
|
+
* Mirrors: electronAPI.bash.restart(sessionName)
|
|
170
|
+
*/
|
|
171
|
+
router.post('/:name/restart', async (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const { name } = req.params;
|
|
174
|
+
const session = bashSessions.get(name);
|
|
175
|
+
|
|
176
|
+
if (!session) {
|
|
177
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Kill existing
|
|
181
|
+
if (session.process && !session.process.killed) {
|
|
182
|
+
session.process.kill();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
bashSessions.delete(name);
|
|
186
|
+
|
|
187
|
+
// Re-create
|
|
188
|
+
req.body.config = { name, cwd: session.cwd };
|
|
189
|
+
|
|
190
|
+
// Use the POST handler
|
|
191
|
+
const handler = router.stack.find(r => r.route?.path === '/' && r.route.methods.post);
|
|
192
|
+
if (handler) {
|
|
193
|
+
return handler.route.stack[0].handle(req, res);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
res.status(500).json({ error: 'Could not restart' });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error('[bash:restart]', err);
|
|
199
|
+
res.status(500).json({ error: err.message });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* POST /api/bash/for-document
|
|
205
|
+
* Get or create bash session for a document
|
|
206
|
+
* Mirrors: electronAPI.bash.forDocument(documentPath)
|
|
207
|
+
*/
|
|
208
|
+
router.post('/for-document', async (req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
const { documentPath } = req.body;
|
|
211
|
+
if (!documentPath) {
|
|
212
|
+
return res.status(400).json({ error: 'documentPath required' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const docName = `bash-${path.basename(documentPath, '.md')}`;
|
|
216
|
+
|
|
217
|
+
// Check if session exists
|
|
218
|
+
if (bashSessions.has(docName)) {
|
|
219
|
+
const session = bashSessions.get(docName);
|
|
220
|
+
return res.json({
|
|
221
|
+
name: docName,
|
|
222
|
+
port: session.port,
|
|
223
|
+
cwd: session.cwd,
|
|
224
|
+
url: `http://localhost:${session.port}/mrp/v1`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create session in document's directory
|
|
229
|
+
const fullPath = path.resolve(ctx.projectDir, documentPath);
|
|
230
|
+
req.body.config = {
|
|
231
|
+
name: docName,
|
|
232
|
+
cwd: path.dirname(fullPath),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Use the POST handler
|
|
236
|
+
const handler = router.stack.find(r => r.route?.path === '/' && r.route.methods.post);
|
|
237
|
+
if (handler) {
|
|
238
|
+
return handler.route.stack[0].handle(req, res);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
res.status(500).json({ error: 'Could not create session' });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error('[bash:forDocument]', err);
|
|
244
|
+
res.status(500).json({ error: err.message });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return router;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function findFreePort(start, end) {
|
|
252
|
+
for (let port = start; port <= end; port++) {
|
|
253
|
+
if (await isPortFree(port)) {
|
|
254
|
+
return port;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`No free port found in range ${start}-${end}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isPortFree(port) {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
const server = net.createServer();
|
|
263
|
+
server.once('error', () => resolve(false));
|
|
264
|
+
server.once('listening', () => {
|
|
265
|
+
server.close();
|
|
266
|
+
resolve(true);
|
|
267
|
+
});
|
|
268
|
+
server.listen(port, '127.0.0.1');
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function waitForPort(port, timeout = 10000) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const start = Date.now();
|
|
275
|
+
|
|
276
|
+
function check() {
|
|
277
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
278
|
+
socket.once('connect', () => {
|
|
279
|
+
socket.end();
|
|
280
|
+
resolve();
|
|
281
|
+
});
|
|
282
|
+
socket.once('error', () => {
|
|
283
|
+
if (Date.now() - start > timeout) {
|
|
284
|
+
reject(new Error(`Timeout waiting for port ${port}`));
|
|
285
|
+
} else {
|
|
286
|
+
setTimeout(check, 200);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
check();
|
|
292
|
+
});
|
|
293
|
+
}
|
package/src/api/file.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.file.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import { constants as fsConstants } from 'fs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create file routes
|
|
14
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
15
|
+
*/
|
|
16
|
+
export function createFileRoutes(ctx) {
|
|
17
|
+
const router = Router();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GET /api/file/scan?root=...&extensions=...&maxDepth=...
|
|
21
|
+
* Scan files in a directory
|
|
22
|
+
* Mirrors: electronAPI.file.scan(root, options)
|
|
23
|
+
*/
|
|
24
|
+
router.get('/scan', async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const root = req.query.root || ctx.projectDir;
|
|
27
|
+
const extensions = req.query.extensions?.split(',') || ['.md'];
|
|
28
|
+
const maxDepth = parseInt(req.query.maxDepth) || 6;
|
|
29
|
+
const includeHidden = req.query.includeHidden === 'true';
|
|
30
|
+
|
|
31
|
+
const files = await scanDirectory(root, extensions, maxDepth, includeHidden);
|
|
32
|
+
res.json(files);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[file:scan]', err);
|
|
35
|
+
res.status(500).json({ error: err.message });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /api/file/create
|
|
41
|
+
* Create a file
|
|
42
|
+
* Mirrors: electronAPI.file.create(filePath, content)
|
|
43
|
+
*/
|
|
44
|
+
router.post('/create', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const { filePath, content = '' } = req.body;
|
|
47
|
+
if (!filePath) {
|
|
48
|
+
return res.status(400).json({ error: 'filePath required' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
52
|
+
|
|
53
|
+
// Create directory if needed
|
|
54
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Check if file exists
|
|
57
|
+
try {
|
|
58
|
+
await fs.access(fullPath, fsConstants.F_OK);
|
|
59
|
+
return res.status(409).json({ error: 'File already exists' });
|
|
60
|
+
} catch {
|
|
61
|
+
// File doesn't exist, good to create
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
65
|
+
|
|
66
|
+
ctx.eventBus.projectChanged(ctx.projectDir);
|
|
67
|
+
res.json({ success: true, path: fullPath });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error('[file:create]', err);
|
|
70
|
+
res.status(500).json({ error: err.message });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* POST /api/file/create-in-project
|
|
76
|
+
* Create a file within a project (handles FSML ordering)
|
|
77
|
+
* Mirrors: electronAPI.file.createInProject(projectRoot, relativePath, content)
|
|
78
|
+
*/
|
|
79
|
+
router.post('/create-in-project', async (req, res) => {
|
|
80
|
+
try {
|
|
81
|
+
const { projectRoot, relativePath, content = '' } = req.body;
|
|
82
|
+
if (!relativePath) {
|
|
83
|
+
return res.status(400).json({ error: 'relativePath required' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const root = projectRoot || ctx.projectDir;
|
|
87
|
+
const fullPath = resolveSafePath(root, relativePath);
|
|
88
|
+
|
|
89
|
+
// Create directory if needed
|
|
90
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
91
|
+
|
|
92
|
+
// If file exists, find next available FSML name
|
|
93
|
+
let finalPath = fullPath;
|
|
94
|
+
try {
|
|
95
|
+
await fs.access(fullPath, fsConstants.F_OK);
|
|
96
|
+
// File exists, generate unique name
|
|
97
|
+
const dir = path.dirname(fullPath);
|
|
98
|
+
const ext = path.extname(fullPath);
|
|
99
|
+
const base = path.basename(fullPath, ext);
|
|
100
|
+
|
|
101
|
+
let counter = 1;
|
|
102
|
+
while (true) {
|
|
103
|
+
finalPath = path.join(dir, `${base}-${counter}${ext}`);
|
|
104
|
+
try {
|
|
105
|
+
await fs.access(finalPath, fsConstants.F_OK);
|
|
106
|
+
counter++;
|
|
107
|
+
} catch {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// File doesn't exist, use original path
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await fs.writeFile(finalPath, content, 'utf-8');
|
|
116
|
+
|
|
117
|
+
ctx.eventBus.projectChanged(root);
|
|
118
|
+
res.json({
|
|
119
|
+
success: true,
|
|
120
|
+
path: path.relative(root, finalPath),
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error('[file:createInProject]', err);
|
|
124
|
+
res.status(500).json({ error: err.message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* POST /api/file/move
|
|
130
|
+
* Move/rename a file (with automatic refactoring)
|
|
131
|
+
* Mirrors: electronAPI.file.move(projectRoot, fromPath, toPath)
|
|
132
|
+
*/
|
|
133
|
+
router.post('/move', async (req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const { projectRoot, fromPath, toPath } = req.body;
|
|
136
|
+
if (!fromPath || !toPath) {
|
|
137
|
+
return res.status(400).json({ error: 'fromPath and toPath required' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const root = projectRoot || ctx.projectDir;
|
|
141
|
+
const fullFromPath = resolveSafePath(root, fromPath);
|
|
142
|
+
const fullToPath = resolveSafePath(root, toPath);
|
|
143
|
+
|
|
144
|
+
// Create destination directory if needed
|
|
145
|
+
await fs.mkdir(path.dirname(fullToPath), { recursive: true });
|
|
146
|
+
|
|
147
|
+
// Move the file
|
|
148
|
+
await fs.rename(fullFromPath, fullToPath);
|
|
149
|
+
|
|
150
|
+
// TODO: Update internal links in other files (refactoring)
|
|
151
|
+
// This would require parsing all .md files and updating links
|
|
152
|
+
// For now, just return the moved file
|
|
153
|
+
|
|
154
|
+
ctx.eventBus.projectChanged(root);
|
|
155
|
+
res.json({
|
|
156
|
+
success: true,
|
|
157
|
+
movedFile: {
|
|
158
|
+
from: fromPath,
|
|
159
|
+
to: toPath,
|
|
160
|
+
},
|
|
161
|
+
updatedFiles: [], // TODO: implement link refactoring
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error('[file:move]', err);
|
|
165
|
+
res.status(500).json({ error: err.message });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* POST /api/file/reorder
|
|
171
|
+
* Reorder a file/folder (drag-drop with FSML ordering)
|
|
172
|
+
* Mirrors: electronAPI.file.reorder(projectRoot, sourcePath, targetPath, position)
|
|
173
|
+
*/
|
|
174
|
+
router.post('/reorder', async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const { projectRoot, sourcePath, targetPath, position } = req.body;
|
|
177
|
+
if (!sourcePath || !targetPath || !position) {
|
|
178
|
+
return res.status(400).json({ error: 'sourcePath, targetPath, and position required' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const root = projectRoot || ctx.projectDir;
|
|
182
|
+
|
|
183
|
+
// TODO: Implement FSML reordering
|
|
184
|
+
// This involves:
|
|
185
|
+
// 1. Reading the source and target directories
|
|
186
|
+
// 2. Calculating new FSML prefixes
|
|
187
|
+
// 3. Renaming files with new prefixes
|
|
188
|
+
|
|
189
|
+
// For now, just do a simple move
|
|
190
|
+
const fullSourcePath = resolveSafePath(root, sourcePath);
|
|
191
|
+
let fullTargetPath;
|
|
192
|
+
|
|
193
|
+
if (position === 'inside') {
|
|
194
|
+
// Move into target directory
|
|
195
|
+
fullTargetPath = resolveSafePath(root, path.join(targetPath, path.basename(sourcePath)));
|
|
196
|
+
} else {
|
|
197
|
+
// Move to same directory as target
|
|
198
|
+
fullTargetPath = resolveSafePath(root, path.join(path.dirname(targetPath), path.basename(sourcePath)));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await fs.mkdir(path.dirname(fullTargetPath), { recursive: true });
|
|
202
|
+
await fs.rename(fullSourcePath, fullTargetPath);
|
|
203
|
+
|
|
204
|
+
ctx.eventBus.projectChanged(root);
|
|
205
|
+
res.json({
|
|
206
|
+
success: true,
|
|
207
|
+
movedFile: {
|
|
208
|
+
from: sourcePath,
|
|
209
|
+
to: path.relative(root, fullTargetPath),
|
|
210
|
+
},
|
|
211
|
+
updatedFiles: [],
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error('[file:reorder]', err);
|
|
215
|
+
res.status(500).json({ error: err.message });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* DELETE /api/file?path=...
|
|
221
|
+
* Delete a file
|
|
222
|
+
* Mirrors: electronAPI.file.delete(filePath)
|
|
223
|
+
*/
|
|
224
|
+
router.delete('/', async (req, res) => {
|
|
225
|
+
try {
|
|
226
|
+
const filePath = req.query.path;
|
|
227
|
+
if (!filePath) {
|
|
228
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
232
|
+
|
|
233
|
+
const stat = await fs.stat(fullPath);
|
|
234
|
+
if (stat.isDirectory()) {
|
|
235
|
+
await fs.rm(fullPath, { recursive: true });
|
|
236
|
+
} else {
|
|
237
|
+
await fs.unlink(fullPath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
ctx.eventBus.projectChanged(ctx.projectDir);
|
|
241
|
+
res.json({ success: true });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error('[file:delete]', err);
|
|
244
|
+
res.status(500).json({ error: err.message });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* GET /api/file/read?path=...
|
|
250
|
+
* Read a file
|
|
251
|
+
* Mirrors: electronAPI.file.read(filePath)
|
|
252
|
+
*/
|
|
253
|
+
router.get('/read', async (req, res) => {
|
|
254
|
+
try {
|
|
255
|
+
const filePath = req.query.path;
|
|
256
|
+
if (!filePath) {
|
|
257
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
261
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
262
|
+
|
|
263
|
+
res.json({ success: true, content });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (err.code === 'ENOENT') {
|
|
266
|
+
return res.status(404).json({ success: false, error: 'File not found' });
|
|
267
|
+
}
|
|
268
|
+
console.error('[file:read]', err);
|
|
269
|
+
res.status(500).json({ success: false, error: err.message });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* POST /api/file/write
|
|
275
|
+
* Write a file
|
|
276
|
+
* Mirrors: electronAPI.file.write(filePath, content)
|
|
277
|
+
*/
|
|
278
|
+
router.post('/write', async (req, res) => {
|
|
279
|
+
try {
|
|
280
|
+
const { filePath, content } = req.body;
|
|
281
|
+
if (!filePath) {
|
|
282
|
+
return res.status(400).json({ error: 'filePath required' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
286
|
+
|
|
287
|
+
// Create directory if needed
|
|
288
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
289
|
+
|
|
290
|
+
await fs.writeFile(fullPath, content ?? '', 'utf-8');
|
|
291
|
+
|
|
292
|
+
res.json({ success: true });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error('[file:write]', err);
|
|
295
|
+
res.status(500).json({ error: err.message });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* GET /api/file/preview?path=...&lines=...
|
|
301
|
+
* Read file preview
|
|
302
|
+
* Mirrors: electronAPI.readPreview(filePath, lines)
|
|
303
|
+
*/
|
|
304
|
+
router.get('/preview', async (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
const filePath = req.query.path;
|
|
307
|
+
const lines = parseInt(req.query.lines) || 40;
|
|
308
|
+
|
|
309
|
+
if (!filePath) {
|
|
310
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
314
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
315
|
+
const previewLines = content.split('\n').slice(0, lines).join('\n');
|
|
316
|
+
|
|
317
|
+
res.json({ success: true, content: previewLines });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err.code === 'ENOENT') {
|
|
320
|
+
return res.status(404).json({ success: false, error: 'File not found' });
|
|
321
|
+
}
|
|
322
|
+
console.error('[file:preview]', err);
|
|
323
|
+
res.status(500).json({ success: false, error: err.message });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* GET /api/file/info?path=...
|
|
329
|
+
* Get file info
|
|
330
|
+
* Mirrors: electronAPI.getFileInfo(filePath)
|
|
331
|
+
*/
|
|
332
|
+
router.get('/info', async (req, res) => {
|
|
333
|
+
try {
|
|
334
|
+
const filePath = req.query.path;
|
|
335
|
+
if (!filePath) {
|
|
336
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const fullPath = resolveSafePath(ctx.projectDir, filePath);
|
|
340
|
+
const stat = await fs.stat(fullPath);
|
|
341
|
+
|
|
342
|
+
res.json({
|
|
343
|
+
path: fullPath,
|
|
344
|
+
size: stat.size,
|
|
345
|
+
modified: stat.mtime.toISOString(),
|
|
346
|
+
created: stat.birthtime.toISOString(),
|
|
347
|
+
isDirectory: stat.isDirectory(),
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err.code === 'ENOENT') {
|
|
351
|
+
return res.status(404).json({ error: 'File not found' });
|
|
352
|
+
}
|
|
353
|
+
console.error('[file:info]', err);
|
|
354
|
+
res.status(500).json({ error: err.message });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return router;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve path safely within project directory
|
|
363
|
+
*/
|
|
364
|
+
function resolveSafePath(projectDir, relativePath) {
|
|
365
|
+
const resolved = path.resolve(projectDir, relativePath);
|
|
366
|
+
|
|
367
|
+
// Security: ensure resolved path is within project directory
|
|
368
|
+
if (!resolved.startsWith(path.resolve(projectDir))) {
|
|
369
|
+
throw new Error('Path traversal not allowed');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return resolved;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Scan directory for files
|
|
377
|
+
*/
|
|
378
|
+
async function scanDirectory(root, extensions, maxDepth, includeHidden, currentDepth = 0) {
|
|
379
|
+
if (currentDepth > maxDepth) return [];
|
|
380
|
+
|
|
381
|
+
const files = [];
|
|
382
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
383
|
+
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
// Skip hidden files unless requested
|
|
386
|
+
if (!includeHidden && entry.name.startsWith('.')) continue;
|
|
387
|
+
|
|
388
|
+
// Skip common non-content directories
|
|
389
|
+
if (entry.name === 'node_modules') continue;
|
|
390
|
+
if (entry.name === '__pycache__') continue;
|
|
391
|
+
if (entry.name === '.git') continue;
|
|
392
|
+
|
|
393
|
+
const fullPath = path.join(root, entry.name);
|
|
394
|
+
|
|
395
|
+
if (entry.isDirectory()) {
|
|
396
|
+
const subFiles = await scanDirectory(fullPath, extensions, maxDepth, includeHidden, currentDepth + 1);
|
|
397
|
+
files.push(...subFiles);
|
|
398
|
+
} else {
|
|
399
|
+
const ext = path.extname(entry.name);
|
|
400
|
+
if (extensions.includes(ext)) {
|
|
401
|
+
files.push(fullPath);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return files;
|
|
407
|
+
}
|
package/src/api/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API route exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { createProjectRoutes } from './project.js';
|
|
6
|
+
export { createSessionRoutes } from './session.js';
|
|
7
|
+
export { createBashRoutes } from './bash.js';
|
|
8
|
+
export { createFileRoutes } from './file.js';
|
|
9
|
+
export { createAssetRoutes } from './asset.js';
|
|
10
|
+
export { createSystemRoutes } from './system.js';
|
|
11
|
+
export { createRuntimeRoutes } from './runtime.js';
|