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.
@@ -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
+ }
@@ -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
+ }
@@ -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';