mnfst-run 1.0.20 → 1.0.22

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +635 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
package/serve.mjs CHANGED
@@ -31,6 +31,15 @@
31
31
  * panel already shows the page, so a second browser tab
32
32
  * is just noise. Put `--no-open` in an LLM preview's
33
33
  * launch config to guarantee suppression.
34
+ * --attach Supervised/agent mode (e.g. an LLM preview panel that
35
+ * assigns a port and tracks the process it spawns). Never
36
+ * starts a SECOND dev server for a project already running:
37
+ * if a server for this root exists on another port, it
38
+ * binds the assigned port and reverse-proxies to that real
39
+ * server (live reload included); if it's already on the
40
+ * assigned port it just attaches; otherwise it starts one
41
+ * normally. Let the supervisor pick the port (no --port;
42
+ * it's read from PORT). Pair with `--no-open`.
34
43
  * --list Print all mnfst-run servers currently running on this
35
44
  * machine and exit.
36
45
  *
@@ -43,7 +52,9 @@
43
52
  * sources and updates Alpine store reactively (no reload)
44
53
  * other → full page reload
45
54
  */
46
- import { createServer, get as httpGet } from 'http';
55
+ import { createServer, get as httpGet, request as httpRequest } from 'http';
56
+ import { request as httpsRequest } from 'https';
57
+ import { connect as tlsConnect } from 'tls';
47
58
  import {
48
59
  readFileSync, statSync, watch,
49
60
  existsSync, writeFileSync, unlinkSync,
@@ -152,9 +163,10 @@ const LIVE_RELOAD_SCRIPT = `<script>
152
163
  // by the host. See the Appwrite setup doc for the full pattern.
153
164
  function loadEnvFile(rootDir) {
154
165
  const envPath = join(rootDir, '.env');
155
- if (!existsSync(envPath)) return { public: {}, private: [] };
166
+ if (!existsSync(envPath)) return { public: {}, private: [], privateValues: {} };
156
167
  const publicEnv = {};
157
168
  const privateNames = [];
169
+ const privateValues = {}; // server-side only — NEVER injected into window.env
158
170
  try {
159
171
  const text = readFileSync(envPath, 'utf8');
160
172
  for (const line of text.split(/\r?\n/)) {
@@ -170,12 +182,12 @@ function loadEnvFile(rootDir) {
170
182
  value = value.slice(1, -1);
171
183
  }
172
184
  if (key.startsWith('PUBLIC_')) publicEnv[key] = value;
173
- else privateNames.push(key);
185
+ else { privateNames.push(key); privateValues[key] = value; }
174
186
  }
175
187
  } catch (error) {
176
188
  console.warn('[mnfst-run] Failed to parse .env:', error.message);
177
189
  }
178
- return { public: publicEnv, private: privateNames };
190
+ return { public: publicEnv, private: privateNames, privateValues };
179
191
  }
180
192
 
181
193
  // Build a `<script>window.env = {…};</script>` tag from the public env map.
@@ -226,6 +238,12 @@ let openBrowserEnabled = !(
226
238
  );
227
239
 
228
240
  let listMode = false;
241
+ // --attach: supervised/agent mode (e.g. Claude Code's preview panel). Guarantees
242
+ // a live server in the FOREGROUND on the requested --port: if that exact port is
243
+ // already serving this root, stay attached to it (don't exit) instead of bailing;
244
+ // a separate server the user started on another port is left alone. Never writes
245
+ // or deletes the running-server registry, so it can't clobber the user's entry.
246
+ let attachMode = false;
229
247
 
230
248
  for (let i = 0; i < args.length; i++) {
231
249
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) { port = parseInt(args[++i], 10); continue; }
@@ -233,6 +251,7 @@ for (let i = 0; i < args.length; i++) {
233
251
  if (args[i] === '--idle-shutdown' && args[i + 1]) { idleShutdownSec = parseInt(args[++i], 10); continue; }
234
252
  if (args[i] === '--no-open') { openBrowserEnabled = false; continue; }
235
253
  if (args[i] === '--open') { openBrowserEnabled = true; continue; }
254
+ if (args[i] === '--attach') { attachMode = true; continue; }
236
255
  if (args[i] === '--list' || args[i] === '-l') { listMode = true; continue; }
237
256
  if (!args[i].startsWith('-')) dir = args[i];
238
257
  }
@@ -341,13 +360,87 @@ if (listMode) {
341
360
  }
342
361
 
343
362
  const root = resolve(process.cwd(), dir);
363
+ const EDIT_ENABLED = process.argv.includes('--edit') || process.env.MNFST_EDIT === '1'; // gates /__edit/save (edit-plugin source write-back, authoring only)
344
364
 
345
365
  // Load .env from the serving root (if present) and pre-build the inject
346
366
  // script. Kept as a single string so serveFile doesn't re-stringify on every
347
367
  // HTML response. Empty string when no public vars exist — the injection step
348
368
  // becomes a no-op for projects whose .env holds only server-side secrets.
349
- const { public: publicEnv, private: privateEnvNames } = loadEnvFile(root);
350
- const envInjectScript = buildEnvInjectScript(publicEnv);
369
+ const { public: publicEnv, private: privateEnvNames, privateValues: privateEnv } = loadEnvFile(root);
370
+ // Appwrite dev proxy: when APPWRITE_PROXY_TARGET is set (server-side .env), the
371
+ // dev server proxies /_appwrite/* to that Appwrite origin so the session cookie
372
+ // is FIRST-PARTY on localhost. Cross-origin Appwrite Cloud blocks the third-party
373
+ // session cookie, and some endpoints (e.g. Presences) don't honour the
374
+ // X-Fallback-Cookies workaround the SDK uses for the others — so user-scoped
375
+ // calls land as the anonymous "guests" role. Proxying makes Appwrite same-origin
376
+ // and fixes it uniformly (HTTP + Realtime WebSocket).
377
+ const APPWRITE_PROXY_TARGET = (process.env.APPWRITE_PROXY_TARGET || privateEnv.APPWRITE_PROXY_TARGET || '').trim();
378
+ const APPWRITE_PROXY_PREFIX = '/_appwrite';
379
+
380
+ let envInjectScript = buildEnvInjectScript(publicEnv);
381
+ if (APPWRITE_PROXY_TARGET) {
382
+ // Point the Appwrite endpoint at the same-origin proxy. Computed in the browser
383
+ // from location.origin so it's port-independent (auto-port safe) and prod-safe
384
+ // (production sets PUBLIC_APPWRITE_ENDPOINT to the real/custom domain instead).
385
+ envInjectScript += `<script>window.env=Object.assign(window.env||{},{PUBLIC_APPWRITE_ENDPOINT:location.origin+'${APPWRITE_PROXY_PREFIX}/v1'});</script>`;
386
+ }
387
+
388
+
389
+ // --- Turnkey AI relay (gates the same-origin /_ai/chat route) ---------------
390
+ // Reads the optional `ai` block from manifest.json and the LLM key from .env
391
+ // (server-side only). When the `ai` block is present, the dev server hosts the
392
+ // relay so a chat adapter can POST to `/_ai/chat` with no separate proxy, no
393
+ // CORS, and no key in the browser. No key → MOCK replies (try keyless); add the
394
+ // key for real. The same path is served by managed Manifest hosting in prod.
395
+ const aiConfig = (() => {
396
+ try { return JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')).ai || null; }
397
+ catch { return null; }
398
+ })();
399
+ const aiKey = process.env.ANTHROPIC_API_KEY || privateEnv.ANTHROPIC_API_KEY || '';
400
+ if (publicEnv.PUBLIC_ANTHROPIC_API_KEY) {
401
+ // The one footgun: a PUBLIC_-prefixed LLM key would ship to every visitor.
402
+ console.warn('[mnfst-run] ⚠ PUBLIC_ANTHROPIC_API_KEY is injected into the BROWSER and exposes your key to every visitor. Rename it to ANTHROPIC_API_KEY (no PUBLIC_ prefix) — the dev server uses it server-side via /_ai/chat.');
403
+ }
404
+ if (aiConfig) {
405
+ console.log(`[mnfst-run] AI relay on /_ai/chat — provider=${aiConfig.provider || 'anthropic'} model=${aiConfig.model || 'claude-haiku-4-5'} mode=${aiKey ? 'REAL' : 'MOCK (no ANTHROPIC_API_KEY)'}`);
406
+ }
407
+
408
+ function aiSse(res, o) { res.write(`event: ${o.type}\ndata: ${JSON.stringify(o)}\n\n`); }
409
+ function streamMockAi(res) {
410
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
411
+ aiSse(res, { type: 'message_start', message: { id: 'mock', role: 'assistant' } });
412
+ aiSse(res, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
413
+ const text = "Here's a **mock** reply from the in-server relay:\n\n- no separate proxy\n- the key stays server-side\n- add `ANTHROPIC_API_KEY` to `.env` for real Claude\n\n```js\nconst turnkey = true;\n```";
414
+ const chunks = text.match(/\S+\s*/g) || [text];
415
+ let i = 0;
416
+ const tick = setInterval(() => {
417
+ if (i >= chunks.length) { clearInterval(tick); aiSse(res, { type: 'content_block_stop', index: 0 }); aiSse(res, { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: chunks.length } }); aiSse(res, { type: 'message_stop' }); res.end(); return; }
418
+ aiSse(res, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: chunks[i++] } });
419
+ }, 45);
420
+ res.on('close', () => clearInterval(tick));
421
+ }
422
+ async function streamRealAi(res, payload) {
423
+ const upstream = await fetch('https://api.anthropic.com/v1/messages', {
424
+ method: 'POST',
425
+ headers: { 'content-type': 'application/json', 'x-api-key': aiKey, 'anthropic-version': '2023-06-01' },
426
+ body: JSON.stringify({
427
+ model: payload.model || aiConfig.model || 'claude-haiku-4-5',
428
+ max_tokens: payload.max_tokens || aiConfig.maxTokens || 1024,
429
+ stream: true,
430
+ system: payload.system || aiConfig.system || undefined,
431
+ messages: payload.messages || []
432
+ })
433
+ });
434
+ if (!upstream.ok) {
435
+ const body = await upstream.text();
436
+ res.writeHead(upstream.status, { 'Content-Type': 'text/event-stream' });
437
+ aiSse(res, { type: 'error', error: { message: `upstream ${upstream.status}: ${body.slice(0, 300)}` } });
438
+ return res.end();
439
+ }
440
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
441
+ for await (const chunk of upstream.body) res.write(chunk); // passthrough — exact Anthropic SSE
442
+ res.end();
443
+ }
351
444
  const publicCount = Object.keys(publicEnv).length;
352
445
  if (publicCount > 0) {
353
446
  console.log(`Loaded ${publicCount} PUBLIC_ env var(s) into window.env`);
@@ -362,13 +455,26 @@ if (privateEnvNames.length > 0) {
362
455
  );
363
456
  }
364
457
 
365
- // Dedup: if a server is already serving this exact root, point the user at
366
- // it (and open the browser, since that's what they were going to do anyway).
458
+ const label = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
459
+
460
+ // If a server is already serving this exact root, REUSE it — never start a
461
+ // second dev server for the same project.
462
+ // - manual use: print the URL and exit.
463
+ // - --attach (supervisor, e.g. Claude Code's preview panel): the panel only
464
+ // uses a server on the port it assigned us, and can't point at a server it
465
+ // didn't spawn. So if the existing server is on OUR port, just attach; if
466
+ // it's on a different port, bind our port and reverse-proxy to it — the
467
+ // existing server stays the only real dev server (file-watch, live reload),
468
+ // and the proxy is a thin pass-through the panel can track.
367
469
  const existing = await findRunningServer(root);
368
470
  if (existing) {
471
+ if (attachMode) {
472
+ if (existing.port === port) attachToExisting(existing.port); // already on our port — just keep alive
473
+ else startProxy(port, existing.port); // bridge our port → the real server
474
+ await new Promise(() => {}); // block here — never fall through and start a duplicate
475
+ }
369
476
  const url = `http://localhost:${existing.port}`;
370
- const label0 = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
371
- console.log(`\n${label0} already running at ${url} (pid ${existing.pid})\n`);
477
+ console.log(`\n${label} already running at ${url} (pid ${existing.pid})\n`);
372
478
  // Open the browser anyway — matches the experience of starting fresh.
373
479
  // (Skipped under --no-open / Claude Code, where the preview panel is the browser.)
374
480
  if (openBrowserEnabled) {
@@ -468,6 +574,297 @@ function isFile(p) {
468
574
  try { return statSync(p).isFile(); } catch { return false; }
469
575
  }
470
576
 
577
+ // Reorder a data file's rows/items to match `order` (array of ids). CSV (tabular,
578
+ // header with an `id` column) or JSON (array of {id}). Ids not in `order` keep their
579
+ // relative order at the end. Returns true if written. Used by /__edit/save (spike).
580
+ function reorderDataFile(file, order) {
581
+ const ext = extname(file).toLowerCase();
582
+ const text = readFileSync(file, 'utf8');
583
+ const ord = (order || []).map(String);
584
+ if (ext === '.json') {
585
+ const data = JSON.parse(text);
586
+ if (!Array.isArray(data)) return false;
587
+ const byId = new Map(data.map(it => [String(it.id), it]));
588
+ const next = ord.map(id => byId.get(id)).filter(Boolean);
589
+ data.forEach(it => { if (!ord.includes(String(it.id))) next.push(it); });
590
+ writeFileSync(file, JSON.stringify(next, null, 2) + (text.endsWith('\n') ? '\n' : ''));
591
+ return true;
592
+ }
593
+ if (ext === '.csv') {
594
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
595
+ const lines = text.split(/\r?\n/);
596
+ const trailing = lines.length && lines[lines.length - 1] === '';
597
+ if (trailing) lines.pop();
598
+ if (lines.length < 2) return false;
599
+ const header = lines[0];
600
+ const idIdx = header.split(',').map(s => s.trim().toLowerCase()).indexOf('id');
601
+ if (idIdx < 0) return false;
602
+ const rows = lines.slice(1);
603
+ const idOf = (r) => (r.split(',')[idIdx] || '').trim(); // id col is simple (unquoted); full row line preserved
604
+ const byId = new Map(rows.map(r => [idOf(r), r]));
605
+ const next = ord.map(id => byId.get(id)).filter(Boolean);
606
+ rows.forEach(r => { if (!ord.includes(idOf(r))) next.push(r); });
607
+ writeFileSync(file, [header, ...next].join(eol) + (trailing ? eol : ''));
608
+ return true;
609
+ }
610
+ return false;
611
+ }
612
+
613
+ // Quote-aware CSV line split + cell quoting (so editing an early column doesn't corrupt
614
+ // a later quoted field with commas).
615
+ function parseCsvLine(line) {
616
+ const out = []; let cur = '', q = false;
617
+ for (let i = 0; i < line.length; i++) { const ch = line[i]; if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } else { if (ch === ',') { out.push(cur); cur = ''; } else if (ch === '"') q = true; else cur += ch; } }
618
+ out.push(cur); return out;
619
+ }
620
+ const csvCell = (v) => /[",\n]/.test(v) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v);
621
+ // Data-value write-back (local CSV/JSON cell). Cloud sources are not files → caller should
622
+ // persist via $x.<source>.$update(id,{field}) instead.
623
+ function writeDataValue(p, manifest) {
624
+ const src = (manifest.data || {})[p.source], rel = typeof src === 'string' ? src : null;
625
+ if (!rel) return { kind: 'data-val', source: p.source, status: 'skipped', reason: 'not a file source (cloud → use $x.$update)' };
626
+ const file = safeResolve(rel); if (!file || !isFile(file)) return { kind: 'data-val', source: p.source, status: 'error', reason: `file not found: ${rel}` };
627
+ const ext = extname(file).toLowerCase(), text = readFileSync(file, 'utf8');
628
+ if (ext === '.json') {
629
+ const data = JSON.parse(text); if (!Array.isArray(data)) return { kind: 'data-val', source: p.source, status: 'error', reason: 'JSON is not an array' };
630
+ const rec = data.find(r => String(r.id) === String(p.id)); if (!rec) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
631
+ rec[p.field] = p.value; writeFileSync(file, JSON.stringify(data, null, 2) + (text.endsWith('\n') ? '\n' : ''));
632
+ return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
633
+ }
634
+ if (ext === '.csv') {
635
+ const eol = text.includes('\r\n') ? '\r\n' : '\n', lines = text.split(/\r?\n/);
636
+ const trailing = lines.length && lines[lines.length - 1] === ''; if (trailing) lines.pop();
637
+ if (lines.length < 2) return { kind: 'data-val', source: p.source, status: 'error', reason: 'empty CSV' };
638
+ const header = parseCsvLine(lines[0]).map(h => h.trim().toLowerCase());
639
+ const idIdx = header.indexOf('id'), fIdx = header.indexOf(String(p.field).toLowerCase());
640
+ if (idIdx < 0 || fIdx < 0) return { kind: 'data-val', source: p.source, status: 'error', reason: `column not found (id/${p.field})` };
641
+ let hit = false;
642
+ const rows = lines.slice(1).map(line => { const cells = parseCsvLine(line); if (String(cells[idIdx]).trim() === String(p.id)) { cells[fIdx] = p.value; hit = true; return cells.map(csvCell).join(','); } return line; });
643
+ if (!hit) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
644
+ writeFileSync(file, [lines[0], ...rows].join(eol) + (trailing ? eol : ''));
645
+ return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
646
+ }
647
+ return { kind: 'data-val', source: p.source, status: 'error', reason: `unsupported ext ${ext}` };
648
+ }
649
+
650
+ // --- Dependency-free HTML region editor (spike) ---
651
+ // packages/run is zero-dep by design, so instead of pulling in an HTML parser we do a
652
+ // small tag-aware scan: find the element carrying x-edit="<key>", depth-match its close,
653
+ // and splice. Robust for authored Manifest source (well-formed); not a general parser.
654
+ const escapeReg = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
655
+ function tagEnd(html, from) { // from = index of '<'; returns index of the unquoted '>'
656
+ let q = null;
657
+ for (let i = from + 1; i < html.length; i++) {
658
+ const ch = html[i];
659
+ if (q) { if (ch === q) q = null; }
660
+ else if (ch === '"' || ch === "'") q = ch;
661
+ else if (ch === '>') return i;
662
+ }
663
+ return -1;
664
+ }
665
+ function matchingClose(html, tag, from) { // depth-match </tag> accounting for nested same-name tags
666
+ const re = new RegExp(`<(/?)${escapeReg(tag)}(?=[\\s/>])`, 'gi');
667
+ re.lastIndex = from;
668
+ let depth = 1, m;
669
+ while ((m = re.exec(html))) {
670
+ if (m[1] === '/') { depth--; if (depth === 0) { const gt = html.indexOf('>', m.index); return { start: m.index, end: gt + 1 }; } }
671
+ else { const ge = tagEnd(html, m.index); if (ge >= 0) { if (html[ge - 1] !== '/') depth++; re.lastIndex = ge; } }
672
+ }
673
+ return null;
674
+ }
675
+ function locateEditEl(html, key) { // element whose x-edit*-named attr value === key
676
+ const m = new RegExp(`x-edit[.\\w-]*\\s*=\\s*(?:"${escapeReg(key)}"|'${escapeReg(key)}')`).exec(html);
677
+ if (!m) return null;
678
+ const openStart = html.lastIndexOf('<', m.index); if (openStart < 0) return null;
679
+ const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(openStart)); if (!nameM) return null;
680
+ const openEnd = tagEnd(html, openStart); if (openEnd < 0) return null;
681
+ const innerStart = openEnd + 1;
682
+ const cl = matchingClose(html, nameM[1], innerStart); if (!cl) return null;
683
+ return { tag: nameM[1], openStart, openEnd, innerStart, innerEnd: cl.start };
684
+ }
685
+ function setAttr(openTag, name, value) { // replace or insert an attribute in an opening-tag string
686
+ const val = `"${String(value).replace(/"/g, '&quot;')}"`;
687
+ const re = new RegExp(`(\\s${escapeReg(name)}\\s*=\\s*)(?:"[^"]*"|'[^']*')`);
688
+ if (re.test(openTag)) return openTag.replace(re, `$1${val}`);
689
+ return openTag.replace(/\s*\/?>$/, m => ` ${name}=${val}${m}`);
690
+ }
691
+ // Static per-node ops + reorder — surgical source edits (no whole-innerHTML replacement).
692
+ function navInRegion(html, loc, path) {
693
+ let node = { innerStart: loc.innerStart, innerEnd: loc.innerEnd };
694
+ for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
695
+ return node;
696
+ }
697
+ function serverStaticKey(html, child) { // mirror the client's staticKey (tag + first 24 text chars)
698
+ const text = html.slice(child.innerStart, child.innerEnd).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 24);
699
+ return child.tag.toUpperCase() + ':' + text;
700
+ }
701
+ function writeStaticOps(file, key, edits, order) {
702
+ for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
703
+ let html = readFileSync(file, 'utf8');
704
+ const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
705
+ const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
706
+ if (!node) continue;
707
+ const openTag = html.slice(node.tagStart, node.openEnd + 1);
708
+ if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
709
+ else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
710
+ else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
711
+ writeFileSync(file, html);
712
+ }
713
+ let reordered = false;
714
+ if (order && order.length) {
715
+ let html = readFileSync(file, 'utf8'); const loc = locateEditEl(html, key);
716
+ if (loc) {
717
+ const kids = []; let n = 0, c;
718
+ while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
719
+ const byKey = {}; kids.forEach(k => { byKey[serverStaticKey(html, k)] = k; });
720
+ const seq = order.map(k => byKey[k]).filter(Boolean);
721
+ if (seq.length === kids.length && seq.length) {
722
+ const reassembled = seq.map(k => html.slice(k.tagStart, k.closeEnd)).join('\n ');
723
+ html = html.slice(0, kids[0].tagStart) + reassembled + html.slice(kids[kids.length - 1].closeEnd);
724
+ writeFileSync(file, html); reordered = true;
725
+ }
726
+ }
727
+ }
728
+ return { region: key, status: 'written', file: basename(file), edits: (edits || []).length, reordered };
729
+ }
730
+ function writeStaticRegion(file, key, innerHTML, style) {
731
+ let html = readFileSync(file, 'utf8');
732
+ const loc = locateEditEl(html, key);
733
+ if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
734
+ let openTag = html.slice(loc.openStart, loc.openEnd + 1);
735
+ if (style) openTag = setAttr(openTag, 'style', style);
736
+ html = html.slice(0, loc.openStart) + openTag + '\n' + (innerHTML || '') + '\n' + html.slice(loc.innerEnd);
737
+ writeFileSync(file, html);
738
+ return { region: key, status: 'written', file: basename(file) };
739
+ }
740
+ // Structural navigation for component-file edits (dependency-free).
741
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
742
+ const escapeText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
743
+ function childAt(html, from, to, idx) { // nth element child within [from,to), skipping comments/text
744
+ let i = from, count = 0;
745
+ while (i < to) {
746
+ const lt = html.indexOf('<', i); if (lt < 0 || lt >= to) return null;
747
+ if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt); i = e < 0 ? to : e + 3; continue; }
748
+ if (html[lt + 1] === '/') { i = html.indexOf('>', lt) + 1; continue; }
749
+ const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(lt)); if (!nameM) { i = lt + 1; continue; }
750
+ const tag = nameM[1], openEnd = tagEnd(html, lt); if (openEnd < 0) return null;
751
+ const selfClose = html[openEnd - 1] === '/' || VOID_TAGS.has(tag.toLowerCase());
752
+ let innerStart = openEnd + 1, innerEnd = innerStart, closeEnd = openEnd + 1;
753
+ if (!selfClose) { const cl = matchingClose(html, tag, innerStart); if (!cl) return null; innerEnd = cl.start; closeEnd = cl.end; }
754
+ if (count === idx) return { tag, tagStart: lt, openEnd, innerStart, innerEnd, closeEnd };
755
+ count++; i = closeEnd;
756
+ }
757
+ return null;
758
+ }
759
+ function navigateToNode(html, path) { // path indices are relative to the component's first top-level element ('' = the root itself)
760
+ let node = childAt(html, 0, html.length, 0); if (!node) return null;
761
+ if (!path) return node;
762
+ for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
763
+ return node;
764
+ }
765
+ function resolveComponentFile(manifest, name) {
766
+ const all = [...(manifest.preloadedComponents || []), ...(manifest.components || [])];
767
+ return all.find(p => String(p).split('/').pop().replace('.html', '') === name) || null;
768
+ }
769
+
770
+ // Theme var write: rewrite (or append) a single `--var: value;` in the target CSS file.
771
+ // Scoped vars carry their file (data-edit-theme-file); global vars fall back to the
772
+ // standard theme file. Surgical — rewrites the existing declaration in place, else
773
+ // appends into the first :root{} block, else creates one.
774
+ const DEFAULT_THEME_FILE = 'styles/core/manifest.theme.css';
775
+ function writeThemeVar(p) {
776
+ if (!/^--[\w-]+$/.test(p.var || '')) return { region: p.var, status: 'error', reason: 'bad var name' };
777
+ const rel = (p.file || DEFAULT_THEME_FILE).replace(/^\//, '');
778
+ const file = safeResolve('/' + rel);
779
+ if (!file) return { region: p.var, status: 'error', reason: `path outside root: ${rel}` };
780
+ let css = isFile(file) ? readFileSync(file, 'utf8') : '';
781
+ const decl = `${p.var}: ${p.value};`;
782
+ const declRe = new RegExp(`(${escapeReg(p.var)}\\s*:)[^;]*;`);
783
+ if (declRe.test(css)) css = css.replace(declRe, `$1 ${p.value};`); // rewrite in place
784
+ else if (/:root\s*[^{]*\{/.test(css)) css = css.replace(/(:root\s*[^{]*\{)/, `$1\n ${decl}`); // append into :root
785
+ else css = `:root {\n ${decl}\n}\n${css}`; // create :root
786
+ writeFileSync(file, css);
787
+ return { region: `${p.scope ? p.scope + ':' : ''}${p.var}`, status: 'written', file: rel };
788
+ }
789
+ const modParamIn = (openTag, attr) => { const m = openTag.match(new RegExp(escapeReg(attr) + `\\s*=\\s*("[^"]*"|'[^']*')`)); if (!m) return null; const mm = m[1].slice(1, -1).match(/\$modify\(['"]([\w-]+)['"]\)/); return mm ? mm[1] : null; };
790
+ const allModifyParams = (html) => { const s = new Set(); let m; const re = /\$modify\(['"]([\w-]+)['"]\)/g; while ((m = re.exec(html))) s.add(m[1]); return [...s]; };
791
+ const removeAttr = (openTag, name) => openTag.replace(new RegExp(`\\s${escapeReg(name)}\\s*=\\s*(?:"[^"]*"|'[^']*')`, 'g'), '');
792
+ const classOf = (openTag) => { const m = openTag.match(/\sclass\s*=\s*("[^"]*"|'[^']*')/); return m ? m[1].slice(1, -1) : ''; };
793
+
794
+ // Component edits, per node (structural path), per prop (text|class), routed by scope.
795
+ // main → edit the component source literal/class (affects every instance)
796
+ // instance → set the $modify instance attr; AUTO-PROMOTE a plain node first
797
+ // (text → x-text="$modify('p_…') ?? 'orig'"; class → :class="$modify('c_…') ?? 'orig'")
798
+ // reverts → remove the instance's $modify attrs so it falls back to the component default
799
+ function writeComponentEdits(p, manifest) {
800
+ const compRel = resolveComponentFile(manifest, p.component);
801
+ if (!compRel) return { region: p.region, status: 'error', reason: `component '${p.component}' not registered` };
802
+ const compFile = safeResolve(compRel);
803
+ if (!compFile || !isFile(compFile)) return { region: p.region, status: 'error', reason: `component file not found: ${compRel}` };
804
+ const indexFile = join(root, 'index.html'), instanceAttrs = {}, removeNames = new Set(), applied = [];
805
+ for (const ed of (p.edits || [])) {
806
+ const prop = ed.prop || 'text';
807
+ let html = readFileSync(compFile, 'utf8');
808
+ const node = navigateToNode(html, ed.path);
809
+ if (!node) { applied.push({ path: ed.path, status: 'not-found' }); continue; }
810
+ let openTag = html.slice(node.tagStart, node.openEnd + 1);
811
+ const seg = ed.path === '' ? 'root' : ed.path.replace(/\./g, '_');
812
+
813
+ if (prop === 'class') {
814
+ const param = modParamIn(openTag, ':class');
815
+ if (p.scope === 'main') { writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1)); applied.push({ path: ed.path, prop, status: 'main' }); }
816
+ else if (param) { instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param }); }
817
+ else { const name = 'c_' + seg; writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, ':class', `$modify('${name}') ?? '${classOf(openTag).replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1)); instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name }); }
818
+ continue;
819
+ }
820
+
821
+ // text — value is innerHTML; may contain nested elements (<i>/<br>), bound via x-html.
822
+ const hasMarkup = /<[a-z!\/][\s\S]*>/i.test(ed.value);
823
+ const param = modParamIn(openTag, 'x-html') || modParamIn(openTag, 'x-text');
824
+ if (p.scope === 'main') {
825
+ if (param) writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/((?:\?\?|\|\|)\s*)(['"]).*?\2/, `$1'${ed.value.replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1));
826
+ else writeFileSync(compFile, html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd)); // raw HTML literal
827
+ applied.push({ path: ed.path, prop, status: 'main' });
828
+ } else if (param) {
829
+ if (hasMarkup && /\sx-text\s*=/.test(openTag) && !/\sx-html\s*=/.test(openTag)) { writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/\sx-text(\s*=)/, ' x-html$1') + html.slice(node.openEnd + 1)); }
830
+ instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param });
831
+ } else { // PROMOTE: x-html if rich, else x-text
832
+ const name = 'p_' + seg, bind = hasMarkup ? 'x-html' : 'x-text';
833
+ const orig = html.slice(node.innerStart, node.innerEnd).trim().replace(/'/g, "\\'");
834
+ writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, bind, `$modify('${name}') ?? '${orig}'`) + html.slice(node.openEnd + 1));
835
+ instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name, bind });
836
+ }
837
+ }
838
+ if (p.scope === 'instance' && (p.reverts || []).length) {
839
+ const html = readFileSync(compFile, 'utf8');
840
+ for (const rp of p.reverts) {
841
+ if (rp === '*') { allModifyParams(html).forEach(n => removeNames.add(n)); applied.push({ path: '*', status: 'revert-all' }); continue; }
842
+ const node = navigateToNode(html, rp); if (!node) continue;
843
+ const openTag = html.slice(node.tagStart, node.openEnd + 1);
844
+ [modParamIn(openTag, 'x-text'), modParamIn(openTag, 'x-html'), modParamIn(openTag, ':class')].forEach(n => { if (n) removeNames.add(n); });
845
+ applied.push({ path: rp, status: 'reverted' });
846
+ }
847
+ }
848
+ const inst = (Object.keys(instanceAttrs).length || removeNames.size) ? writeComponentInstance(indexFile, p.region, instanceAttrs, [...removeNames]) : null;
849
+ return { region: p.region, status: 'written', scope: p.scope, file: basename(compFile), applied, instance: inst && inst.status };
850
+ }
851
+ function writeComponentInstance(file, key, overrides, removals) {
852
+ let html = readFileSync(file, 'utf8');
853
+ const loc = locateEditEl(html, key);
854
+ if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
855
+ const inner = html.slice(loc.innerStart, loc.innerEnd);
856
+ const cm = /<x-[\w-]+/.exec(inner); // first component instance in the region
857
+ if (!cm) return { region: key, status: 'error', reason: 'no <x-*> instance in region' };
858
+ const instStart = loc.innerStart + cm.index, instEnd = tagEnd(html, instStart);
859
+ if (instEnd < 0) return { region: key, status: 'error', reason: 'malformed instance tag' };
860
+ let openTag = html.slice(instStart, instEnd + 1);
861
+ for (const [k, v] of Object.entries(overrides || {})) openTag = setAttr(openTag, k, v);
862
+ for (const name of (removals || [])) openTag = removeAttr(openTag, name);
863
+ html = html.slice(0, instStart) + openTag + html.slice(instEnd + 1);
864
+ writeFileSync(file, html);
865
+ return { region: key, status: 'written', file: basename(file), applied: Object.keys(overrides || {}), removed: removals || [] };
866
+ }
867
+
471
868
  // Resolve a request path against `root` and refuse anything that escapes.
472
869
  // `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
473
870
  // returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
@@ -542,6 +939,75 @@ function isLocalOrigin(origin, port) {
542
939
  }
543
940
 
544
941
  // --- HTTP server ---
942
+ // --- Appwrite dev proxy (see APPWRITE_PROXY_TARGET above) ---
943
+
944
+ // Rewrite upstream Set-Cookie so the session cookie is storable + sent on
945
+ // http://localhost: drop Domain (defaults to our host), drop Secure (we're http),
946
+ // and downgrade SameSite=None → Lax (None requires Secure, which we just removed).
947
+ function rewriteAppwriteSetCookie(values) {
948
+ return (Array.isArray(values) ? values : [values]).map(v => v
949
+ .replace(/;\s*Domain=[^;]*/ig, '')
950
+ .replace(/;\s*Secure\b/ig, '')
951
+ .replace(/;\s*SameSite=None/ig, '; SameSite=Lax'));
952
+ }
953
+
954
+ // /_appwrite/v1/account?x → /v1/account?x (strip our prefix, keep path + query)
955
+ function appwriteUpstreamPath(reqUrl, urlPath) {
956
+ const search = reqUrl.includes('?') ? reqUrl.slice(reqUrl.indexOf('?')) : '';
957
+ return urlPath.slice(APPWRITE_PROXY_PREFIX.length) + search;
958
+ }
959
+
960
+ function proxyAppwriteHttp(req, res, urlPath) {
961
+ let u;
962
+ try { u = new URL(APPWRITE_PROXY_TARGET); } catch { res.writeHead(502); res.end('bad APPWRITE_PROXY_TARGET'); return; }
963
+ const headers = { ...req.headers, host: u.host };
964
+ delete headers.connection;
965
+ // Force cookie-only auth. Appwrite emits X-Fallback-Cookies (because the
966
+ // forwarded Origin looks cross-origin) and the SDK then replays it on every
967
+ // request — but the Presences endpoint REJECTS any request carrying the
968
+ // fallback, even with a valid session cookie present. Strip it both ways so
969
+ // the real first-party cookie (which the proxy makes same-origin) is used.
970
+ delete headers['x-fallback-cookies'];
971
+ const transport = u.protocol === 'https:' ? httpsRequest : httpRequest;
972
+ const upstream = transport({
973
+ protocol: u.protocol, hostname: u.hostname,
974
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
975
+ method: req.method, path: appwriteUpstreamPath(req.url, urlPath), headers,
976
+ }, (up) => {
977
+ const outHeaders = { ...up.headers };
978
+ delete outHeaders.connection; delete outHeaders['transfer-encoding'];
979
+ delete outHeaders['x-fallback-cookies']; // keep the SDK out of localStorage-fallback mode
980
+ if (up.headers['set-cookie']) outHeaders['set-cookie'] = rewriteAppwriteSetCookie(up.headers['set-cookie']);
981
+ res.writeHead(up.statusCode || 502, outHeaders);
982
+ up.pipe(res);
983
+ });
984
+ upstream.on('error', (e) => { try { res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('appwrite proxy error: ' + e.message); } catch { /* client gone */ } });
985
+ req.pipe(upstream);
986
+ }
987
+
988
+ // Raw WebSocket tunnel for Appwrite Realtime (wss). Replays the HTTP upgrade over
989
+ // a TLS socket to the upstream and pipes both ways; the browser's first-party
990
+ // localhost cookie rides the upgrade headers, so realtime auth works too.
991
+ function proxyAppwriteWs(req, socket, head, urlPath) {
992
+ let u;
993
+ try { u = new URL(APPWRITE_PROXY_TARGET); } catch { socket.destroy(); return; }
994
+ const upstream = tlsConnect({ host: u.hostname, port: u.port || 443, servername: u.hostname }, () => {
995
+ const headers = { ...req.headers, host: u.host };
996
+ delete headers['x-fallback-cookies']; // cookie-only auth (see HTTP proxy)
997
+ let handshake = `${req.method} ${appwriteUpstreamPath(req.url, urlPath)} HTTP/1.1\r\n`;
998
+ for (const [k, v] of Object.entries(headers)) {
999
+ (Array.isArray(v) ? v : [v]).forEach(val => { handshake += `${k}: ${val}\r\n`; });
1000
+ }
1001
+ handshake += '\r\n';
1002
+ upstream.write(handshake);
1003
+ if (head && head.length) upstream.write(head);
1004
+ upstream.pipe(socket);
1005
+ socket.pipe(upstream);
1006
+ });
1007
+ upstream.on('error', () => socket.destroy());
1008
+ socket.on('error', () => upstream.destroy());
1009
+ }
1010
+
545
1011
  const server = createServer((req, res) => {
546
1012
  const urlPath = decodeURIComponent(req.url.split('?')[0]);
547
1013
 
@@ -555,6 +1021,13 @@ const server = createServer((req, res) => {
555
1021
  return;
556
1022
  }
557
1023
 
1024
+ // Appwrite dev proxy — forward to the configured Appwrite origin so the
1025
+ // session cookie is first-party (see APPWRITE_PROXY_TARGET).
1026
+ if (APPWRITE_PROXY_TARGET && urlPath.startsWith(APPWRITE_PROXY_PREFIX + '/')) {
1027
+ proxyAppwriteHttp(req, res, urlPath);
1028
+ return;
1029
+ }
1030
+
558
1031
  // Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
559
1032
  // on a registered port really is OUR server for the expected root, not
560
1033
  // some unrelated process that happened to inherit a recycled PID/port.
@@ -628,6 +1101,60 @@ const server = createServer((req, res) => {
628
1101
  return;
629
1102
  }
630
1103
 
1104
+ // Edit-plugin B-side write-back (SPIKE, dev-only). POST + same-origin. Currently
1105
+ // handles the `data` regime: reorder the source CSV/JSON for a registered data
1106
+ // source. static/component regimes are reported unsupported (need an HTML parser).
1107
+ // Turnkey AI relay — same-origin chat proxy; key held server-side, never in
1108
+ // the browser. Inert (404) unless manifest.json has an `ai` block. No key →
1109
+ // mock stream so keyless dev works; add ANTHROPIC_API_KEY to .env for real.
1110
+ if (urlPath === '/_ai/chat') {
1111
+ if (!aiConfig) { res.writeHead(404); res.end(); return; }
1112
+ if (req.method !== 'POST') { res.writeHead(405, { 'Allow': 'POST' }); res.end(); return; }
1113
+ if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
1114
+ let raw = '';
1115
+ req.on('data', c => { raw += c; if (raw.length > 25e6) req.destroy(); }); // 25MB cap (attachments)
1116
+ req.on('end', async () => {
1117
+ let payload = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { res.writeHead(400); res.end('bad json'); return; }
1118
+ try { if (aiKey) await streamRealAi(res, payload); else streamMockAi(res); }
1119
+ catch (e) { try { res.writeHead(500); res.end(String(e && e.message || e)); } catch (_) {} }
1120
+ });
1121
+ return;
1122
+ }
1123
+
1124
+ if (urlPath === '/__edit/save') {
1125
+ if (!EDIT_ENABLED) { res.writeHead(404); res.end(); return; } // opt-in (--edit / MNFST_EDIT=1): source write-back is authoring-only
1126
+ if (req.method !== 'POST') { res.writeHead(405, { 'Allow': 'POST' }); res.end(); return; }
1127
+ if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
1128
+ let raw = '';
1129
+ req.on('data', c => { raw += c; if (raw.length > 1e6) req.destroy(); });
1130
+ req.on('end', () => {
1131
+ let patches; try { patches = JSON.parse(raw); } catch { res.writeHead(400); res.end('bad json'); return; }
1132
+ if (!Array.isArray(patches)) patches = [patches];
1133
+ let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
1134
+ const indexFile = join(root, 'index.html');
1135
+ const results = patches.map(p => {
1136
+ try {
1137
+ if (p.kind === 'data') {
1138
+ const src = (manifest.data || {})[p.source];
1139
+ const rel = typeof src === 'string' ? src : null;
1140
+ if (!rel) return { region: p.region, status: 'error', reason: `data source '${p.source}' is not a plain file path` };
1141
+ const file = safeResolve(rel);
1142
+ if (!file || !isFile(file)) return { region: p.region, status: 'error', reason: `file not found: ${rel}` };
1143
+ return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
1144
+ }
1145
+ if (p.kind === 'data-val') return writeDataValue(p, manifest);
1146
+ if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order);
1147
+ if (p.kind === 'component') return writeComponentEdits(p, manifest);
1148
+ if (p.kind === 'theme') return writeThemeVar(p);
1149
+ return { region: p.region, status: 'skipped', reason: `unknown kind ${p.kind}` };
1150
+ } catch (e) { return { region: p.region, status: 'error', reason: e.message }; }
1151
+ });
1152
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1153
+ res.end(JSON.stringify({ results }));
1154
+ });
1155
+ return;
1156
+ }
1157
+
631
1158
  const exact = safeResolve(urlPath);
632
1159
  if (exact && isFile(exact)) return serveFile(res, exact);
633
1160
 
@@ -644,8 +1171,7 @@ const server = createServer((req, res) => {
644
1171
  });
645
1172
 
646
1173
  // --- Auto-port ---
647
- // Human-readable label: the dir arg as given, or the cwd folder name if serving '.'
648
- const label = dir === '.' ? basename(process.cwd()) : dir.replace(/\\/g, '/');
1174
+ // (`label` is defined earlier, before the reuse/dedup check.)
649
1175
 
650
1176
  function openBrowser(url) {
651
1177
  const cmd = process.platform === 'win32' ? `start ${url}`
@@ -654,6 +1180,82 @@ function openBrowser(url) {
654
1180
  exec(cmd);
655
1181
  }
656
1182
 
1183
+ // Whether THIS process owns the running-server registry entry for `root`.
1184
+ // Stays false in --attach mode (we never claim the entry), so the exit handler
1185
+ // can't delete an entry belonging to the user's own server for the same root.
1186
+ let weOwnServer = false;
1187
+
1188
+ // --attach: the requested port is already serving this root. Stay in the
1189
+ // foreground so the supervising preview panel keeps tracking this process,
1190
+ // without owning the server — never touch its registry; just re-probe and exit
1191
+ // once it goes away.
1192
+ function attachToExisting(p) {
1193
+ const url = `http://localhost:${p}`;
1194
+ console.log(`\n${label} already running at ${url} — attached.\n`);
1195
+ if (openBrowserEnabled) openBrowser(url);
1196
+ watchUpstream(p);
1197
+ }
1198
+
1199
+ // Exit once the server we're bridging/attached to goes away — we're only a
1200
+ // pass-through, so there's nothing to serve without it.
1201
+ function watchUpstream(upstreamPort) {
1202
+ setInterval(async () => {
1203
+ const id = await probeIdentity(upstreamPort);
1204
+ if (!id || id.root !== root) {
1205
+ console.log('\nmnfst-run: the server it was bridging has stopped — exiting.\n');
1206
+ process.exit(0);
1207
+ }
1208
+ }, 5000);
1209
+ }
1210
+
1211
+ // --attach: a real dev server for this root is already running on `upstreamPort`,
1212
+ // but the preview panel can only use a server on the port it assigned us
1213
+ // (`listenPort`). Bind that port and transparently reverse-proxy every request
1214
+ // to the real server — including the live-reload SSE stream — so the existing
1215
+ // server stays the ONE dev server and the panel still works. We don't own a
1216
+ // server, so we never touch the registry.
1217
+ function startProxy(listenPort, upstreamPort) {
1218
+ const proxy = createServer((creq, cres) => {
1219
+ // Rewrite host/origin to the upstream so its loopback host + same-origin
1220
+ // checks pass (the client speaks to us on listenPort, the server on upstream).
1221
+ const headers = { ...creq.headers, host: `localhost:${upstreamPort}` };
1222
+ if (headers.origin) headers.origin = `http://localhost:${upstreamPort}`;
1223
+ if (headers.referer) {
1224
+ headers.referer = headers.referer.split(`localhost:${listenPort}`).join(`localhost:${upstreamPort}`);
1225
+ }
1226
+ const preq = httpRequest(
1227
+ { host: '127.0.0.1', port: upstreamPort, method: creq.method, path: creq.url, headers },
1228
+ (pres) => {
1229
+ cres.writeHead(pres.statusCode || 502, pres.headers);
1230
+ pres.pipe(cres); // stream — keeps SSE (text/event-stream) flowing live
1231
+ },
1232
+ );
1233
+ preq.on('error', () => { try { cres.writeHead(502); cres.end('mnfst-run proxy: upstream unavailable'); } catch { /* client gone */ } });
1234
+ creq.pipe(preq);
1235
+ });
1236
+ proxy.on('error', (err) => {
1237
+ console.error(`mnfst-run: could not bind proxy port ${listenPort}: ${err.code || err.message}`);
1238
+ process.exit(1);
1239
+ });
1240
+ proxy.listen(listenPort, '127.0.0.1', () => {
1241
+ console.log(
1242
+ `\n${label} already running at http://localhost:${upstreamPort} — ` +
1243
+ `bridged to http://localhost:${listenPort} for the preview panel.\n`,
1244
+ );
1245
+ });
1246
+ watchUpstream(upstreamPort);
1247
+ }
1248
+
1249
+ // Appwrite Realtime (WebSocket) proxy — same first-party-cookie rationale as the
1250
+ // HTTP proxy. Without it, switching the endpoint to the proxy would break realtime.
1251
+ if (APPWRITE_PROXY_TARGET) {
1252
+ server.on('upgrade', (req, socket, head) => {
1253
+ const p = (req.url || '').split('?')[0];
1254
+ if (p.startsWith(APPWRITE_PROXY_PREFIX + '/')) proxyAppwriteWs(req, socket, head, p);
1255
+ else socket.destroy();
1256
+ });
1257
+ }
1258
+
657
1259
  function tryListen(p, attempt = 0) {
658
1260
  if (attempt > 20) {
659
1261
  console.error('mnfst-run: could not find a free port after 20 attempts.');
@@ -667,14 +1269,28 @@ function tryListen(p, attempt = 0) {
667
1269
  const onListening = () => {
668
1270
  server.removeListener('error', onError);
669
1271
  const url = `http://localhost:${p}`;
1272
+ // A successful fresh bind means WE are the server for this root — register it
1273
+ // (so a later manual `mnfst-run` for the same project reuses it instead of
1274
+ // starting another). In --attach we only reach here when nothing was already
1275
+ // running, so there's no entry to clobber.
670
1276
  writeRegistry(root, p);
1277
+ weOwnServer = true;
671
1278
  console.log(`\n${label} running at ${url}\n`);
672
1279
  if (openBrowserEnabled) openBrowser(url);
673
1280
  };
674
1281
  const onError = err => {
675
1282
  server.removeListener('listening', onListening);
676
- if (err.code === 'EADDRINUSE') tryListen(p + 1, attempt + 1);
677
- else throw err;
1283
+ if (err.code !== 'EADDRINUSE') { throw err; }
1284
+ // Under --attach, if the requested port is already OUR project, attach to it
1285
+ // (stay alive) rather than spawn a duplicate on the next port up.
1286
+ if (attachMode && attempt === 0) {
1287
+ probeIdentity(p).then((id) => {
1288
+ if (id && id.root === root) attachToExisting(p);
1289
+ else tryListen(p + 1, attempt + 1);
1290
+ });
1291
+ return;
1292
+ }
1293
+ tryListen(p + 1, attempt + 1);
678
1294
  };
679
1295
  server.once('listening', onListening);
680
1296
  server.once('error', onError);
@@ -684,10 +1300,11 @@ function tryListen(p, attempt = 0) {
684
1300
  server.listen(p, '127.0.0.1');
685
1301
  }
686
1302
 
687
- // Clean up the registry entry on graceful exit. process.exit() (used by
688
- // idle-shutdown) fires 'exit'; SIGINT/SIGTERM are translated into a
689
- // process.exit so the same path runs for Ctrl+C and `kill <pid>`.
690
- process.on('exit', () => removeRegistry(root));
1303
+ // Clean up the registry entry on graceful exit but only if we actually own it
1304
+ // (never in --attach mode, where the entry may belong to the user's server).
1305
+ // process.exit() (used by idle-shutdown) fires 'exit'; SIGINT/SIGTERM are
1306
+ // translated into a process.exit so the same path runs for Ctrl+C and `kill`.
1307
+ process.on('exit', () => { if (weOwnServer) removeRegistry(root); });
691
1308
  process.on('SIGINT', () => process.exit(0));
692
1309
  process.on('SIGTERM', () => process.exit(0));
693
1310