myrlin-workbook 0.9.25 → 0.9.27

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/package.json CHANGED
@@ -1,66 +1,71 @@
1
- {
2
- "name": "myrlin-workbook",
3
- "version": "0.9.25",
4
- "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
- "main": "src/index.js",
6
- "bin": {
7
- "myrlin-workbook": "./src/gui.js",
8
- "myrlin": "./src/gui.js",
9
- "myrlin-tui": "./src/index.js",
10
- "cwm": "./src/index.js"
11
- },
12
- "scripts": {
13
- "start": "node src/index.js",
14
- "demo": "node src/demo.js",
15
- "gui": "node src/supervisor.js",
16
- "gui:daemon": "node src/supervisor.js --daemon",
17
- "gui:bare": "node src/gui.js",
18
- "gui:demo": "node src/supervisor.js --demo",
19
- "test": "node test/run.js",
20
- "mcp:visual-qa": "node src/mcp/visual-qa.js",
21
- "gui:cdp": "node src/supervisor.js --cdp",
22
- "postinstall": "node scripts/postinstall.js",
23
- "restart": "bash scripts/restart-gui.sh"
24
- },
25
- "repository": {
26
- "type": "git",
27
- "url": "https://github.com/therealarthur/myrlin-workbook.git"
28
- },
29
- "homepage": "https://github.com/therealarthur/myrlin-workbook",
30
- "engines": {
31
- "node": ">=18.0.0"
32
- },
33
- "keywords": [
34
- "claude",
35
- "workspace",
36
- "manager",
37
- "terminal",
38
- "tui",
39
- "ai",
40
- "coding-assistant",
41
- "session-manager",
42
- "developer-tools",
43
- "xterm",
44
- "myrlin"
45
- ],
46
- "author": "Arthur",
47
- "license": "AGPL-3.0-only",
48
- "dependencies": {
49
- "blessed": "^0.1.81",
50
- "blessed-contrib": "^4.11.0",
51
- "chalk": "^5.6.2",
52
- "chrome-remote-interface": "^0.34.0",
53
- "express": "^5.2.1",
54
- "node-pty": "^1.1.0",
55
- "qrcode": "^1.5.4",
56
- "ws": "^8.19.0"
57
- },
58
- "devDependencies": {
59
- "@playwright/test": "^1.58.2",
60
- "@xterm/addon-fit": "^0.11.0",
61
- "@xterm/addon-web-links": "^0.12.0",
62
- "@xterm/xterm": "^6.0.0",
63
- "ffmpeg-static": "^5.3.0",
64
- "sharp": "^0.34.5"
65
- }
66
- }
1
+ {
2
+ "name": "myrlin-workbook",
3
+ "version": "0.9.27",
4
+ "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "myrlin-workbook": "./src/gui.js",
8
+ "myrlin": "./src/gui.js",
9
+ "myrlin-tui": "./src/index.js",
10
+ "cwm": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node src/index.js",
14
+ "demo": "node src/demo.js",
15
+ "gui": "node src/supervisor.js",
16
+ "gui:daemon": "node src/supervisor.js --daemon",
17
+ "gui:bare": "node src/gui.js",
18
+ "gui:demo": "node src/supervisor.js --demo",
19
+ "test": "node test/run.js",
20
+ "mcp:visual-qa": "node src/mcp/visual-qa.js",
21
+ "gui:cdp": "node src/supervisor.js --cdp",
22
+ "postinstall": "node scripts/postinstall.js",
23
+ "restart": "bash scripts/restart-gui.sh",
24
+ "build:icons": "node scripts/build-lucide-bundle.mjs && node scripts/build-material-bundle.mjs"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/therealarthur/myrlin-workbook.git"
29
+ },
30
+ "homepage": "https://github.com/therealarthur/myrlin-workbook",
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "keywords": [
35
+ "claude",
36
+ "workspace",
37
+ "manager",
38
+ "terminal",
39
+ "tui",
40
+ "ai",
41
+ "coding-assistant",
42
+ "session-manager",
43
+ "developer-tools",
44
+ "xterm",
45
+ "myrlin"
46
+ ],
47
+ "author": "Arthur",
48
+ "license": "AGPL-3.0-only",
49
+ "dependencies": {
50
+ "@lucide/lab": "^0.1.2",
51
+ "blessed": "^0.1.81",
52
+ "blessed-contrib": "^4.11.0",
53
+ "chalk": "^5.6.2",
54
+ "chrome-remote-interface": "^0.34.0",
55
+ "express": "^5.2.1",
56
+ "lucide": "^1.8.0",
57
+ "node-pty": "^1.1.0",
58
+ "qrcode": "^1.5.4",
59
+ "simple-git": "^3.33.0",
60
+ "ws": "^8.19.0"
61
+ },
62
+ "devDependencies": {
63
+ "@material-icons/svg": "^1.0.33",
64
+ "@playwright/test": "^1.58.2",
65
+ "@xterm/addon-fit": "^0.11.0",
66
+ "@xterm/addon-web-links": "^0.12.0",
67
+ "@xterm/xterm": "^6.0.0",
68
+ "ffmpeg-static": "^5.3.0",
69
+ "sharp": "^0.34.5"
70
+ }
71
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build script: generates public/vendor/lucide.bundle.js
4
+ * Imports curated icons from `lucide` and `@lucide/lab`, converts to SVG strings,
5
+ * and outputs a plain JS bundle that sets window.__lucideIcons.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, resolve } from 'path';
11
+ import vm from 'vm';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const ROOT = resolve(__dirname, '..');
15
+ const OUT = resolve(ROOT, 'src/web/public/vendor/lucide.bundle.js');
16
+
17
+ // ── Curated icon list ────────────────────────────────────────────────────────
18
+ // Format: { category: [[kebab-name, 'lucide' | 'lab'], ...], ... }
19
+ const ICON_CATEGORIES = {
20
+ 'Folders & Files': [
21
+ ['folder', 'lucide'],
22
+ ['folder-open', 'lucide'],
23
+ ['folder-code', 'lucide'],
24
+ ['folder-git-2', 'lucide'],
25
+ ['folder-sync', 'lucide'],
26
+ ['folder-key', 'lucide'],
27
+ ['folder-search', 'lucide'],
28
+ ['folders', 'lucide'],
29
+ ['file', 'lucide'],
30
+ ['file-code', 'lucide'],
31
+ ['file-text', 'lucide'],
32
+ ['files', 'lucide'],
33
+ ['notebook', 'lucide'],
34
+ ['notebook-pen', 'lucide'],
35
+ ],
36
+ 'Code & Dev': [
37
+ ['code', 'lucide'],
38
+ ['code-xml', 'lucide'],
39
+ ['square-terminal', 'lucide'],
40
+ ['terminal', 'lucide'],
41
+ ['git-branch', 'lucide'],
42
+ ['git-merge', 'lucide'],
43
+ ['git-commit-vertical', 'lucide'],
44
+ ['git-fork', 'lucide'],
45
+ ['braces', 'lucide'],
46
+ ['brackets', 'lucide'],
47
+ ['hash', 'lucide'],
48
+ ['variable', 'lucide'],
49
+ ],
50
+ 'Infrastructure': [
51
+ ['database', 'lucide'],
52
+ ['database-zap', 'lucide'],
53
+ ['server', 'lucide'],
54
+ ['server-cog', 'lucide'],
55
+ ['cpu', 'lucide'],
56
+ ['hard-drive', 'lucide'],
57
+ ['network', 'lucide'],
58
+ ['wifi', 'lucide'],
59
+ ['cloud', 'lucide'],
60
+ ['cloud-upload', 'lucide'],
61
+ ['globe', 'lucide'],
62
+ ['layers', 'lucide'],
63
+ ],
64
+ 'Apps & Tools': [
65
+ ['box', 'lucide'],
66
+ ['package', 'lucide'],
67
+ ['package-2', 'lucide'],
68
+ ['workflow', 'lucide'],
69
+ ['settings', 'lucide'],
70
+ ['settings-2', 'lucide'],
71
+ ['wrench', 'lucide'],
72
+ ['hammer', 'lucide'],
73
+ ['cog', 'lucide'],
74
+ ['tool-case', 'lucide'],
75
+ ['puzzle', 'lucide'],
76
+ ['blocks', 'lucide'],
77
+ ['plug', 'lucide'],
78
+ ['cpu', 'lucide'],
79
+ ],
80
+ 'Design & Art': [
81
+ ['palette', 'lucide'],
82
+ ['brush', 'lucide'],
83
+ ['paintbrush', 'lucide'],
84
+ ['wand', 'lucide'],
85
+ ['wand-sparkles', 'lucide'],
86
+ ['sparkles', 'lucide'],
87
+ ['pen-tool', 'lucide'],
88
+ ['pencil', 'lucide'],
89
+ ['scissors', 'lucide'],
90
+ ['crop', 'lucide'],
91
+ ['image', 'lucide'],
92
+ ['camera', 'lucide'],
93
+ ],
94
+ 'Learning & Science': [
95
+ ['book-open', 'lucide'],
96
+ ['book', 'lucide'],
97
+ ['scroll', 'lucide'],
98
+ ['flask-conical', 'lucide'],
99
+ ['flask-round', 'lucide'],
100
+ ['microscope', 'lucide'],
101
+ ['test-tube', 'lucide'],
102
+ ['brain', 'lucide'],
103
+ ['brain-circuit', 'lucide'],
104
+ ['atom', 'lucide'],
105
+ ['dna', 'lucide'],
106
+ ['telescope', 'lucide'],
107
+ ],
108
+ 'People & Status': [
109
+ ['user', 'lucide'],
110
+ ['users', 'lucide'],
111
+ ['user-round', 'lucide'],
112
+ ['bot', 'lucide'],
113
+ ['rocket', 'lucide'],
114
+ ['star', 'lucide'],
115
+ ['heart', 'lucide'],
116
+ ['zap', 'lucide'],
117
+ ['shield', 'lucide'],
118
+ ['lock', 'lucide'],
119
+ ['key', 'lucide'],
120
+ ['flag', 'lucide'],
121
+ ['trophy', 'lucide'],
122
+ ['medal', 'lucide'],
123
+ ['crown', 'lucide'],
124
+ ],
125
+ 'Home & Work': [
126
+ ['house', 'lucide'],
127
+ ['briefcase', 'lucide'],
128
+ ['building', 'lucide'],
129
+ ['building-2', 'lucide'],
130
+ ['store', 'lucide'],
131
+ ['landmark', 'lucide'],
132
+ ['map-pin', 'lucide'],
133
+ ['compass', 'lucide'],
134
+ ['map', 'lucide'],
135
+ ['globe-2', 'lucide'],
136
+ ['mountain', 'lucide'],
137
+ ],
138
+ 'Media & Comms': [
139
+ ['monitor', 'lucide'],
140
+ ['laptop', 'lucide'],
141
+ ['smartphone', 'lucide'],
142
+ ['mail', 'lucide'],
143
+ ['message-circle', 'lucide'],
144
+ ['bell', 'lucide'],
145
+ ['phone', 'lucide'],
146
+ ['mic', 'lucide'],
147
+ ['music', 'lucide'],
148
+ ['headphones', 'lucide'],
149
+ ['video', 'lucide'],
150
+ ['radio', 'lucide'],
151
+ ],
152
+ 'Nature & Fun': [
153
+ ['sun', 'lucide'],
154
+ ['moon', 'lucide'],
155
+ ['cloud-sun', 'lucide'],
156
+ ['flame', 'lucide'],
157
+ ['waves', 'lucide'],
158
+ ['coffee', 'lucide'],
159
+ ['leaf', 'lucide'],
160
+ ['flower', 'lucide'],
161
+ ['flower-2', 'lucide'],
162
+ ['tree-pine', 'lucide'],
163
+ ['trees', 'lucide'],
164
+ ['snail', 'lucide'],
165
+ ['fish', 'lucide'],
166
+ ['rabbit', 'lucide'],
167
+ ['turtle', 'lucide'],
168
+ ['bird', 'lucide'],
169
+ ['dog', 'lucide'],
170
+ ['cat', 'lucide'],
171
+ ],
172
+ 'Lab Extras': [
173
+ ['owl', 'lab'],
174
+ ['planet', 'lab'],
175
+ ['bee', 'lab'],
176
+ ['venn', 'lab'],
177
+ ['copy-code', 'lab'],
178
+ ['grid-lines', 'lab'],
179
+ ['farm', 'lab'],
180
+ ['toolbox', 'lab'],
181
+ ['toolbox-2', 'lab'],
182
+ ['snowman', 'lab'],
183
+ ['mountain-snow', 'lab'],
184
+ ],
185
+ };
186
+
187
+ // ── SVG serializer ────────────────────────────────────────────────────────────
188
+ function iconDataToSvg(iconData) {
189
+ const children = iconData.map(([tag, attrs]) => {
190
+ // Filter out non-SVG metadata attrs ('key' is a React/lucide internal)
191
+ const entries = Object.entries(attrs).filter(([k]) => k !== 'key');
192
+ const attrStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ');
193
+ return `<${tag} ${attrStr}/>`;
194
+ }).join('');
195
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${children}</svg>`;
196
+ }
197
+
198
+ // ── Main ──────────────────────────────────────────────────────────────────────
199
+ const icons = {}; // { "folder": "<svg>...</svg>", ... }
200
+ const categories = {}; // { "Folders & Files": ["folder", "folder-open", ...], ... }
201
+ const skipped = [];
202
+
203
+ for (const [category, entries] of Object.entries(ICON_CATEGORIES)) {
204
+ const catIcons = [];
205
+ for (const [name, pkg] of entries) {
206
+ // De-dup (e.g. 'cpu' appears twice in the list above)
207
+ if (icons[name]) { catIcons.push(name); continue; }
208
+
209
+ const iconDir = pkg === 'lab'
210
+ ? resolve(ROOT, `node_modules/@lucide/lab/dist/esm/icons/${name}.js`)
211
+ : resolve(ROOT, `node_modules/lucide/dist/esm/icons/${name}.js`);
212
+
213
+ try {
214
+ const src = readFileSync(iconDir, 'utf8');
215
+ // Extract: const <Name> = <array>;\nexport { <Name> as default }
216
+ // Use greedy match up to the last ";" before the export statement
217
+ const match = src.match(/const \w+ = ([\s\S]+?);\s*\nexport/);
218
+ if (!match) { skipped.push(`${name} (parse fail)`); continue; }
219
+ // Evaluate JS object literal (has unquoted keys, not valid JSON)
220
+ const iconData = vm.runInNewContext(`(${match[1]})`);
221
+ icons[name] = iconDataToSvg(iconData);
222
+ catIcons.push(name);
223
+ } catch {
224
+ skipped.push(`${name} (${pkg})`);
225
+ }
226
+ }
227
+ if (catIcons.length > 0) categories[category] = catIcons;
228
+ }
229
+
230
+ // ── Output ────────────────────────────────────────────────────────────────────
231
+ mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
232
+
233
+ const output = `/* lucide icons bundle — generated by scripts/build-lucide-bundle.mjs */
234
+ /* Includes ${Object.keys(icons).length} icons from lucide + @lucide/lab */
235
+ (function() {
236
+ window.__lucideIcons = ${JSON.stringify(icons)};
237
+ window.__lucideIconCategories = ${JSON.stringify(categories)};
238
+ document.dispatchEvent(new Event('lucide-ready'));
239
+ })();
240
+ `;
241
+
242
+ writeFileSync(OUT, output);
243
+ console.log(`✓ Built ${Object.keys(icons).length} icons → src/web/public/vendor/lucide.bundle.js`);
244
+ if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build script: generates public/vendor/material-icons.bundle.js
4
+ * Includes ALL ~2191 icons from @material-icons/svg (baseline/filled variant),
5
+ * organized by their official categories from data.json.
6
+ *
7
+ * Icon names are stored with a "mi/" prefix when used in ws.icon so they
8
+ * can coexist with Lucide icon names without collision.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, resolve } from 'path';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = resolve(__dirname, '..');
17
+ const ICONS_DIR = resolve(ROOT, 'node_modules/@material-icons/svg/svg');
18
+ const DATA_JSON = resolve(ROOT, 'node_modules/@material-icons/svg/data.json');
19
+ const OUT = resolve(ROOT, 'src/web/public/vendor/material-icons.bundle.js');
20
+
21
+ // Official category short-name → display name
22
+ const CATEGORY_LABELS = {
23
+ action: 'Action',
24
+ alert: 'Alert',
25
+ av: 'Audio & Video',
26
+ communication:'Communication',
27
+ content: 'Content',
28
+ device: 'Device',
29
+ editor: 'Editor',
30
+ file: 'File',
31
+ hardware: 'Hardware',
32
+ home: 'Home',
33
+ image: 'Image',
34
+ maps: 'Maps',
35
+ math: 'Math',
36
+ navigation: 'Navigation',
37
+ notification: 'Notification',
38
+ places: 'Places',
39
+ search: 'Search',
40
+ shopping: 'Shopping',
41
+ social: 'Social',
42
+ toggle: 'Toggle',
43
+ };
44
+
45
+ // ── SVG processor ─────────────────────────────────────────────────────────────
46
+ function processSvg(raw) {
47
+ return raw
48
+ .replace(/width="24"/, 'width="14"')
49
+ .replace(/height="24"/, 'height="14"')
50
+ // Make color inherit from CSS (without this, icons always render black)
51
+ .replace('<svg ', '<svg fill="currentColor" ');
52
+ }
53
+
54
+ // ── Load icon metadata ────────────────────────────────────────────────────────
55
+ const data = JSON.parse(readFileSync(DATA_JSON, 'utf8'));
56
+ const iconMeta = data.icons; // [{ name, categories: [string], tags: [string] }]
57
+
58
+ // Build category → [name] map using official categories
59
+ const categoryMap = {}; // { 'Audio & Video': ['10k', ...], ... }
60
+ for (const icon of iconMeta) {
61
+ for (const cat of (icon.categories || [])) {
62
+ const label = CATEGORY_LABELS[cat] || cat;
63
+ if (!categoryMap[label]) categoryMap[label] = [];
64
+ categoryMap[label].push(icon.name);
65
+ }
66
+ }
67
+
68
+ // ── Process all icons ─────────────────────────────────────────────────────────
69
+ const icons = {}; // { name: '<svg>...' }
70
+ const categories = {}; // { 'Audio & Video': ['10k', ...] }
71
+ const skipped = [];
72
+
73
+ for (const icon of iconMeta) {
74
+ const svgPath = resolve(ICONS_DIR, icon.name, 'baseline.svg');
75
+ if (!existsSync(svgPath)) {
76
+ skipped.push(icon.name);
77
+ continue;
78
+ }
79
+ try {
80
+ const raw = readFileSync(svgPath, 'utf8').trim();
81
+ icons[icon.name] = processSvg(raw);
82
+ } catch {
83
+ skipped.push(icon.name);
84
+ }
85
+ }
86
+
87
+ // Build final categories (only include icons that were successfully loaded)
88
+ for (const [label, names] of Object.entries(categoryMap)) {
89
+ const valid = names.filter(n => icons[n]);
90
+ if (valid.length > 0) categories[label] = valid;
91
+ }
92
+
93
+ // ── Output ────────────────────────────────────────────────────────────────────
94
+ mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
95
+
96
+ const output = `/* Material Icons bundle — generated by scripts/build-material-bundle.mjs */
97
+ /* Includes ${Object.keys(icons).length} icons from @material-icons/svg (baseline variant) */
98
+ /* Icon names are stored as "mi/<name>" in workspace records to avoid Lucide collisions */
99
+ (function() {
100
+ window.__materialIcons = ${JSON.stringify(icons)};
101
+ window.__materialIconCategories = ${JSON.stringify(categories)};
102
+ document.dispatchEvent(new Event('material-icons-ready'));
103
+ })();
104
+ `;
105
+
106
+ writeFileSync(OUT, output);
107
+ console.log(`✓ Built ${Object.keys(icons).length} Material icons across ${Object.keys(categories).length} categories → src/web/public/vendor/material-icons.bundle.js`);
108
+ if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
@@ -452,7 +452,7 @@ class Store extends EventEmitter {
452
452
 
453
453
  // ─── Workspace CRUD ──────────────────────────────────────
454
454
 
455
- createWorkspace({ name, description = '', color = 'cyan' }) {
455
+ createWorkspace({ name, description = '', color = 'cyan', icon = null }) {
456
456
  const id = crypto.randomUUID();
457
457
  const now = new Date().toISOString();
458
458
  const workspace = {
@@ -460,6 +460,7 @@ class Store extends EventEmitter {
460
460
  name,
461
461
  description,
462
462
  color,
463
+ icon,
463
464
  sessions: [],
464
465
  createdAt: now,
465
466
  lastActive: now,
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Map of file extensions to CodeMirror language names.
8
+ */
9
+ const EXT_TO_LANG = {
10
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
11
+ ts: 'typescript', tsx: 'typescript',
12
+ jsx: 'javascript',
13
+ json: 'json',
14
+ html: 'html', htm: 'html',
15
+ css: 'css', scss: 'css', less: 'css',
16
+ md: 'markdown', markdown: 'markdown',
17
+ py: 'python',
18
+ sh: 'shell', bash: 'shell',
19
+ yaml: 'yaml', yml: 'yaml',
20
+ toml: 'toml',
21
+ rs: 'rust',
22
+ go: 'go',
23
+ java: 'java',
24
+ rb: 'ruby',
25
+ php: 'php',
26
+ sql: 'sql',
27
+ xml: 'xml',
28
+ txt: 'text',
29
+ };
30
+
31
+ /**
32
+ * Directory names to skip when building the file tree.
33
+ * Prevents traversal into version control internals and build artifacts.
34
+ */
35
+ const SKIP_DIRS = new Set([
36
+ '.git', 'node_modules', '.DS_Store', '__pycache__',
37
+ '.next', 'dist', 'build', '.cache',
38
+ ]);
39
+
40
+ /**
41
+ * Validate that the resolved target path stays within root.
42
+ * Throws if path traversal is detected.
43
+ *
44
+ * @param {string} root - Absolute workspace root path
45
+ * @param {string} relPath - Relative path from client
46
+ * @returns {string} Resolved absolute path
47
+ */
48
+ function validatePath(root, relPath) {
49
+ if (!root || typeof root !== 'string') throw new Error('root is required');
50
+ const resolved = path.resolve(root, relPath || '');
51
+ if (!resolved.startsWith(path.resolve(root) + path.sep) && resolved !== path.resolve(root)) {
52
+ throw new Error('Path traversal detected');
53
+ }
54
+ return resolved;
55
+ }
56
+
57
+ /**
58
+ * GET /api/files/tree
59
+ * Returns directory entries (dirs and files) for a given subpath within the
60
+ * workspace root. Skips .git, node_modules, and other build artifacts.
61
+ *
62
+ * @param {string} workingDir - Workspace root directory
63
+ * @param {string} [subpath=''] - Relative subpath to list
64
+ * @returns {Promise<{path: string, entries: Array}>}
65
+ */
66
+ async function getTree(workingDir, subpath = '') {
67
+ const root = path.resolve(workingDir);
68
+ const target = validatePath(root, subpath);
69
+
70
+ const entries = await fs.promises.readdir(target, { withFileTypes: true });
71
+ const result = [];
72
+
73
+ for (const entry of entries) {
74
+ if (SKIP_DIRS.has(entry.name)) continue;
75
+ // Skip hidden files/dirs (dot-files) at root level
76
+ if (subpath === '' && entry.name.startsWith('.')) continue;
77
+ const relPath = subpath ? path.join(subpath, entry.name) : entry.name;
78
+ if (entry.isDirectory()) {
79
+ result.push({ name: entry.name, path: relPath, type: 'dir' });
80
+ } else if (entry.isFile()) {
81
+ const ext = path.extname(entry.name).slice(1).toLowerCase();
82
+ result.push({ name: entry.name, path: relPath, type: 'file', ext });
83
+ }
84
+ }
85
+
86
+ // Sort: directories first, then files, each group sorted alphabetically
87
+ result.sort((a, b) => {
88
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
89
+ return a.name.localeCompare(b.name);
90
+ });
91
+
92
+ return { path: subpath || '/', entries: result };
93
+ }
94
+
95
+ /**
96
+ * GET /api/files/content
97
+ * Returns file content as UTF-8 text with a language hint for CodeMirror.
98
+ * Rejects files larger than 5MB.
99
+ *
100
+ * @param {string} workingDir - Workspace root directory
101
+ * @param {string} file - Relative path to the file
102
+ * @returns {Promise<{content: string, language: string, size: number, path: string}>}
103
+ */
104
+ async function getContent(workingDir, file) {
105
+ if (!file) throw new Error('file is required');
106
+ const root = path.resolve(workingDir);
107
+ const target = validatePath(root, file);
108
+
109
+ const stat = await fs.promises.stat(target);
110
+ if (stat.size > 5 * 1024 * 1024) throw new Error('File too large (> 5MB)');
111
+
112
+ const content = await fs.promises.readFile(target, 'utf8');
113
+ const ext = path.extname(file).slice(1).toLowerCase();
114
+ return {
115
+ content,
116
+ language: EXT_TO_LANG[ext] || 'text',
117
+ size: stat.size,
118
+ path: file,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * POST /api/files/save
124
+ * Atomically writes file content by writing to a temp file then renaming.
125
+ * Creates parent directories if they don't exist.
126
+ *
127
+ * @param {string} workingDir - Workspace root directory
128
+ * @param {string} file - Relative path to the file
129
+ * @param {string} content - UTF-8 text content to write
130
+ * @returns {Promise<{ok: boolean}>}
131
+ */
132
+ async function saveContent(workingDir, file, content) {
133
+ if (!file) throw new Error('file is required');
134
+ if (typeof content !== 'string') throw new Error('content must be a string');
135
+ const root = path.resolve(workingDir);
136
+ const target = validatePath(root, file);
137
+
138
+ // Ensure parent directory exists
139
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
140
+
141
+ // Atomic write: write to temp file then rename into place
142
+ const tmp = target + '.tmp.' + Date.now();
143
+ await fs.promises.writeFile(tmp, content, 'utf8');
144
+ await fs.promises.rename(tmp, target);
145
+
146
+ return { ok: true };
147
+ }
148
+
149
+ module.exports = { getTree, getContent, saveContent };