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/julia.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Julia Session API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.julia.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import net from 'net';
|
|
12
|
+
|
|
13
|
+
// Session registry: sessionName -> { port, process, cwd }
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create Julia routes
|
|
18
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
19
|
+
*/
|
|
20
|
+
export function createJuliaRoutes(ctx) {
|
|
21
|
+
const router = Router();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GET /api/julia
|
|
25
|
+
* List all running Julia sessions
|
|
26
|
+
* Mirrors: electronAPI.julia.list()
|
|
27
|
+
*/
|
|
28
|
+
router.get('/', async (req, res) => {
|
|
29
|
+
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
|
+
}
|
|
39
|
+
res.json(list);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('[julia:list]', err);
|
|
42
|
+
res.status(500).json({ error: err.message });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* GET /api/julia/available
|
|
48
|
+
* Check if Julia is available on the system
|
|
49
|
+
* Mirrors: electronAPI.julia.isAvailable()
|
|
50
|
+
*/
|
|
51
|
+
router.get('/available', async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const available = await isJuliaAvailable();
|
|
54
|
+
res.json({ available });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('[julia:available]', err);
|
|
57
|
+
res.json({ available: false });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* POST /api/julia
|
|
63
|
+
* Start a new Julia session
|
|
64
|
+
* Mirrors: electronAPI.julia.start(config)
|
|
65
|
+
*/
|
|
66
|
+
router.post('/', async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const { config } = req.body;
|
|
69
|
+
const { name, cwd } = config || {};
|
|
70
|
+
|
|
71
|
+
if (!name) {
|
|
72
|
+
return res.status(400).json({ error: 'config.name required' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if Julia is available
|
|
76
|
+
if (!await isJuliaAvailable()) {
|
|
77
|
+
return res.status(503).json({ error: 'Julia is not available on this system' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check if session already exists
|
|
81
|
+
if (sessions.has(name)) {
|
|
82
|
+
const existing = sessions.get(name);
|
|
83
|
+
if (existing.process && !existing.process.killed) {
|
|
84
|
+
return res.json({
|
|
85
|
+
name,
|
|
86
|
+
port: existing.port,
|
|
87
|
+
cwd: existing.cwd,
|
|
88
|
+
reused: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Find free port
|
|
94
|
+
const port = await findFreePort(9001, 9100);
|
|
95
|
+
const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
|
|
96
|
+
|
|
97
|
+
// Start Julia MRP server
|
|
98
|
+
// Note: This assumes mrmd-julia is installed and provides an MRP-compatible server
|
|
99
|
+
const proc = spawn('julia', [
|
|
100
|
+
'-e',
|
|
101
|
+
`using MrmdJulia; MrmdJulia.serve(${port})`,
|
|
102
|
+
], {
|
|
103
|
+
cwd: workDir,
|
|
104
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Wait for server to start (with timeout)
|
|
108
|
+
try {
|
|
109
|
+
await waitForPort(port, 15000);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
proc.kill();
|
|
112
|
+
return res.status(500).json({ error: `Julia server failed to start: ${err.message}` });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sessions.set(name, {
|
|
116
|
+
port,
|
|
117
|
+
process: proc,
|
|
118
|
+
cwd: workDir,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.on('exit', (code) => {
|
|
122
|
+
console.log(`[julia] ${name} exited with code ${code}`);
|
|
123
|
+
sessions.delete(name);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
res.json({
|
|
127
|
+
name,
|
|
128
|
+
port,
|
|
129
|
+
cwd: workDir,
|
|
130
|
+
url: `http://localhost:${port}/mrp/v1`,
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('[julia:start]', err);
|
|
134
|
+
res.status(500).json({ error: err.message });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* DELETE /api/julia/:name
|
|
140
|
+
* Stop a Julia session
|
|
141
|
+
* Mirrors: electronAPI.julia.stop(sessionName)
|
|
142
|
+
*/
|
|
143
|
+
router.delete('/:name', async (req, res) => {
|
|
144
|
+
try {
|
|
145
|
+
const { name } = req.params;
|
|
146
|
+
const session = sessions.get(name);
|
|
147
|
+
|
|
148
|
+
if (!session) {
|
|
149
|
+
return res.json({ success: true, message: 'Session not found' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (session.process && !session.process.killed) {
|
|
153
|
+
session.process.kill();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
sessions.delete(name);
|
|
157
|
+
res.json({ success: true });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('[julia:stop]', err);
|
|
160
|
+
res.status(500).json({ error: err.message });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* POST /api/julia/:name/restart
|
|
166
|
+
* Restart a Julia session
|
|
167
|
+
* Mirrors: electronAPI.julia.restart(sessionName)
|
|
168
|
+
*/
|
|
169
|
+
router.post('/:name/restart', async (req, res) => {
|
|
170
|
+
try {
|
|
171
|
+
const { name } = req.params;
|
|
172
|
+
const session = sessions.get(name);
|
|
173
|
+
|
|
174
|
+
if (!session) {
|
|
175
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Kill existing
|
|
179
|
+
if (session.process && !session.process.killed) {
|
|
180
|
+
session.process.kill();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Re-create
|
|
184
|
+
const cwd = session.cwd;
|
|
185
|
+
sessions.delete(name);
|
|
186
|
+
|
|
187
|
+
// Forward to start handler
|
|
188
|
+
req.body.config = { name, cwd };
|
|
189
|
+
// Recursively call POST /
|
|
190
|
+
// In production, extract logic to shared function
|
|
191
|
+
return res.redirect(307, '/api/julia');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error('[julia:restart]', err);
|
|
194
|
+
res.status(500).json({ error: err.message });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* POST /api/julia/for-document
|
|
200
|
+
* Get or create Julia session for a document
|
|
201
|
+
* Mirrors: electronAPI.julia.forDocument(documentPath)
|
|
202
|
+
*/
|
|
203
|
+
router.post('/for-document', async (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
const { documentPath } = req.body;
|
|
206
|
+
if (!documentPath) {
|
|
207
|
+
return res.status(400).json({ error: 'documentPath required' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check if Julia is available
|
|
211
|
+
if (!await isJuliaAvailable()) {
|
|
212
|
+
return res.json(null);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const docName = `julia-${path.basename(documentPath, '.md')}`;
|
|
216
|
+
|
|
217
|
+
// Check if session exists
|
|
218
|
+
if (sessions.has(docName)) {
|
|
219
|
+
const session = sessions.get(docName);
|
|
220
|
+
return res.json({
|
|
221
|
+
name: docName,
|
|
222
|
+
port: session.port,
|
|
223
|
+
cwd: session.cwd,
|
|
224
|
+
url: `http://localhost:${session.port}/mrp/v1`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create session
|
|
229
|
+
const fullPath = path.resolve(ctx.projectDir, documentPath);
|
|
230
|
+
const port = await findFreePort(9001, 9100);
|
|
231
|
+
const workDir = path.dirname(fullPath);
|
|
232
|
+
|
|
233
|
+
const proc = spawn('julia', [
|
|
234
|
+
'-e',
|
|
235
|
+
`using MrmdJulia; MrmdJulia.serve(${port})`,
|
|
236
|
+
], {
|
|
237
|
+
cwd: workDir,
|
|
238
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await waitForPort(port, 15000);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
proc.kill();
|
|
245
|
+
return res.json(null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
sessions.set(docName, {
|
|
249
|
+
port,
|
|
250
|
+
process: proc,
|
|
251
|
+
cwd: workDir,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
res.json({
|
|
255
|
+
name: docName,
|
|
256
|
+
port,
|
|
257
|
+
cwd: workDir,
|
|
258
|
+
url: `http://localhost:${port}/mrp/v1`,
|
|
259
|
+
});
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.error('[julia:forDocument]', err);
|
|
262
|
+
res.status(500).json({ error: err.message });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return router;
|
|
267
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.project.*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import { watch } from 'chokidar';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create project routes
|
|
14
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
15
|
+
*/
|
|
16
|
+
export function createProjectRoutes(ctx) {
|
|
17
|
+
const router = Router();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GET /api/project?path=...
|
|
21
|
+
* Get project info for a file path
|
|
22
|
+
* Mirrors: electronAPI.project.get(filePath)
|
|
23
|
+
*/
|
|
24
|
+
router.get('/', async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const filePath = req.query.path;
|
|
27
|
+
if (!filePath) {
|
|
28
|
+
return res.status(400).json({ error: 'path query parameter required' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const projectInfo = await getProjectInfo(filePath, ctx.projectDir);
|
|
32
|
+
res.json(projectInfo);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[project:get]', err);
|
|
35
|
+
res.status(500).json({ error: err.message });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /api/project
|
|
41
|
+
* Create a new mrmd project
|
|
42
|
+
* Mirrors: electronAPI.project.create(targetPath)
|
|
43
|
+
*/
|
|
44
|
+
router.post('/', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const { targetPath } = req.body;
|
|
47
|
+
if (!targetPath) {
|
|
48
|
+
return res.status(400).json({ error: 'targetPath required' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resolvedPath = path.resolve(ctx.projectDir, targetPath);
|
|
52
|
+
|
|
53
|
+
// Create directory if it doesn't exist
|
|
54
|
+
await fs.mkdir(resolvedPath, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Create mrmd.md config file
|
|
57
|
+
const mrmdPath = path.join(resolvedPath, 'mrmd.md');
|
|
58
|
+
const mrmdContent = `# ${path.basename(resolvedPath)}
|
|
59
|
+
|
|
60
|
+
A new mrmd project.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
venv: .venv
|
|
64
|
+
---
|
|
65
|
+
`;
|
|
66
|
+
await fs.writeFile(mrmdPath, mrmdContent);
|
|
67
|
+
|
|
68
|
+
// Get and return project info
|
|
69
|
+
const projectInfo = await getProjectInfo(mrmdPath, ctx.projectDir);
|
|
70
|
+
res.json(projectInfo);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error('[project:create]', err);
|
|
73
|
+
res.status(500).json({ error: err.message });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* GET /api/project/nav?root=...
|
|
79
|
+
* Get navigation tree for a project
|
|
80
|
+
* Mirrors: electronAPI.project.nav(projectRoot)
|
|
81
|
+
*/
|
|
82
|
+
router.get('/nav', async (req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
const projectRoot = req.query.root || ctx.projectDir;
|
|
85
|
+
const navTree = await buildNavTree(projectRoot);
|
|
86
|
+
res.json(navTree);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('[project:nav]', err);
|
|
89
|
+
res.status(500).json({ error: err.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/project/invalidate
|
|
95
|
+
* Invalidate cached project info
|
|
96
|
+
* Mirrors: electronAPI.project.invalidate(projectRoot)
|
|
97
|
+
*/
|
|
98
|
+
router.post('/invalidate', async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const { projectRoot } = req.body;
|
|
101
|
+
// In this implementation we don't cache, so this is a no-op
|
|
102
|
+
// but we emit an event so the UI can refresh
|
|
103
|
+
ctx.eventBus.projectChanged(projectRoot || ctx.projectDir);
|
|
104
|
+
res.json({ success: true });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('[project:invalidate]', err);
|
|
107
|
+
res.status(500).json({ error: err.message });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* POST /api/project/watch
|
|
113
|
+
* Watch project for file changes
|
|
114
|
+
* Mirrors: electronAPI.project.watch(projectRoot)
|
|
115
|
+
*/
|
|
116
|
+
router.post('/watch', async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const { projectRoot } = req.body;
|
|
119
|
+
const watchPath = projectRoot || ctx.projectDir;
|
|
120
|
+
|
|
121
|
+
// Close existing watcher if any
|
|
122
|
+
if (ctx.watchers.has(watchPath)) {
|
|
123
|
+
await ctx.watchers.get(watchPath).close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create new watcher
|
|
127
|
+
const watcher = watch(watchPath, {
|
|
128
|
+
ignored: /(^|[\/\\])\.|node_modules|\.git|__pycache__|\.mrmd-sync/,
|
|
129
|
+
persistent: true,
|
|
130
|
+
ignoreInitial: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
watcher.on('all', (event, filePath) => {
|
|
134
|
+
if (filePath.endsWith('.md')) {
|
|
135
|
+
ctx.eventBus.projectChanged(watchPath);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
ctx.watchers.set(watchPath, watcher);
|
|
140
|
+
res.json({ success: true, watching: watchPath });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('[project:watch]', err);
|
|
143
|
+
res.status(500).json({ error: err.message });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* POST /api/project/unwatch
|
|
149
|
+
* Stop watching project
|
|
150
|
+
* Mirrors: electronAPI.project.unwatch()
|
|
151
|
+
*/
|
|
152
|
+
router.post('/unwatch', async (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
// Close all watchers
|
|
155
|
+
for (const [watchPath, watcher] of ctx.watchers) {
|
|
156
|
+
await watcher.close();
|
|
157
|
+
ctx.watchers.delete(watchPath);
|
|
158
|
+
}
|
|
159
|
+
res.json({ success: true });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[project:unwatch]', err);
|
|
162
|
+
res.status(500).json({ error: err.message });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return router;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get project info for a file path
|
|
171
|
+
*/
|
|
172
|
+
async function getProjectInfo(filePath, defaultRoot) {
|
|
173
|
+
const resolvedPath = path.resolve(defaultRoot, filePath);
|
|
174
|
+
|
|
175
|
+
// Find project root by walking up to find mrmd.md
|
|
176
|
+
let projectRoot = path.dirname(resolvedPath);
|
|
177
|
+
let mrmdConfig = null;
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < 10; i++) {
|
|
180
|
+
const mrmdPath = path.join(projectRoot, 'mrmd.md');
|
|
181
|
+
try {
|
|
182
|
+
const content = await fs.readFile(mrmdPath, 'utf-8');
|
|
183
|
+
mrmdConfig = parseMrmdConfig(content);
|
|
184
|
+
break;
|
|
185
|
+
} catch {
|
|
186
|
+
const parent = path.dirname(projectRoot);
|
|
187
|
+
if (parent === projectRoot) break;
|
|
188
|
+
projectRoot = parent;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If no mrmd.md found, use the provided directory
|
|
193
|
+
if (!mrmdConfig) {
|
|
194
|
+
projectRoot = defaultRoot;
|
|
195
|
+
mrmdConfig = { venv: '.venv' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build nav tree
|
|
199
|
+
const navTree = await buildNavTree(projectRoot);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
root: projectRoot,
|
|
203
|
+
config: mrmdConfig,
|
|
204
|
+
navTree,
|
|
205
|
+
currentFile: resolvedPath,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse mrmd.md config (frontmatter)
|
|
211
|
+
*/
|
|
212
|
+
function parseMrmdConfig(content) {
|
|
213
|
+
const config = { venv: '.venv' };
|
|
214
|
+
|
|
215
|
+
// Simple YAML frontmatter parsing
|
|
216
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
217
|
+
if (match) {
|
|
218
|
+
const yaml = match[1];
|
|
219
|
+
const venvMatch = yaml.match(/venv:\s*(.+)/);
|
|
220
|
+
if (venvMatch) {
|
|
221
|
+
config.venv = venvMatch[1].trim();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return config;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build navigation tree for a project
|
|
230
|
+
*/
|
|
231
|
+
async function buildNavTree(projectRoot, relativePath = '') {
|
|
232
|
+
const fullPath = path.join(projectRoot, relativePath);
|
|
233
|
+
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
234
|
+
const nodes = [];
|
|
235
|
+
|
|
236
|
+
// Sort entries: directories first, then files, alphabetically
|
|
237
|
+
entries.sort((a, b) => {
|
|
238
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
239
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
240
|
+
// Handle FSML ordering (numeric prefixes)
|
|
241
|
+
const aNum = parseInt(a.name.match(/^(\d+)/)?.[1] || '999');
|
|
242
|
+
const bNum = parseInt(b.name.match(/^(\d+)/)?.[1] || '999');
|
|
243
|
+
if (aNum !== bNum) return aNum - bNum;
|
|
244
|
+
return a.name.localeCompare(b.name);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
// Skip hidden files and special directories
|
|
249
|
+
if (entry.name.startsWith('.')) continue;
|
|
250
|
+
if (entry.name === 'node_modules') continue;
|
|
251
|
+
if (entry.name === '__pycache__') continue;
|
|
252
|
+
if (entry.name === '_assets') continue;
|
|
253
|
+
|
|
254
|
+
const entryRelPath = path.join(relativePath, entry.name);
|
|
255
|
+
|
|
256
|
+
if (entry.isDirectory()) {
|
|
257
|
+
const children = await buildNavTree(projectRoot, entryRelPath);
|
|
258
|
+
// Only include directories that have .md files (directly or nested)
|
|
259
|
+
if (children.length > 0 || await hasIndexFile(path.join(projectRoot, entryRelPath))) {
|
|
260
|
+
nodes.push({
|
|
261
|
+
type: 'folder',
|
|
262
|
+
name: cleanName(entry.name),
|
|
263
|
+
path: entryRelPath,
|
|
264
|
+
children,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
|
|
268
|
+
nodes.push({
|
|
269
|
+
type: 'file',
|
|
270
|
+
name: cleanName(entry.name.replace(/\.md$/, '')),
|
|
271
|
+
path: entryRelPath,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return nodes;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Check if directory has an index file
|
|
281
|
+
*/
|
|
282
|
+
async function hasIndexFile(dirPath) {
|
|
283
|
+
try {
|
|
284
|
+
const entries = await fs.readdir(dirPath);
|
|
285
|
+
return entries.some(e => e.endsWith('.md'));
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Clean FSML numeric prefix from name
|
|
293
|
+
*/
|
|
294
|
+
function cleanName(name) {
|
|
295
|
+
return name.replace(/^\d+[-_.\s]*/, '');
|
|
296
|
+
}
|