nothumanallowed 13.5.199 → 14.0.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,1092 @@
1
+ /**
2
+ * WebCraft routes — /api/studio/webcraft/*
3
+ *
4
+ * Architecture:
5
+ * - SandboxManager — encapsulates child-process lifecycle (start/stop/status)
6
+ * - ProjectStore — pure filesystem helpers for project files/metadata
7
+ * - SkillStore — per-project .nha-context/ skill/memory/provider files
8
+ * - SnapshotStore — tarball-based immutable project snapshots
9
+ * - WebCraftAgent — LLM tool-loop for chat-driven code editing
10
+ *
11
+ * All LLM calls go through the shared services/llm.mjs (callLLM / callLLMStream).
12
+ * Sandbox shims are injected at startup so user projects run without real DB/Redis.
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { exec, spawn } from 'child_process';
18
+ import { createServer } from 'net';
19
+ import { promisify } from 'util';
20
+ import { sendJSON, sendError, parseBody, sendSSE } from '../index.mjs';
21
+ import { loadConfig } from '../../config.mjs';
22
+ import { callLLM, callLLMStream } from '../../services/llm.mjs';
23
+ import { NHA_DIR } from '../../constants.mjs';
24
+
25
+ const execAsync = promisify(exec);
26
+
27
+ // ── Project root ─────────────────────────────────────────────────────────────
28
+
29
+ const WEBCRAFT_DIR = path.join(NHA_DIR, 'webcraft');
30
+
31
+ function ensureDir(p) {
32
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
33
+ return p;
34
+ }
35
+
36
+ // ── SandboxManager ───────────────────────────────────────────────────────────
37
+
38
+ class SandboxManager {
39
+ constructor() {
40
+ /** @type {{ proc: import('child_process').ChildProcess; port: number; projectName: string; startedAt: Date; healthy: boolean } | null} */
41
+ this._sandbox = null;
42
+ }
43
+
44
+ isRunning() {
45
+ return this._sandbox !== null && this._sandbox.proc && !this._sandbox.proc.killed;
46
+ }
47
+
48
+ status() {
49
+ if (!this.isRunning()) return { running: false };
50
+ const { port, projectName, startedAt, healthy } = this._sandbox;
51
+ return { running: true, port, projectName, startedAt, healthy };
52
+ }
53
+
54
+ /** Returns the port of the running sandbox or null. */
55
+ get port() { return this.isRunning() ? this._sandbox.port : null; }
56
+
57
+ async stop() {
58
+ if (!this.isRunning()) return;
59
+ const { proc } = this._sandbox;
60
+ this._sandbox = null;
61
+ try {
62
+ proc.kill('SIGTERM');
63
+ // Give it a grace period then SIGKILL
64
+ await new Promise((resolve) => {
65
+ const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} resolve(); }, 4000);
66
+ proc.once('exit', () => { clearTimeout(t); resolve(); });
67
+ });
68
+ } catch {}
69
+ }
70
+
71
+ /**
72
+ * Starts the sandbox and streams progress via SSE.
73
+ * @param {string} projectName
74
+ * @param {string} projectDir
75
+ * @param {(event: object) => void} emit
76
+ */
77
+ async start(projectName, projectDir, emit) {
78
+ // Kill any existing sandbox
79
+ if (this.isRunning()) {
80
+ emit({ type: 'status', msg: 'Stopping previous sandbox...' });
81
+ await this.stop();
82
+ }
83
+
84
+ if (!fs.existsSync(projectDir)) {
85
+ emit({ type: 'error', msg: `Project directory not found: ${projectDir}` });
86
+ return;
87
+ }
88
+
89
+ // ── Inject shims so user projects run without real DB / Redis ──────────
90
+ const shimDir = path.join(projectDir, '.nha-shims');
91
+ ensureDir(shimDir);
92
+ _writeShims(shimDir);
93
+
94
+ const entryFile = _detectEntry(projectDir);
95
+ if (!entryFile) {
96
+ emit({ type: 'error', msg: 'No entry point found (server.js / app.js / index.js).' });
97
+ return;
98
+ }
99
+
100
+ // ── Install dependencies ────────────────────────────────────────────────
101
+ if (fs.existsSync(path.join(projectDir, 'package.json'))) {
102
+ emit({ type: 'status', msg: 'Installing dependencies (npm install)...' });
103
+ try {
104
+ await execAsync('npm install --prefer-offline --no-audit --no-fund', {
105
+ cwd: projectDir,
106
+ timeout: 120_000,
107
+ env: { ...process.env, NODE_ENV: 'development' },
108
+ });
109
+ emit({ type: 'status', msg: 'Dependencies installed.' });
110
+ } catch (e) {
111
+ emit({ type: 'warn', msg: `npm install warning: ${e.message.slice(0, 200)}` });
112
+ }
113
+ }
114
+
115
+ // ── Find a free port ────────────────────────────────────────────────────
116
+ const port = await _findFreePort(4000, 4999);
117
+ if (!port) {
118
+ emit({ type: 'error', msg: 'No free ports available in range 4000-4999.' });
119
+ return;
120
+ }
121
+
122
+ emit({ type: 'status', msg: `Starting on port ${port}...` });
123
+
124
+ // Patch entry file to use our shims and bind to the found port
125
+ const patchedEntry = _patchEntry(projectDir, entryFile, shimDir, port);
126
+
127
+ const proc = spawn('node', [patchedEntry], {
128
+ cwd: projectDir,
129
+ env: {
130
+ ...process.env,
131
+ PORT: String(port),
132
+ NODE_ENV: 'development',
133
+ NHA_SANDBOX: '1',
134
+ },
135
+ detached: false,
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ });
138
+
139
+ this._sandbox = { proc, port, projectName, startedAt: new Date(), healthy: false };
140
+
141
+ proc.stdout.on('data', (d) => {
142
+ const line = d.toString().trim();
143
+ if (line) emit({ type: 'log', msg: line });
144
+ if (/listen|running|started|ready|port/i.test(line)) {
145
+ if (this._sandbox) this._sandbox.healthy = true;
146
+ emit({ type: 'ready', port });
147
+ }
148
+ });
149
+
150
+ proc.stderr.on('data', (d) => {
151
+ const line = d.toString().trim();
152
+ if (line) emit({ type: 'log', msg: `[stderr] ${line}` });
153
+ });
154
+
155
+ proc.once('exit', (code) => {
156
+ if (this._sandbox?.proc === proc) this._sandbox = null;
157
+ emit({ type: 'exit', code: code ?? -1 });
158
+ });
159
+
160
+ // Healthcheck: wait up to 15s for the process to bind the port
161
+ const healthy = await _waitForPort(port, 15_000);
162
+ if (healthy && this._sandbox) {
163
+ this._sandbox.healthy = true;
164
+ emit({ type: 'ready', port });
165
+ } else if (!healthy) {
166
+ emit({ type: 'warn', msg: 'Sandbox started but port not yet bound — may still be loading.' });
167
+ }
168
+ }
169
+ }
170
+
171
+ const sandbox = new SandboxManager();
172
+
173
+ // ── ProjectStore ─────────────────────────────────────────────────────────────
174
+
175
+ const ProjectStore = {
176
+ dir(projectName) {
177
+ return path.join(WEBCRAFT_DIR, _safeName(projectName));
178
+ },
179
+
180
+ metaPath(projectName) {
181
+ return path.join(this.dir(projectName), '.nha-meta.json');
182
+ },
183
+
184
+ list() {
185
+ ensureDir(WEBCRAFT_DIR);
186
+ return fs.readdirSync(WEBCRAFT_DIR, { withFileTypes: true })
187
+ .filter((d) => d.isDirectory())
188
+ .map((d) => {
189
+ const metaFile = path.join(WEBCRAFT_DIR, d.name, '.nha-meta.json');
190
+ let meta = {};
191
+ try { meta = JSON.parse(fs.readFileSync(metaFile, 'utf-8')); } catch {}
192
+ const files = _listProjectFiles(path.join(WEBCRAFT_DIR, d.name));
193
+ return {
194
+ name: d.name,
195
+ description: meta.description ?? '',
196
+ fileCount: files.length,
197
+ createdAt: meta.createdAt ?? null,
198
+ dir: path.join(WEBCRAFT_DIR, d.name),
199
+ };
200
+ })
201
+ .sort((a, b) => (b.createdAt ?? '') < (a.createdAt ?? '') ? -1 : 1);
202
+ },
203
+
204
+ load(projectName) {
205
+ const dir = this.dir(projectName);
206
+ if (!fs.existsSync(dir)) return null;
207
+ const metaFile = this.metaPath(projectName);
208
+ let meta = {};
209
+ try { meta = JSON.parse(fs.readFileSync(metaFile, 'utf-8')); } catch {}
210
+ const rawFiles = _listProjectFiles(dir);
211
+ const files = rawFiles.map((rel) => ({
212
+ name: rel,
213
+ content: fs.readFileSync(path.join(dir, rel), 'utf-8'),
214
+ }));
215
+ return { projectName, description: meta.description ?? '', files };
216
+ },
217
+
218
+ save(projectName, description, files) {
219
+ const dir = ensureDir(this.dir(projectName));
220
+ for (const f of files) {
221
+ if (!_isSafePath(f.name)) continue;
222
+ const abs = path.join(dir, f.name);
223
+ ensureDir(path.dirname(abs));
224
+ fs.writeFileSync(abs, f.content ?? '', 'utf-8');
225
+ }
226
+ const meta = {
227
+ description: description ?? '',
228
+ createdAt: fs.existsSync(this.metaPath(projectName))
229
+ ? JSON.parse(fs.readFileSync(this.metaPath(projectName), 'utf-8')).createdAt ?? new Date().toISOString()
230
+ : new Date().toISOString(),
231
+ updatedAt: new Date().toISOString(),
232
+ };
233
+ fs.writeFileSync(this.metaPath(projectName), JSON.stringify(meta, null, 2), 'utf-8');
234
+ },
235
+
236
+ delete(projectName) {
237
+ const dir = this.dir(projectName);
238
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
239
+ },
240
+
241
+ readFile(projectName, relPath) {
242
+ if (!_isSafePath(relPath)) return null;
243
+ const abs = path.join(this.dir(projectName), relPath);
244
+ if (!fs.existsSync(abs)) return null;
245
+ return fs.readFileSync(abs, 'utf-8');
246
+ },
247
+
248
+ writeFile(projectName, relPath, content) {
249
+ if (!_isSafePath(relPath)) return false;
250
+ const abs = path.join(this.dir(projectName), relPath);
251
+ ensureDir(path.dirname(abs));
252
+ fs.writeFileSync(abs, content, 'utf-8');
253
+ return true;
254
+ },
255
+
256
+ grep(projectName, query) {
257
+ const dir = this.dir(projectName);
258
+ if (!fs.existsSync(dir)) return [];
259
+ const files = _listProjectFiles(dir);
260
+ const matches = [];
261
+ const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
262
+ for (const rel of files) {
263
+ const abs = path.join(dir, rel);
264
+ try {
265
+ const lines = fs.readFileSync(abs, 'utf-8').split('\n');
266
+ for (let i = 0; i < lines.length; i++) {
267
+ if (re.test(lines[i])) {
268
+ matches.push({ file: rel, lineNum: i + 1, line: lines[i].slice(0, 200) });
269
+ if (matches.length >= 200) return matches;
270
+ }
271
+ }
272
+ } catch {}
273
+ }
274
+ return matches;
275
+ },
276
+
277
+ syntaxCheck(projectName) {
278
+ const dir = this.dir(projectName);
279
+ if (!fs.existsSync(dir)) return [];
280
+ const files = _listProjectFiles(dir).filter((f) => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs'));
281
+ return files.map((rel) => {
282
+ const abs = path.join(dir, rel);
283
+ try {
284
+ const src = fs.readFileSync(abs, 'utf-8');
285
+ // Quick static syntax check via Function constructor (catches syntax errors)
286
+ new Function(src); // eslint-disable-line no-new-func
287
+ return { file: rel, ok: true, error: '' };
288
+ } catch (e) {
289
+ return { file: rel, ok: false, error: e.message.replace(/\n.*/s, '') };
290
+ }
291
+ });
292
+ },
293
+ };
294
+
295
+ // ── SkillStore ────────────────────────────────────────────────────────────────
296
+
297
+ const SkillStore = {
298
+ dir(projectName) {
299
+ return path.join(ProjectStore.dir(projectName), '.nha-context');
300
+ },
301
+
302
+ list(projectName) {
303
+ const dir = this.dir(projectName);
304
+ if (!fs.existsSync(dir)) return [];
305
+ return fs.readdirSync(dir)
306
+ .filter((f) => f.endsWith('.md') || f.endsWith('.txt'))
307
+ .map((fname) => {
308
+ const content = fs.readFileSync(path.join(dir, fname), 'utf-8');
309
+ const type = fname.startsWith('memory') ? 'memory'
310
+ : fname.startsWith('liara') || fname.startsWith('provider') ? 'provider'
311
+ : fname.endsWith('.log.md') ? 'log'
312
+ : 'skill';
313
+ return { name: fname, content, type };
314
+ });
315
+ },
316
+
317
+ save(projectName, skills) {
318
+ const dir = ensureDir(this.dir(projectName));
319
+ for (const s of skills) {
320
+ if (!s.name || !s.name.match(/^[a-z0-9_./-]+$/i)) continue;
321
+ fs.writeFileSync(path.join(dir, s.name), s.content ?? '', 'utf-8');
322
+ }
323
+ },
324
+
325
+ delete(projectName, skillName) {
326
+ if (!skillName || !skillName.match(/^[a-z0-9_./-]+$/i)) return;
327
+ const abs = path.join(this.dir(projectName), skillName);
328
+ if (fs.existsSync(abs)) fs.unlinkSync(abs);
329
+ },
330
+
331
+ context(projectName) {
332
+ const skills = this.list(projectName);
333
+ if (skills.length === 0) return '';
334
+ return skills
335
+ .filter((s) => s.content.trim())
336
+ .map((s) => `## ${s.type.toUpperCase()} — ${s.name}\n${s.content.slice(0, 4000)}`)
337
+ .join('\n\n---\n\n');
338
+ },
339
+ };
340
+
341
+ // ── SnapshotStore ─────────────────────────────────────────────────────────────
342
+
343
+ const SnapshotStore = {
344
+ dir(projectName) {
345
+ return path.join(NHA_DIR, 'webcraft-snapshots', _safeName(projectName));
346
+ },
347
+
348
+ list(projectName) {
349
+ const dir = this.dir(projectName);
350
+ if (!fs.existsSync(dir)) return [];
351
+ return fs.readdirSync(dir)
352
+ .filter((f) => f.endsWith('.json'))
353
+ .map((f) => {
354
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
355
+ return { ts: raw.ts, fileCount: raw.files?.length ?? 0 };
356
+ })
357
+ .sort((a, b) => b.ts.localeCompare(a.ts));
358
+ },
359
+
360
+ take(projectName) {
361
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
362
+ const dir = ensureDir(this.dir(projectName));
363
+ const proj = ProjectStore.load(projectName);
364
+ if (!proj) return null;
365
+ const snap = { ts, projectName, files: proj.files };
366
+ fs.writeFileSync(path.join(dir, `${ts}.json`), JSON.stringify(snap), 'utf-8');
367
+ return ts;
368
+ },
369
+
370
+ restore(projectName, ts) {
371
+ const dir = this.dir(projectName);
372
+ const snapFile = path.join(dir, `${ts}.json`);
373
+ if (!fs.existsSync(snapFile)) return false;
374
+ const snap = JSON.parse(fs.readFileSync(snapFile, 'utf-8'));
375
+ ProjectStore.save(projectName, '', snap.files ?? []);
376
+ return true;
377
+ },
378
+ };
379
+
380
+ // ── ChatStore ─────────────────────────────────────────────────────────────────
381
+
382
+ const ChatStore = {
383
+ path(projectName) {
384
+ return path.join(ProjectStore.dir(projectName), '.nha-chat.json');
385
+ },
386
+ load(projectName) {
387
+ const p = this.path(projectName);
388
+ if (!fs.existsSync(p)) return [];
389
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return []; }
390
+ },
391
+ save(projectName, messages) {
392
+ const dir = ProjectStore.dir(projectName);
393
+ if (!fs.existsSync(dir)) return;
394
+ fs.writeFileSync(this.path(projectName), JSON.stringify(messages), 'utf-8');
395
+ },
396
+ };
397
+
398
+ // ── WebCraftAgent ─────────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Tool-calling agent that can read/edit/write files inside the project.
402
+ * Uses structured SSE events: { type: 'text', token } | { type: 'tool', ... } | { type: 'done', changed }
403
+ */
404
+ async function runWebCraftAgent(config, projectName, message, attachments, emit) {
405
+ const dir = ProjectStore.dir(projectName);
406
+ if (!fs.existsSync(dir)) { emit({ type: 'error', msg: 'Project not found' }); return; }
407
+
408
+ const files = _listProjectFiles(dir);
409
+ const skillCtx = SkillStore.context(projectName);
410
+
411
+ const fileIndex = files.map((f) => `- ${f}`).join('\n');
412
+ const today = new Date().toISOString().slice(0, 10);
413
+ const LANG_MAP = { en:'English',it:'Italian',es:'Spanish',fr:'French',de:'German',pt:'Portuguese' };
414
+ const language = LANG_MAP[(config?.language||'it').slice(0,2)] || 'Italian';
415
+
416
+ // Build context: include files mentioned in the message or all files if ≤ 8
417
+ const mentionedFiles = files.filter((f) => message.toLowerCase().includes(f.toLowerCase().split('/').pop() ?? ''));
418
+ const contextFiles = mentionedFiles.length > 0 ? mentionedFiles : files.slice(0, 8);
419
+ const fileContents = contextFiles.map((rel) => {
420
+ try {
421
+ const content = fs.readFileSync(path.join(dir, rel), 'utf-8');
422
+ return `### FILE: ${rel}\n\`\`\`\n${content.slice(0, 6000)}\n\`\`\``;
423
+ } catch { return ''; }
424
+ }).filter(Boolean).join('\n\n');
425
+
426
+ const toolSpec = `
427
+ AVAILABLE TOOLS (use XML tags exactly as shown):
428
+
429
+ 1. Read a file:
430
+ <tool>{"op":"read","path":"filename.js"}</tool>
431
+
432
+ 2. Edit a file (replace a snippet):
433
+ <tool>{"op":"edit","path":"filename.js","old":"EXACT_EXISTING_CODE","new":"REPLACEMENT_CODE"}</tool>
434
+
435
+ 3. Write a complete file (full content):
436
+ <tool>{"op":"write","path":"filename.js","content":"FULL_FILE_CONTENT"}</tool>
437
+
438
+ RULES:
439
+ - Always use edit for small targeted changes (preferred — faster and safer)
440
+ - Use write only when creating a new file or doing a complete rewrite
441
+ - "old" must be an EXACT verbatim match of the existing code — no paraphrasing
442
+ - Never apply a tool to a file outside the project scope
443
+ - After each tool use, continue explaining what you did
444
+ `;
445
+
446
+ const systemPrompt = [
447
+ `You are WebCraft Agent, an expert full-stack developer. Today is ${today}. Respond in ${language}.`,
448
+ `\n\n## PROJECT: ${projectName}`,
449
+ `\n## FILES:\n${fileIndex}`,
450
+ skillCtx ? `\n\n## CONTEXT (Skills/Memory):\n${skillCtx}` : '',
451
+ attachments?.length ? `\n\n## ATTACHMENTS: ${attachments.map((a) => a.name).join(', ')}` : '',
452
+ `\n\n## CURRENT FILE CONTENTS:\n${fileContents}`,
453
+ `\n\n${toolSpec}`,
454
+ `\n\nIMPORTANT: Be precise, surgical, and explain every change.`,
455
+ ].join('');
456
+
457
+ // Prepare user content (text + images if any)
458
+ const userContent = attachments?.length
459
+ ? _buildMultimodalContent(message, attachments)
460
+ : message;
461
+
462
+ let fullResponse = '';
463
+ let hasChanges = false;
464
+
465
+ await callLLMStream(config, systemPrompt, userContent, (token) => {
466
+ fullResponse += token;
467
+ // Suppress raw <tool> blocks from text stream — only emit visible text
468
+ const visibleToken = token.replace(/<tool>[\s\S]*?<\/tool>/g, '');
469
+ if (visibleToken) emit({ type: 'text', token: visibleToken });
470
+ }, { max_tokens: 8192 });
471
+
472
+ // ── Execute all tool calls found in the response ───────────────────────────
473
+ const toolRegex = /<tool>([\s\S]*?)<\/tool>/g;
474
+ let match;
475
+ while ((match = toolRegex.exec(fullResponse)) !== null) {
476
+ let toolCall;
477
+ try { toolCall = JSON.parse(match[1].trim()); } catch {
478
+ emit({ type: 'tool', op: 'parse_error', path: '', result: 'json_parse_failed' });
479
+ continue;
480
+ }
481
+
482
+ const { op, path: relPath, old: oldStr, new: newStr, content } = toolCall;
483
+ if (!relPath || !_isSafePath(relPath)) {
484
+ emit({ type: 'tool', op, path: relPath ?? '', result: 'unsafe_path' });
485
+ continue;
486
+ }
487
+
488
+ if (op === 'read') {
489
+ const src = ProjectStore.readFile(projectName, relPath);
490
+ emit({ type: 'tool', op: 'read', path: relPath, result: src !== null ? 'ok' : 'not_found' });
491
+
492
+ } else if (op === 'edit') {
493
+ const src = ProjectStore.readFile(projectName, relPath);
494
+ if (src === null) {
495
+ emit({ type: 'tool', op: 'edit', path: relPath, result: 'file_not_found' });
496
+ } else if (!src.includes(oldStr)) {
497
+ // Fallback: try LLM-assisted repair
498
+ const repaired = await _attemptEditRepair(config, relPath, src, oldStr, newStr);
499
+ if (repaired) {
500
+ ProjectStore.writeFile(projectName, relPath, repaired);
501
+ hasChanges = true;
502
+ emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok_repaired', oldSnippet: oldStr.slice(0, 300), newSnippet: newStr.slice(0, 300) });
503
+ } else {
504
+ emit({ type: 'tool', op: 'edit', path: relPath, result: 'old_not_found', oldSnippet: oldStr.slice(0, 200) });
505
+ }
506
+ } else {
507
+ const newSrc = src.replace(oldStr, newStr ?? '');
508
+ ProjectStore.writeFile(projectName, relPath, newSrc);
509
+ hasChanges = true;
510
+ emit({ type: 'tool', op: 'edit', path: relPath, result: 'ok', oldSnippet: oldStr.slice(0, 300), newSnippet: newStr?.slice(0, 300) ?? '' });
511
+ }
512
+
513
+ } else if (op === 'write') {
514
+ if (content === undefined) {
515
+ emit({ type: 'tool', op: 'write', path: relPath, result: 'missing_content' });
516
+ } else {
517
+ ProjectStore.writeFile(projectName, relPath, content);
518
+ hasChanges = true;
519
+ emit({ type: 'tool', op: 'write', path: relPath, result: 'ok' });
520
+ }
521
+ }
522
+ }
523
+
524
+ emit({ type: 'done', changed: hasChanges });
525
+ }
526
+
527
+ // ── Generation pipeline (SSE) ─────────────────────────────────────────────────
528
+
529
+ const FILE_PLAN_SYSTEM = `You are an expert full-stack web developer. Your job is to design the complete file structure for a web project.
530
+ Output ONLY a JSON array of file objects: [{"name":"filename","purpose":"brief purpose"}]
531
+ Rules:
532
+ - Include ALL necessary files: HTML, CSS, JS, server, package.json, .env.example, README.md
533
+ - Use relative paths (e.g. "public/styles.css", "routes/auth.js")
534
+ - No explanation, no markdown, ONLY the JSON array.`;
535
+
536
+ async function runGenerate(config, projectName, description, blocks, authFields, emit) {
537
+ const blocksDesc = Object.entries(blocks)
538
+ .filter(([, enabled]) => enabled)
539
+ .map(([key]) => key)
540
+ .join(', ');
541
+ const authDesc = blocks.auth
542
+ ? `Auth fields: ${authFields.map((f) => `${f.label}(${f.type}${f.required ? ',required' : ''})`).join(', ')}`
543
+ : '';
544
+
545
+ const planPrompt = `Project: ${projectName}
546
+ Description: ${description}
547
+ ${blocksDesc ? `Required blocks: ${blocksDesc}` : ''}
548
+ ${authDesc}
549
+ Design a complete file structure for this project.`;
550
+
551
+ // Round 1: plan files
552
+ let filePlan = [];
553
+ try {
554
+ const planRaw = await callLLM(config, FILE_PLAN_SYSTEM, planPrompt, { max_tokens: 1500 });
555
+ const clean = planRaw.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
556
+ .replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '').trim();
557
+ const arr = JSON.parse(clean.match(/\[[\s\S]*\]/)?.[0] ?? clean);
558
+ if (Array.isArray(arr)) filePlan = arr.filter((f) => f.name && _isSafePath(f.name));
559
+ } catch {}
560
+
561
+ if (filePlan.length === 0) {
562
+ // Minimal fallback structure
563
+ filePlan = [
564
+ { name: 'index.html', purpose: 'Main HTML page' },
565
+ { name: 'styles.css', purpose: 'CSS styles' },
566
+ { name: 'app.js', purpose: 'Application logic' },
567
+ { name: 'server.js', purpose: 'Express server' },
568
+ { name: 'package.json', purpose: 'Node.js package manifest' },
569
+ ];
570
+ }
571
+
572
+ emit({ type: 'plan', files: filePlan });
573
+
574
+ const projectDir = ensureDir(ProjectStore.dir(projectName));
575
+ const generatedFiles = [];
576
+
577
+ // Round 2: generate each file
578
+ for (let fi = 0; fi < filePlan.length; fi++) {
579
+ const fileSpec = filePlan[fi];
580
+ emit({ type: 'file_start', name: fileSpec.name, fi: fi + 1, total: filePlan.length });
581
+
582
+ const allFileNames = filePlan.map((f) => f.name).join(', ');
583
+ const prevContext = generatedFiles.slice(-3)
584
+ .map((f) => `### ${f.name} (excerpt)\n${f.content.slice(0, 800)}`)
585
+ .join('\n\n');
586
+
587
+ const fileSys = `You are an expert full-stack web developer generating production-quality code.
588
+ Output ONLY the complete file content — no explanation, no markdown fences, no comments about what you're doing.
589
+ Write real, working, modern code. Do NOT use placeholder text like "TODO" or "// add code here".`;
590
+
591
+ const filePrompt = `Project: ${projectName}
592
+ Description: ${description}
593
+ ${blocksDesc ? `Required blocks: ${blocksDesc}` : ''}
594
+ ${authDesc}
595
+ All files in project: ${allFileNames}
596
+
597
+ Generate COMPLETE content for: ${fileSpec.name}
598
+ Purpose: ${fileSpec.purpose}
599
+ ${prevContext ? `\n--- Recent files for context ---\n${prevContext}` : ''}
600
+
601
+ Output ONLY the complete file content.`;
602
+
603
+ let fileContent = '';
604
+ let syntaxError = null;
605
+
606
+ try {
607
+ await callLLMStream(config, fileSys, filePrompt, (chunk) => {
608
+ fileContent += chunk;
609
+ emit({ type: 'file_chunk', name: fileSpec.name, chunk, fi: fi + 1, total: filePlan.length });
610
+ }, { max_tokens: 4096 });
611
+
612
+ // Quick syntax check for JS files
613
+ if (fileSpec.name.endsWith('.js') || fileSpec.name.endsWith('.mjs')) {
614
+ try { new Function(fileContent); } catch (e) { syntaxError = e.message.replace(/\n.*/s, ''); }
615
+ }
616
+
617
+ const abs = path.join(projectDir, fileSpec.name);
618
+ ensureDir(path.dirname(abs));
619
+ fs.writeFileSync(abs, fileContent, 'utf-8');
620
+ generatedFiles.push({ name: fileSpec.name, content: fileContent });
621
+ emit({ type: 'file_done', name: fileSpec.name, fi: fi + 1, total: filePlan.length, syntaxError });
622
+ } catch (e) {
623
+ emit({ type: 'file_error', name: fileSpec.name, error: e.message });
624
+ }
625
+ }
626
+
627
+ // Save project metadata
628
+ const meta = {
629
+ description,
630
+ blocks,
631
+ authFields,
632
+ createdAt: new Date().toISOString(),
633
+ updatedAt: new Date().toISOString(),
634
+ };
635
+ fs.writeFileSync(ProjectStore.metaPath(projectName), JSON.stringify(meta, null, 2), 'utf-8');
636
+
637
+ // Initialize skill context files
638
+ const ctxDir = ensureDir(SkillStore.dir(projectName));
639
+ const memFile = path.join(ctxDir, 'memory.md');
640
+ if (!fs.existsSync(memFile)) {
641
+ fs.writeFileSync(memFile, `# ${projectName} — Project Memory\n\n_Add architectural decisions, preferences, and notes here._\n`, 'utf-8');
642
+ }
643
+ const logFile = path.join(ctxDir, 'changes.log.md');
644
+ const logEntry = `## ${new Date().toISOString().slice(0, 10)} — Initial generation\n- Generated ${generatedFiles.length} files\n- Description: ${description}\n`;
645
+ fs.writeFileSync(logFile, (fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf-8') : '') + logEntry, 'utf-8');
646
+
647
+ const inputTokensEst = Math.round(filePlan.length * 800);
648
+ const outputTokensEst = generatedFiles.reduce((sum, f) => sum + Math.ceil(f.content.length / 4), 0);
649
+ emit({ type: 'done', tokIn: inputTokensEst, tokOut: outputTokensEst });
650
+ }
651
+
652
+ // ── ZIP download ──────────────────────────────────────────────────────────────
653
+
654
+ async function sendZip(projectName, res) {
655
+ const dir = ProjectStore.dir(projectName);
656
+ if (!fs.existsSync(dir)) { sendError(res, 404, 'Project not found'); return; }
657
+ res.writeHead(200, {
658
+ 'Content-Type': 'application/zip',
659
+ 'Content-Disposition': `attachment; filename="${_safeName(projectName)}.zip"`,
660
+ 'Access-Control-Allow-Origin': '*',
661
+ });
662
+ const { spawn: spawnProc } = await import('child_process');
663
+ const zip = spawnProc('zip', ['-r', '-', '.'], { cwd: dir });
664
+ zip.stdout.pipe(res);
665
+ zip.stderr.on('data', () => {}); // suppress
666
+ zip.once('exit', (code) => { if (code !== 0 && !res.writableEnded) res.end(); });
667
+ }
668
+
669
+ // ── Simple LLM call (skill AI generation) ────────────────────────────────────
670
+
671
+ async function handleLLMCall(req, res) {
672
+ const body = await parseBody(req);
673
+ const config = loadConfig();
674
+ const { system, user, max_tokens = 2048 } = body;
675
+ if (!user) return sendError(res, 400, 'user prompt required');
676
+ try {
677
+ const text = await callLLM(config, system ?? '', user, { max_tokens });
678
+ sendJSON(res, 200, { text });
679
+ } catch (e) { sendError(res, 500, e.message); }
680
+ }
681
+
682
+ // ── Route registration ────────────────────────────────────────────────────────
683
+
684
+ export function register(router) {
685
+
686
+ // ── LLM call (skill AI generation) ────────────────────────────────────────
687
+ router.post('/api/studio/webcraft', handleLLMCall);
688
+
689
+ // ── Generate project — SSE ─────────────────────────────────────────────────
690
+ router.post('/api/studio/webcraft/generate', async (req, res) => {
691
+ const body = await parseBody(req, 1_048_576);
692
+ const config = loadConfig();
693
+ const { projectName, description, blocks = {}, authFields = [] } = body;
694
+ if (!projectName || !description) return sendError(res, 400, 'projectName and description required');
695
+
696
+ const sse = sendSSE(res);
697
+ try {
698
+ await runGenerate(config, projectName, description, blocks, authFields, sse.send);
699
+ } catch (e) {
700
+ sse.send({ type: 'error', msg: e.message });
701
+ }
702
+ sse.end();
703
+ });
704
+
705
+ // ── Projects list ─────────────────────────────────────────────────────────
706
+ router.get('/api/studio/webcraft/projects', (_req, res) => {
707
+ try { sendJSON(res, 200, { projects: ProjectStore.list() }); }
708
+ catch (e) { sendError(res, 500, e.message); }
709
+ });
710
+
711
+ // ── Project load ──────────────────────────────────────────────────────────
712
+ router.get(/^\/api\/studio\/webcraft\/projects\/load\/(?<name>[^?]+)/, (req, res) => {
713
+ const projectName = decodeURIComponent(req.params.name ?? '');
714
+ const data = ProjectStore.load(projectName);
715
+ if (!data) return sendError(res, 404, 'Project not found');
716
+ sendJSON(res, 200, data);
717
+ });
718
+
719
+ // ── Project save ──────────────────────────────────────────────────────────
720
+ router.post('/api/studio/webcraft/projects/save', async (req, res) => {
721
+ try {
722
+ const { projectName, description, files } = await parseBody(req, 10_485_760);
723
+ if (!projectName || !Array.isArray(files)) return sendError(res, 400, 'projectName and files required');
724
+ ProjectStore.save(projectName, description ?? '', files);
725
+ sendJSON(res, 200, { ok: true });
726
+ } catch (e) { sendError(res, 500, e.message); }
727
+ });
728
+
729
+ // ── Project delete ────────────────────────────────────────────────────────
730
+ router.delete(/^\/api\/studio\/webcraft\/projects\/(?<name>[^?]+)/, async (req, res) => {
731
+ try {
732
+ const projectName = decodeURIComponent(req.params.name ?? '');
733
+ ProjectStore.delete(projectName);
734
+ sendJSON(res, 200, { ok: true });
735
+ } catch (e) { sendError(res, 500, e.message); }
736
+ });
737
+
738
+ // ── Chat save ─────────────────────────────────────────────────────────────
739
+ router.post('/api/studio/webcraft/projects/chat/save', async (req, res) => {
740
+ try {
741
+ const { projectName, chat } = await parseBody(req, 5_242_880);
742
+ if (!projectName) return sendError(res, 400, 'projectName required');
743
+ ChatStore.save(projectName, chat ?? []);
744
+ sendJSON(res, 200, { ok: true });
745
+ } catch (e) { sendError(res, 500, e.message); }
746
+ });
747
+
748
+ // ── Chat load ─────────────────────────────────────────────────────────────
749
+ router.get(/^\/api\/studio\/webcraft\/projects\/chat\/load\/(?<name>[^?]+)/, (req, res) => {
750
+ const projectName = decodeURIComponent(req.params.name ?? '');
751
+ sendJSON(res, 200, { chat: ChatStore.load(projectName) });
752
+ });
753
+
754
+ // ── Skills get ────────────────────────────────────────────────────────────
755
+ router.get(/^\/api\/studio\/webcraft\/skills\/(?<name>[^/?]+)(?:\?|$)/, (req, res) => {
756
+ const projectName = decodeURIComponent(req.params.name ?? '');
757
+ sendJSON(res, 200, { skills: SkillStore.list(projectName) });
758
+ });
759
+
760
+ // ── Skills save ───────────────────────────────────────────────────────────
761
+ router.post(/^\/api\/studio\/webcraft\/skills\/(?<name>[^/?]+)(?:\?|$)/, async (req, res) => {
762
+ try {
763
+ const projectName = decodeURIComponent(req.params.name ?? '');
764
+ const { skills } = await parseBody(req);
765
+ if (!Array.isArray(skills)) return sendError(res, 400, 'skills array required');
766
+ SkillStore.save(projectName, skills);
767
+ sendJSON(res, 200, { ok: true });
768
+ } catch (e) { sendError(res, 500, e.message); }
769
+ });
770
+
771
+ // ── Skill delete ──────────────────────────────────────────────────────────
772
+ router.post(/^\/api\/studio\/webcraft\/skills\/(?<name>[^/?]+)\/delete(?:\?|$)/, async (req, res) => {
773
+ try {
774
+ const projectName = decodeURIComponent(req.params.name ?? '');
775
+ const { name: skillName } = await parseBody(req);
776
+ SkillStore.delete(projectName, skillName);
777
+ sendJSON(res, 200, { ok: true });
778
+ } catch (e) { sendError(res, 500, e.message); }
779
+ });
780
+
781
+ // ── Sandbox start — SSE ───────────────────────────────────────────────────
782
+ router.post('/api/studio/webcraft/sandbox/start', async (req, res) => {
783
+ const { projectName } = await parseBody(req);
784
+ if (!projectName) return sendError(res, 400, 'projectName required');
785
+ const projectDir = ProjectStore.dir(projectName);
786
+
787
+ const sse = sendSSE(res);
788
+ try {
789
+ await sandbox.start(projectName, projectDir, sse.send);
790
+ } catch (e) {
791
+ sse.send({ type: 'error', msg: e.message });
792
+ }
793
+ sse.end();
794
+ });
795
+
796
+ // ── Sandbox stop ──────────────────────────────────────────────────────────
797
+ router.delete('/api/studio/webcraft/sandbox', async (_req, res) => {
798
+ try {
799
+ await sandbox.stop();
800
+ sendJSON(res, 200, { ok: true });
801
+ } catch (e) { sendError(res, 500, e.message); }
802
+ });
803
+
804
+ // ── Sandbox status ────────────────────────────────────────────────────────
805
+ router.get('/api/studio/webcraft/sandbox/status', (_req, res) => {
806
+ sendJSON(res, 200, sandbox.status());
807
+ });
808
+
809
+ // ── WebCraft Agent chat — SSE ─────────────────────────────────────────────
810
+ router.post('/api/studio/webcraft/agent', async (req, res) => {
811
+ const body = await parseBody(req, 10_485_760);
812
+ const config = loadConfig();
813
+ const { projectName, message, attachments = [] } = body;
814
+ if (!projectName || !message) return sendError(res, 400, 'projectName and message required');
815
+
816
+ const sse = sendSSE(res);
817
+ try {
818
+ await runWebCraftAgent(config, projectName, message, attachments, sse.send);
819
+ } catch (e) {
820
+ sse.send({ type: 'error', msg: e.message });
821
+ }
822
+ sse.end();
823
+ });
824
+
825
+ // ── Autofix queue ─────────────────────────────────────────────────────────
826
+ router.get('/api/studio/webcraft/agent/autofix-queue', (_req, res) => {
827
+ // The UI manages autofix state client-side; server reports sandbox crash if any
828
+ sendJSON(res, 200, { queue: [], sandboxRunning: sandbox.isRunning(), sandboxPort: sandbox.port });
829
+ });
830
+
831
+ // ── Grep ──────────────────────────────────────────────────────────────────
832
+ router.post('/api/studio/webcraft/grep', async (req, res) => {
833
+ try {
834
+ const { projectName, query } = await parseBody(req);
835
+ if (!projectName || !query) return sendError(res, 400, 'projectName and query required');
836
+ sendJSON(res, 200, { matches: ProjectStore.grep(projectName, query) });
837
+ } catch (e) { sendError(res, 500, e.message); }
838
+ });
839
+
840
+ // ── Snapshot create ───────────────────────────────────────────────────────
841
+ router.post('/api/studio/webcraft/snapshot', async (req, res) => {
842
+ try {
843
+ const { projectName } = await parseBody(req);
844
+ if (!projectName) return sendError(res, 400, 'projectName required');
845
+ const ts = SnapshotStore.take(projectName);
846
+ if (!ts) return sendError(res, 500, 'Snapshot failed');
847
+ sendJSON(res, 200, { snapshot: ts });
848
+ } catch (e) { sendError(res, 500, e.message); }
849
+ });
850
+
851
+ // ── Snapshots list ────────────────────────────────────────────────────────
852
+ router.get(/^\/api\/studio\/webcraft\/snapshots\/(?<name>[^?]+)/, (req, res) => {
853
+ const projectName = decodeURIComponent(req.params.name ?? '');
854
+ sendJSON(res, 200, { snapshots: SnapshotStore.list(projectName) });
855
+ });
856
+
857
+ // ── Snapshot restore ──────────────────────────────────────────────────────
858
+ router.post('/api/studio/webcraft/restore', async (req, res) => {
859
+ try {
860
+ const { projectName, ts } = await parseBody(req);
861
+ if (!projectName || !ts) return sendError(res, 400, 'projectName and ts required');
862
+ const ok = SnapshotStore.restore(projectName, ts);
863
+ if (!ok) return sendError(res, 404, 'Snapshot not found');
864
+ sendJSON(res, 200, { ok: true });
865
+ } catch (e) { sendError(res, 500, e.message); }
866
+ });
867
+
868
+ // ── Syntax check ──────────────────────────────────────────────────────────
869
+ router.post('/api/studio/webcraft/syntax-check', async (req, res) => {
870
+ try {
871
+ const { projectName } = await parseBody(req);
872
+ if (!projectName) return sendError(res, 400, 'projectName required');
873
+ sendJSON(res, 200, { results: ProjectStore.syntaxCheck(projectName) });
874
+ } catch (e) { sendError(res, 500, e.message); }
875
+ });
876
+
877
+ // ── ZIP download ───────────────────────────────────────────────────────────
878
+ router.get(/^\/api\/studio\/webcraft\/download\/(?<name>[^?]+)/, (req, res) => {
879
+ const projectName = decodeURIComponent(req.params.name ?? '');
880
+ sendZip(projectName, res).catch((e) => sendError(res, 500, e.message));
881
+ });
882
+ }
883
+
884
+ // ── Helpers ───────────────────────────────────────────────────────────────────
885
+
886
+ function _safeName(name) {
887
+ return (name ?? '').replace(/[^a-zA-Z0-9_\-. ]/g, '_').trim() || 'unnamed';
888
+ }
889
+
890
+ function _isSafePath(relPath) {
891
+ if (!relPath || typeof relPath !== 'string') return false;
892
+ if (relPath.includes('..') || path.isAbsolute(relPath)) return false;
893
+ if (relPath.startsWith('.nha-')) return false; // protect internal dirs
894
+ return true;
895
+ }
896
+
897
+ function _listProjectFiles(dir, relBase = '') {
898
+ const results = [];
899
+ try {
900
+ for (const entry of fs.readdirSync(path.join(dir, relBase), { withFileTypes: true })) {
901
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
902
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
903
+ if (entry.isDirectory()) results.push(..._listProjectFiles(dir, rel));
904
+ else results.push(rel);
905
+ }
906
+ } catch {}
907
+ return results;
908
+ }
909
+
910
+ function _detectEntry(dir) {
911
+ for (const name of ['server.js', 'app.js', 'index.js', 'server.mjs', 'app.mjs', 'index.mjs']) {
912
+ if (fs.existsSync(path.join(dir, name))) return name;
913
+ }
914
+ // Try package.json main
915
+ try {
916
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
917
+ if (pkg.main && fs.existsSync(path.join(dir, pkg.main))) return pkg.main;
918
+ } catch {}
919
+ return null;
920
+ }
921
+
922
+ function _patchEntry(projectDir, entryFile, shimDir, port) {
923
+ // Write a launcher that injects shims and then requires the actual entry
924
+ const launcherPath = path.join(projectDir, '.nha-launcher.js');
925
+ const entryAbs = path.join(projectDir, entryFile).replace(/\\/g, '/');
926
+ const shimAbs = path.join(shimDir, 'index.js').replace(/\\/g, '/');
927
+ const launcher = [
928
+ `// NHA WebCraft Sandbox Launcher — auto-generated`,
929
+ `process.env.PORT = process.env.PORT || '${port}';`,
930
+ `process.env.NODE_ENV = 'development';`,
931
+ `// Inject shims before loading user code`,
932
+ `require('${shimAbs}');`,
933
+ `require('${entryAbs}');`,
934
+ ].join('\n');
935
+ fs.writeFileSync(launcherPath, launcher, 'utf-8');
936
+ return '.nha-launcher.js';
937
+ }
938
+
939
+ function _writeShims(shimDir) {
940
+ // In-memory pg replacement
941
+ const pgShim = `
942
+ const EventEmitter = require('events');
943
+ class Client extends EventEmitter {
944
+ constructor() { super(); this.connected = false; }
945
+ async connect() { this.connected = true; }
946
+ async query(text, params) { return { rows: [], rowCount: 0 }; }
947
+ async end() { this.connected = false; }
948
+ release() {}
949
+ }
950
+ class Pool extends EventEmitter {
951
+ constructor() { super(); }
952
+ async query(text, params) { return { rows: [], rowCount: 0 }; }
953
+ async connect() { return new Client(); }
954
+ async end() {}
955
+ on() { return this; }
956
+ }
957
+ module.exports = { Client, Pool, default: { Client, Pool } };
958
+ `;
959
+
960
+ // In-memory redis replacement
961
+ const redisShim = `
962
+ class MemoryStore {
963
+ constructor() { this._store = new Map(); this._timers = new Map(); }
964
+ async get(k) { return this._store.get(k) ?? null; }
965
+ async set(k, v, ...args) {
966
+ this._store.set(k, v);
967
+ const exIdx = args.indexOf('EX');
968
+ if (exIdx >= 0) {
969
+ clearTimeout(this._timers.get(k));
970
+ this._timers.set(k, setTimeout(() => this._store.delete(k), args[exIdx + 1] * 1000));
971
+ }
972
+ return 'OK';
973
+ }
974
+ async del(k) { return this._store.delete(k) ? 1 : 0; }
975
+ async exists(k) { return this._store.has(k) ? 1 : 0; }
976
+ async expire(k, s) { return 1; }
977
+ async ttl(k) { return -1; }
978
+ async hget(k, f) { return (this._store.get(k) ?? {})[f] ?? null; }
979
+ async hset(k, f, v) { const m = this._store.get(k) ?? {}; m[f] = v; this._store.set(k, m); return 1; }
980
+ async hgetall(k) { return this._store.get(k) ?? null; }
981
+ async lpush(k, ...vals) { const a = this._store.get(k) ?? []; a.unshift(...vals); this._store.set(k, a); return a.length; }
982
+ async lrange(k, s, e) { const a = this._store.get(k) ?? []; return a.slice(s, e < 0 ? undefined : e + 1); }
983
+ async incr(k) { const v = (parseInt(this._store.get(k)) || 0) + 1; this._store.set(k, String(v)); return v; }
984
+ async keys(pattern) { return [...this._store.keys()]; }
985
+ async flushall() { this._store.clear(); return 'OK'; }
986
+ on(ev, cb) { return this; }
987
+ createClient() { return new MemoryStore(); }
988
+ }
989
+ const store = new MemoryStore();
990
+ module.exports = store;
991
+ module.exports.createClient = () => store;
992
+ module.exports.default = store;
993
+ `;
994
+
995
+ // Security headers shim (express middleware)
996
+ const helmetShim = `module.exports = () => (req, res, next) => next();`;
997
+
998
+ // Generic no-op shim for unknown enterprise deps
999
+ const noopShim = `module.exports = new Proxy({}, { get: () => new Proxy(() => {}, { get: (_, p) => p === 'then' ? undefined : new Proxy(() => {}, { get: (__, q) => q === 'then' ? undefined : () => {} }) }) });`;
1000
+
1001
+ fs.writeFileSync(path.join(shimDir, 'pg.js'), pgShim, 'utf-8');
1002
+ fs.writeFileSync(path.join(shimDir, 'redis.js'), redisShim, 'utf-8');
1003
+ fs.writeFileSync(path.join(shimDir, 'helmet.js'), helmetShim, 'utf-8');
1004
+ fs.writeFileSync(path.join(shimDir, 'ioredis.js'), redisShim, 'utf-8');
1005
+ fs.writeFileSync(path.join(shimDir, 'mongoose.js'), noopShim, 'utf-8');
1006
+ fs.writeFileSync(path.join(shimDir, 'sequelize.js'), noopShim, 'utf-8');
1007
+
1008
+ // Shim index — overrides require() for known modules via Module._resolveFilename
1009
+ const shimIndex = `
1010
+ const Module = require('module');
1011
+ const path = require('path');
1012
+ const __shimDir = ${JSON.stringify(shimDir)};
1013
+
1014
+ const SHIMS = {
1015
+ 'pg': path.join(__shimDir, 'pg.js'),
1016
+ 'redis': path.join(__shimDir, 'redis.js'),
1017
+ 'ioredis': path.join(__shimDir, 'redis.js'),
1018
+ 'helmet': path.join(__shimDir, 'helmet.js'),
1019
+ 'mongoose': path.join(__shimDir, 'mongoose.js'),
1020
+ 'sequelize': path.join(__shimDir, 'sequelize.js'),
1021
+ };
1022
+
1023
+ const _original = Module._resolveFilename.bind(Module);
1024
+ Module._resolveFilename = function(request, parent, isMain, options) {
1025
+ if (SHIMS[request]) return SHIMS[request];
1026
+ return _original(request, parent, isMain, options);
1027
+ };
1028
+ `;
1029
+ fs.writeFileSync(path.join(shimDir, 'index.js'), shimIndex, 'utf-8');
1030
+ }
1031
+
1032
+ /** Find a free TCP port in [min, max]. */
1033
+ function _findFreePort(min, max) {
1034
+ return new Promise((resolve) => {
1035
+ let current = min;
1036
+ const tryNext = () => {
1037
+ if (current > max) { resolve(null); return; }
1038
+ const server = createServer();
1039
+ server.once('error', () => { current++; tryNext(); });
1040
+ server.listen(current, '127.0.0.1', () => {
1041
+ server.close(() => resolve(current));
1042
+ });
1043
+ };
1044
+ tryNext();
1045
+ });
1046
+ }
1047
+
1048
+ /** Poll until port is open or timeout expires. */
1049
+ function _waitForPort(port, timeoutMs) {
1050
+ return new Promise((resolve) => {
1051
+ const deadline = Date.now() + timeoutMs;
1052
+ const check = () => {
1053
+ const sock = createServer();
1054
+ sock.once('error', () => {
1055
+ // Port is still closed by us — it means the app is listening
1056
+ resolve(true);
1057
+ });
1058
+ sock.listen(port, '127.0.0.1', () => {
1059
+ // We could bind it → app hasn't taken the port yet
1060
+ sock.close();
1061
+ if (Date.now() > deadline) { resolve(false); return; }
1062
+ setTimeout(check, 300);
1063
+ });
1064
+ };
1065
+ check();
1066
+ });
1067
+ }
1068
+
1069
+ /**
1070
+ * If the exact old_string wasn't found, ask the LLM to produce the corrected file.
1071
+ * Returns the new file content or null if repair failed.
1072
+ */
1073
+ async function _attemptEditRepair(config, relPath, currentContent, oldStr, newStr) {
1074
+ try {
1075
+ const repairSys = `You are a code repair agent. You receive a file, an intended old snippet, and a replacement. The old snippet doesn't match exactly. Apply the semantically equivalent change and return ONLY the complete corrected file content.`;
1076
+ const repairPrompt = `FILE: ${relPath}\n\nCURRENT CONTENT:\n${currentContent.slice(0, 6000)}\n\nINTENDED CHANGE:\nOLD (approximate):\n${oldStr.slice(0, 1000)}\n\nNEW:\n${newStr?.slice(0, 1000) ?? ''}\n\nReturn ONLY the complete corrected file.`;
1077
+ const result = await callLLM(config, repairSys, repairPrompt, { max_tokens: 4096 });
1078
+ if (result && result.trim()) return result;
1079
+ } catch {}
1080
+ return null;
1081
+ }
1082
+
1083
+ function _buildMultimodalContent(text, attachments) {
1084
+ // Return structured content for vision-capable models
1085
+ const parts = [{ type: 'text', text }];
1086
+ for (const a of attachments) {
1087
+ if (a.mimeType?.startsWith('image/')) {
1088
+ parts.push({ type: 'image', source: { type: 'base64', media_type: a.mimeType, data: a.base64 } });
1089
+ }
1090
+ }
1091
+ return JSON.stringify(parts); // callLLM handles string content; vision needs provider-specific handling
1092
+ }