mrmd-server 0.2.4 → 0.2.6

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.
@@ -6,34 +6,161 @@
6
6
  */
7
7
 
8
8
  import { Router } from 'express';
9
- import { Project } from 'mrmd-project';
10
9
  import fs from 'fs';
11
10
  import path from 'path';
12
11
 
13
12
  /**
14
- * Detect project from a file path.
13
+ * Resolve project root from a file path without requiring mrmd.md.
15
14
  */
16
- function detectProject(filePath) {
17
- const root = Project.findRoot(filePath, (dir) => fs.existsSync(path.join(dir, 'mrmd.md')));
18
- if (!root) return null;
15
+ function findGitRoot(startDir) {
16
+ let current = path.resolve(startDir || '/');
17
+ while (true) {
18
+ if (fs.existsSync(path.join(current, '.git'))) return current;
19
+ const parent = path.dirname(current);
20
+ if (!parent || parent === current) break;
21
+ current = parent;
22
+ }
23
+ return null;
24
+ }
19
25
 
26
+ function hasDocsInDir(dir) {
20
27
  try {
21
- const mrmdPath = path.join(root, 'mrmd.md');
22
- const content = fs.readFileSync(mrmdPath, 'utf8');
23
- const config = Project.parseConfig(content);
24
- return { root, config };
28
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ return entries.some((entry) => entry.isFile() && /\.(md|qmd)$/i.test(entry.name));
25
30
  } catch {
26
- return { root, config: {} };
31
+ return false;
27
32
  }
28
33
  }
29
34
 
35
+ function resolveProjectRoot(filePath, fallbackRoot = null) {
36
+ const abs = path.resolve(String(filePath || fallbackRoot || process.cwd()));
37
+ let startDir = abs;
38
+ try {
39
+ const stat = fs.statSync(abs);
40
+ if (stat.isFile()) startDir = path.dirname(abs);
41
+ } catch {
42
+ if (path.extname(abs)) startDir = path.dirname(abs);
43
+ }
44
+
45
+ const gitRoot = findGitRoot(startDir);
46
+ if (gitRoot) return gitRoot;
47
+
48
+ const homeDir = path.resolve(process.env.HOME || '/');
49
+ let current = startDir;
50
+ let candidate = startDir;
51
+ for (let i = 0; i < 4; i++) {
52
+ const parent = path.dirname(current);
53
+ if (!parent || parent === current || parent === '/' || parent === homeDir) break;
54
+ if (hasDocsInDir(parent)) {
55
+ candidate = parent;
56
+ current = parent;
57
+ continue;
58
+ }
59
+ break;
60
+ }
61
+
62
+ return candidate;
63
+ }
64
+
30
65
  /**
31
66
  * Create unified runtime routes.
32
67
  * @param {Object} ctx — server context with runtimeService
33
68
  */
34
69
  export function createRuntimeRoutes(ctx) {
35
70
  const router = Router();
36
- const { runtimeService } = ctx;
71
+ const { runtimeService, runtimePreferencesService } = ctx;
72
+
73
+ function normalizeRuntimeLanguage(language) {
74
+ const l = String(language || '').toLowerCase();
75
+ if (l === 'py' || l === 'python3') return 'python';
76
+ if (l === 'sh' || l === 'shell' || l === 'zsh') return 'bash';
77
+ if (l === 'rlang') return 'r';
78
+ if (l === 'jl') return 'julia';
79
+ if (l === 'term' || l === 'terminal') return 'pty';
80
+ return l;
81
+ }
82
+
83
+ async function ensureEffectiveRuntime(documentPath, language, options = {}) {
84
+ const normalized = normalizeRuntimeLanguage(language);
85
+ const supported = new Set(runtimeService.supportedLanguages());
86
+ if (!supported.has(normalized)) {
87
+ return {
88
+ language: normalized,
89
+ alive: false,
90
+ available: false,
91
+ error: `Unsupported runtime language: ${language}`,
92
+ };
93
+ }
94
+
95
+ const resolvedProjectRoot = resolveProjectRoot(documentPath, options.projectRoot || ctx.projectDir || process.cwd());
96
+
97
+ let effective = null;
98
+ let startConfig = null;
99
+ if (runtimePreferencesService?.getEffectiveForDocument) {
100
+ effective = await runtimePreferencesService.getEffectiveForDocument({
101
+ documentPath,
102
+ language: normalized,
103
+ projectRoot: resolvedProjectRoot,
104
+ deviceKind: options.deviceKind || 'desktop',
105
+ });
106
+ startConfig = runtimePreferencesService.toRuntimeStartConfig(effective);
107
+ } else {
108
+ const projectName = path.basename(resolvedProjectRoot) || 'project';
109
+ startConfig = {
110
+ name: `rt:notebook:${projectName}:${normalized}`,
111
+ language: normalized,
112
+ cwd: resolvedProjectRoot,
113
+ };
114
+ if (normalized === 'python') {
115
+ startConfig.venv = path.join(resolvedProjectRoot, '.venv');
116
+ }
117
+ }
118
+
119
+ // If tunnel provider is available, prefer it so execution runs on user's machine.
120
+ if (ctx.tunnelClient?.isAvailable()) {
121
+ try {
122
+ const tunnelResult = await ctx.tunnelClient.startRuntime({
123
+ language: normalized,
124
+ documentPath,
125
+ projectRoot: resolvedProjectRoot,
126
+ });
127
+ const langResult = tunnelResult?.[normalized];
128
+ if (langResult) {
129
+ return {
130
+ ...langResult,
131
+ id: langResult.name,
132
+ alive: !!langResult.alive,
133
+ available: true,
134
+ effective,
135
+ };
136
+ }
137
+ } catch (err) {
138
+ console.warn(`[runtime:ensure:${normalized}] Tunnel failed, falling back to local:`, err.message);
139
+ }
140
+ }
141
+
142
+ try {
143
+ const runtime = await runtimeService.start(startConfig);
144
+ return {
145
+ ...runtime,
146
+ id: runtime.name,
147
+ alive: true,
148
+ available: true,
149
+ autoStart: true,
150
+ effective,
151
+ };
152
+ } catch (e) {
153
+ return {
154
+ ...startConfig,
155
+ id: startConfig.name,
156
+ alive: false,
157
+ available: true,
158
+ autoStart: true,
159
+ error: e.message,
160
+ effective,
161
+ };
162
+ }
163
+ }
37
164
 
38
165
  /**
39
166
  * GET /api/runtime
@@ -42,6 +169,15 @@ export function createRuntimeRoutes(ctx) {
42
169
  */
43
170
  router.get('/', async (req, res) => {
44
171
  try {
172
+ if (ctx.tunnelClient?.isAvailable()) {
173
+ try {
174
+ const tunnelRuntimes = await ctx.tunnelClient.listRuntimes(req.query.language);
175
+ console.log('[runtime:list] Using tunnel — listing Electron runtimes');
176
+ return res.json(tunnelRuntimes);
177
+ } catch (err) {
178
+ console.warn('[runtime:list] Tunnel list failed, falling back to local:', err.message);
179
+ }
180
+ }
45
181
  res.json(runtimeService.list(req.query.language));
46
182
  } catch (err) {
47
183
  console.error('[runtime:list]', err);
@@ -60,6 +196,15 @@ export function createRuntimeRoutes(ctx) {
60
196
  if (!config?.name || !config?.language) {
61
197
  return res.status(400).json({ error: 'config.name and config.language required' });
62
198
  }
199
+ if (ctx.tunnelClient?.isAvailable()) {
200
+ try {
201
+ const tunnelResult = await ctx.tunnelClient.startRuntime(config);
202
+ console.log(`[runtime:start] Using tunnel — starting Electron runtime "${config.name}"`);
203
+ return res.json(tunnelResult?.[config.language] || tunnelResult);
204
+ } catch (err) {
205
+ console.warn(`[runtime:start] Tunnel start failed for "${config.name}", falling back:`, err.message);
206
+ }
207
+ }
63
208
  const result = await runtimeService.start(config);
64
209
  res.json(result);
65
210
  } catch (err) {
@@ -74,6 +219,15 @@ export function createRuntimeRoutes(ctx) {
74
219
  */
75
220
  router.delete('/:name', async (req, res) => {
76
221
  try {
222
+ if (ctx.tunnelClient?.isAvailable()) {
223
+ try {
224
+ const result = await ctx.tunnelClient.stopRuntime(req.params.name);
225
+ console.log(`[runtime:stop] Using tunnel — stopping Electron runtime "${req.params.name}"`);
226
+ return res.json(result);
227
+ } catch (err) {
228
+ console.warn(`[runtime:stop] Tunnel stop failed for "${req.params.name}", falling back:`, err.message);
229
+ }
230
+ }
77
231
  await runtimeService.stop(req.params.name);
78
232
  res.json({ success: true });
79
233
  } catch (err) {
@@ -88,6 +242,15 @@ export function createRuntimeRoutes(ctx) {
88
242
  */
89
243
  router.post('/:name/restart', async (req, res) => {
90
244
  try {
245
+ if (ctx.tunnelClient?.isAvailable()) {
246
+ try {
247
+ const result = await ctx.tunnelClient.restartRuntime(req.params.name);
248
+ console.log(`[runtime:restart] Using tunnel — restarting Electron runtime "${req.params.name}"`);
249
+ return res.json(result);
250
+ } catch (err) {
251
+ console.warn(`[runtime:restart] Tunnel restart failed for "${req.params.name}", falling back:`, err.message);
252
+ }
253
+ }
91
254
  const result = await runtimeService.restart(req.params.name);
92
255
  res.json(result);
93
256
  } catch (err) {
@@ -116,43 +279,25 @@ export function createRuntimeRoutes(ctx) {
116
279
  /**
117
280
  * POST /api/runtime/for-document
118
281
  * Get or create ALL runtimes needed for a document.
119
- * Body: { documentPath, projectConfig?, frontmatter?, projectRoot? }
120
- * Returns: { python: {...}, bash: {...}, r: {...}, julia: {...}, pty: {...} }
282
+ * Body: { documentPath, projectRoot?, deviceKind? }
121
283
  */
122
284
  router.post('/for-document', async (req, res) => {
123
285
  try {
124
- let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
125
-
286
+ const { documentPath, projectRoot, deviceKind } = req.body;
126
287
  if (!documentPath) {
127
288
  return res.status(400).json({ error: 'documentPath required' });
128
289
  }
129
290
 
130
- // Auto-detect project
131
- if (!projectConfig || !projectRoot) {
132
- const detected = detectProject(documentPath);
133
- if (detected) {
134
- projectRoot = projectRoot || detected.root;
135
- projectConfig = projectConfig || detected.config;
136
- } else {
137
- projectRoot = projectRoot || (ctx.projectDir || process.cwd());
138
- projectConfig = projectConfig || {};
139
- }
291
+ const out = {};
292
+ const languages = runtimeService.supportedLanguages();
293
+ for (const language of languages) {
294
+ out[language] = await ensureEffectiveRuntime(documentPath, language, {
295
+ projectRoot,
296
+ deviceKind,
297
+ });
140
298
  }
141
299
 
142
- // Auto-parse frontmatter
143
- if (!frontmatter) {
144
- try {
145
- const content = fs.readFileSync(documentPath, 'utf8');
146
- frontmatter = Project.parseFrontmatter(content);
147
- } catch {
148
- frontmatter = null;
149
- }
150
- }
151
-
152
- const result = await runtimeService.getForDocument(
153
- documentPath, projectConfig, frontmatter, projectRoot
154
- );
155
- res.json(result);
300
+ res.json(out);
156
301
  } catch (err) {
157
302
  console.error('[runtime:forDocument]', err);
158
303
  res.json(null);
@@ -162,40 +307,21 @@ export function createRuntimeRoutes(ctx) {
162
307
  /**
163
308
  * POST /api/runtime/for-document/:language
164
309
  * Get or create a runtime for a specific language.
165
- * Body: { documentPath, projectConfig?, frontmatter?, projectRoot? }
310
+ * Body: { documentPath, projectRoot?, deviceKind? }
166
311
  */
167
312
  router.post('/for-document/:language', async (req, res) => {
168
313
  try {
169
- let { documentPath, projectConfig, frontmatter, projectRoot } = req.body;
314
+ const { documentPath, projectRoot, deviceKind } = req.body;
170
315
  const { language } = req.params;
171
316
 
172
317
  if (!documentPath) {
173
318
  return res.status(400).json({ error: 'documentPath required' });
174
319
  }
175
320
 
176
- if (!projectConfig || !projectRoot) {
177
- const detected = detectProject(documentPath);
178
- if (detected) {
179
- projectRoot = projectRoot || detected.root;
180
- projectConfig = projectConfig || detected.config;
181
- } else {
182
- projectRoot = projectRoot || (ctx.projectDir || process.cwd());
183
- projectConfig = projectConfig || {};
184
- }
185
- }
186
-
187
- if (!frontmatter) {
188
- try {
189
- const content = fs.readFileSync(documentPath, 'utf8');
190
- frontmatter = Project.parseFrontmatter(content);
191
- } catch {
192
- frontmatter = null;
193
- }
194
- }
195
-
196
- const result = await runtimeService.getForDocumentLanguage(
197
- language, documentPath, projectConfig, frontmatter, projectRoot
198
- );
321
+ const result = await ensureEffectiveRuntime(documentPath, language, {
322
+ projectRoot,
323
+ deviceKind,
324
+ });
199
325
  res.json(result);
200
326
  } catch (err) {
201
327
  console.error('[runtime:forDocumentLanguage]', err);
@@ -211,6 +337,20 @@ export function createRuntimeRoutes(ctx) {
211
337
  res.json(runtimeService.isAvailable(req.params.language));
212
338
  });
213
339
 
340
+ /**
341
+ * GET /api/runtime/provider
342
+ * Return connected machine-provider info for tunnel mode.
343
+ */
344
+ router.get('/provider', (req, res) => {
345
+ const provider = ctx.tunnelClient?.getProvider?.() || null;
346
+ const machineInfo = ctx.tunnelClient?.getMachines?.() || { activeMachineId: null, machines: [] };
347
+ res.json({
348
+ available: !!provider,
349
+ provider,
350
+ ...machineInfo,
351
+ });
352
+ });
353
+
214
354
  /**
215
355
  * GET /api/runtime/languages
216
356
  * List all supported languages.
@@ -69,8 +69,8 @@ const DEFAULT_SETTINGS = {
69
69
 
70
70
  // Default preferences
71
71
  defaults: {
72
- juiceLevel: 2,
73
- reasoningLevel: 1,
72
+ juiceLevel: 1,
73
+ reasoningLevel: 0,
74
74
  },
75
75
  };
76
76
 
@@ -682,8 +682,8 @@ export function createSettingsRoutes(ctx) {
682
682
  try {
683
683
  const settings = loadSettings();
684
684
  res.json({
685
- juiceLevel: settings.defaults?.juiceLevel ?? 2,
686
- reasoningLevel: settings.defaults?.reasoningLevel ?? 1,
685
+ juiceLevel: settings.defaults?.juiceLevel ?? 1,
686
+ reasoningLevel: settings.defaults?.reasoningLevel ?? 0,
687
687
  });
688
688
  } catch (err) {
689
689
  console.error('[settings:getDefaults]', err);