@maintainer-pro/ai-bridge 0.1.5 → 0.1.7

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 +2 -2
  2. package/src/daemon.mjs +2688 -404
package/src/daemon.mjs CHANGED
@@ -8,27 +8,70 @@
8
8
  * npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
- import { randomBytes } from "node:crypto";
11
+ import { createHash, randomBytes } from "node:crypto";
12
12
  import fs from "node:fs";
13
13
  import http from "node:http";
14
+ import https from "node:https";
14
15
  import net from "node:net";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
18
  import readline from "node:readline";
18
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { createLogger } from "@maintainer-pro/ai-cli";
19
21
 
20
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
23
  const PACKAGE_VERSION = readPackageVersion();
22
24
  const HEARTBEAT_MS = 15_000;
23
- const ACTION_POLL_MS = 2_000;
24
-
25
- const log = (msg) => console.log(`[bridge] ${msg}`);
26
- const warn = (msg) => console.warn(`[bridge] ${msg}`);
25
+ const WS_PING_MS = 10_000;
26
+ const WS_RECONNECT_MIN_MS = 500;
27
+ const WS_RECONNECT_MAX_MS = 8_000;
28
+
29
+ /** @type {import("@maintainer-pro/ai-cli").Logger} */
30
+ let logger = createLogger("ai-bridge");
31
+ const log = (msg) => logger.info(msg);
32
+ const warn = (msg) => logger.warn(msg);
27
33
  const fail = (msg) => {
28
- console.error(`[bridge] ${msg}`);
34
+ logger.fatal(msg);
29
35
  process.exit(1);
30
36
  };
31
37
 
38
+ function shortId(value) {
39
+ const text = String(value || "");
40
+ return text.length > 12 ? `${text.slice(0, 8)}…` : text;
41
+ }
42
+
43
+ function actionLabel(action) {
44
+ const sandbox = action.sandboxId || action.payload?.sandboxId;
45
+ const folder = action.payload?.folderPath;
46
+ const bits = [`action ${action.code}`];
47
+ if (action.id) bits.push(`id=${shortId(action.id)}`);
48
+ if (sandbox) bits.push(`sandbox=${shortId(sandbox)}`);
49
+ if (folder) bits.push(`folder=${folder}`);
50
+ return bits.join(" ");
51
+ }
52
+
53
+ function resultSummary(result) {
54
+ if (!result || typeof result !== "object") return "";
55
+ /** @type {string[]} */
56
+ const bits = [];
57
+ if (result.error) bits.push(`error=${result.error}`);
58
+ if (result.folderPath) bits.push(`folder=${result.folderPath}`);
59
+ if (result.port) bits.push(`chat=${result.port}`);
60
+ if (result.appUrl) bits.push(`app=${result.appUrl}`);
61
+ if (Array.isArray(result.origins) && result.origins.length) {
62
+ bits.push(`origins=${result.origins.join(",")}`);
63
+ }
64
+ if (typeof result.up === "boolean") bits.push(`chatUp=${result.up}`);
65
+ if (typeof result.waitingForStart === "boolean") {
66
+ bits.push(`waitingForStart=${result.waitingForStart}`);
67
+ }
68
+ if (Array.isArray(result.startedHosts) && result.startedHosts.length) {
69
+ bits.push(`started=${result.startedHosts.join(",")}`);
70
+ }
71
+ if (result.warning) bits.push(`warning=${result.warning}`);
72
+ return bits.join(" ");
73
+ }
74
+
32
75
  function readPackageVersion() {
33
76
  try {
34
77
  const pkg = JSON.parse(
@@ -89,6 +132,10 @@ Flags:
89
132
  --offer-folder <path> Suggest this folder in admin (repeatable via config)
90
133
  --no-ai-server Do not open ai-server terminals for workspaces
91
134
  --help
135
+
136
+ Logging (env):
137
+ LOG_LEVEL=info|debug|warn|error|silent (default: info)
138
+ LOG_PRETTY=0 disable pretty TTY output
92
139
  `);
93
140
  }
94
141
 
@@ -136,18 +183,43 @@ async function ask(question) {
136
183
  });
137
184
  }
138
185
 
186
+ function fetchErrorDetail(err) {
187
+ const cause = err && typeof err === "object" ? err.cause : null;
188
+ const code =
189
+ (cause && typeof cause === "object" && "code" in cause && cause.code) ||
190
+ (err && typeof err === "object" && "code" in err && err.code) ||
191
+ "";
192
+ const port =
193
+ (cause && typeof cause === "object" && "port" in cause && cause.port) ||
194
+ "";
195
+ return [code, port ? `port ${port}` : ""].filter(Boolean).join(" ");
196
+ }
197
+
139
198
  async function api(baseUrl, token, method, pathname, body) {
140
- const url = `${baseUrl.replace(/\/$/, "")}${pathname}`;
199
+ const url = `${String(baseUrl || "").replace(/\/$/, "")}${pathname}`;
200
+ if (!baseUrl) {
201
+ throw new Error("Admin URL is missing. Pair this computer again.");
202
+ }
141
203
  const headers = {
142
204
  Accept: "application/json",
143
205
  "Content-Type": "application/json",
144
206
  };
145
207
  if (token) headers.Authorization = `Bearer ${token}`;
146
- const res = await fetch(url, {
147
- method,
148
- headers,
149
- body: body ? JSON.stringify(body) : undefined,
150
- });
208
+ let res;
209
+ try {
210
+ res = await fetch(url, {
211
+ method,
212
+ headers,
213
+ body: body ? JSON.stringify(body) : undefined,
214
+ });
215
+ } catch (err) {
216
+ const detail = fetchErrorDetail(err);
217
+ throw new Error(
218
+ `Could not reach Maintainer Pro at ${url}${
219
+ detail ? ` (${detail})` : ""
220
+ }. Is the admin server running?`
221
+ );
222
+ }
151
223
  const text = await res.text();
152
224
  let data = null;
153
225
  try {
@@ -163,6 +235,28 @@ async function api(baseUrl, token, method, pathname, body) {
163
235
  return data;
164
236
  }
165
237
 
238
+ async function reportActionProgress(cfg, actionId, message) {
239
+ const text = String(message || "").trim();
240
+ if (!text) return;
241
+ log(text);
242
+ if (!cfg?.adminUrl || !cfg.token || !actionId) return;
243
+ try {
244
+ await api(
245
+ cfg.adminUrl,
246
+ cfg.token,
247
+ "POST",
248
+ `/api/v1/bridge/machine/actions/${actionId}/progress`,
249
+ { message: text }
250
+ );
251
+ } catch (err) {
252
+ warn(
253
+ `progress report failed: ${
254
+ err instanceof Error ? err.message : String(err)
255
+ }`
256
+ );
257
+ }
258
+ }
259
+
166
260
  /** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
167
261
  let aiCliModule = null;
168
262
 
@@ -183,18 +277,31 @@ async function loadAiCli() {
183
277
  return aiCliModule;
184
278
  }
185
279
 
280
+ /** @type {{ at: number, ids: string[] } | null} */
281
+ let cliProviderCache = null;
282
+
186
283
  async function detectCliProviders() {
284
+ if (cliProviderCache && Date.now() - cliProviderCache.at < 60_000) {
285
+ return cliProviderCache.ids;
286
+ }
187
287
  try {
188
288
  const { resolveProvider } = await loadAiCli();
189
289
  const provider = await resolveProvider({ preference: "auto" });
190
- return [provider.id];
290
+ cliProviderCache = { at: Date.now(), ids: [provider.id] };
291
+ return cliProviderCache.ids;
191
292
  } catch {
192
- return [];
293
+ cliProviderCache = { at: Date.now(), ids: [] };
294
+ return cliProviderCache.ids;
193
295
  }
194
296
  }
195
297
 
196
298
  function applyProjectInfo(ws, info, cfg) {
197
299
  if (!ws || !info) return;
300
+ const folder = path.resolve(ws.folderPath || "");
301
+ const fingerprint =
302
+ typeof info.fingerprint === "string" && info.fingerprint
303
+ ? info.fingerprint
304
+ : projectFingerprint(folder);
198
305
  ws.projectInfo = {
199
306
  kind: info.kind,
200
307
  name: info.name,
@@ -205,6 +312,8 @@ function applyProjectInfo(ws, info, cfg) {
205
312
  fixes: info.fixes || [],
206
313
  ready: Boolean(info.ready),
207
314
  provider: info.provider,
315
+ fingerprint,
316
+ inspectedAt: info.inspectedAt || new Date().toISOString(),
208
317
  };
209
318
  if (info.kind) ws.clientKind = info.kind;
210
319
  const uiPort = Number(info.ports?.ui || info.ports?.app);
@@ -217,9 +326,144 @@ function applyProjectInfo(ws, info, cfg) {
217
326
  persistWorkspaceEntry(cfg, ws);
218
327
  }
219
328
 
329
+ const PROJECT_INSPECT_CACHE = "project-inspect.json";
330
+
331
+ function projectInspectCachePath(folder) {
332
+ return path.join(folder, ".maintainer-pro", PROJECT_INSPECT_CACHE);
333
+ }
334
+
335
+ /** Invalidate cache when package.json / common config files change. */
336
+ function projectFingerprint(folder) {
337
+ const resolved = path.resolve(folder || "");
338
+ /** @type {string[]} */
339
+ const parts = [];
340
+ for (const rel of [
341
+ "package.json",
342
+ "package-lock.json",
343
+ "pnpm-lock.yaml",
344
+ "yarn.lock",
345
+ "vite.config.ts",
346
+ "vite.config.js",
347
+ "vite.config.mjs",
348
+ "next.config.js",
349
+ "next.config.mjs",
350
+ "next.config.ts",
351
+ ]) {
352
+ const file = path.join(resolved, rel);
353
+ if (!fs.existsSync(file)) continue;
354
+ try {
355
+ const st = fs.statSync(file);
356
+ parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
357
+ } catch {
358
+ /* ignore */
359
+ }
360
+ }
361
+ try {
362
+ const pkg = JSON.parse(
363
+ fs.readFileSync(path.join(resolved, "package.json"), "utf8")
364
+ );
365
+ parts.push(`name:${pkg.name || ""}`);
366
+ parts.push(`scripts:${JSON.stringify(pkg.scripts || {})}`);
367
+ } catch {
368
+ /* ignore */
369
+ }
370
+ return createHash("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
371
+ }
372
+
373
+ function usableCachedProjectInfo(info) {
374
+ if (!info || typeof info !== "object") return false;
375
+ const scripts = info.scripts && typeof info.scripts === "object" ? info.scripts : {};
376
+ const hasScript = Boolean(scripts.ui || scripts.app || scripts.backend);
377
+ const hasKind = typeof info.kind === "string" && info.kind.length > 0;
378
+ return hasKind || hasScript || Boolean(info.summary);
379
+ }
380
+
381
+ function readProjectInspectCache(folder) {
382
+ const resolved = path.resolve(folder || "");
383
+ try {
384
+ const file = projectInspectCachePath(resolved);
385
+ if (!fs.existsSync(file)) return null;
386
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
387
+ if (!data || typeof data !== "object") return null;
388
+ if (!usableCachedProjectInfo(data.projectInfo)) return null;
389
+ const fingerprint = projectFingerprint(resolved);
390
+ if (data.fingerprint && data.fingerprint !== fingerprint) return null;
391
+ return {
392
+ ...data.projectInfo,
393
+ fingerprint,
394
+ inspectedAt: data.inspectedAt || data.projectInfo?.inspectedAt,
395
+ cached: true,
396
+ };
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ function writeProjectInspectCache(folder, info) {
403
+ const resolved = path.resolve(folder || "");
404
+ if (!info || !usableCachedProjectInfo(info)) return;
405
+ try {
406
+ const dir = path.join(resolved, ".maintainer-pro");
407
+ fs.mkdirSync(dir, { recursive: true });
408
+ const fingerprint = projectFingerprint(resolved);
409
+ const payload = {
410
+ version: 1,
411
+ fingerprint,
412
+ inspectedAt: new Date().toISOString(),
413
+ projectInfo: {
414
+ kind: info.kind,
415
+ name: info.name,
416
+ summary: info.summary,
417
+ scripts: info.scripts || {},
418
+ ports: info.ports || {},
419
+ issues: info.issues || [],
420
+ fixes: info.fixes || [],
421
+ ready: Boolean(info.ready),
422
+ provider: info.provider,
423
+ },
424
+ };
425
+ fs.writeFileSync(
426
+ projectInspectCachePath(resolved),
427
+ `${JSON.stringify(payload, null, 2)}\n`,
428
+ "utf8"
429
+ );
430
+ } catch (err) {
431
+ warn(
432
+ `could not write project inspect cache: ${
433
+ err instanceof Error ? err.message : String(err)
434
+ }`
435
+ );
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Resolve project inspect info: prefer on-disk / config cache, else ask ai-cli.
441
+ * Pass `force: true` to always re-run analysis (recheck / failed start retry).
442
+ */
220
443
  async function inspectProjectWithAiCli(ws, opts = {}) {
221
444
  const folder = path.resolve(ws.folderPath || "");
222
445
  const label = ws.sandboxName || ws.applicationName || "this sandbox";
446
+ const force = Boolean(opts.force);
447
+
448
+ if (!force) {
449
+ const fingerprint = projectFingerprint(folder);
450
+ const fromDisk = readProjectInspectCache(folder);
451
+ const fromWs =
452
+ usableCachedProjectInfo(ws.projectInfo) &&
453
+ (!ws.projectInfo.fingerprint ||
454
+ ws.projectInfo.fingerprint === fingerprint)
455
+ ? ws.projectInfo
456
+ : null;
457
+ const cached = fromDisk || fromWs;
458
+ if (cached) {
459
+ log(`using cached project inspect for ${folder}`);
460
+ applyProjectInfo(ws, { ...cached, fingerprint }, opts.cfg);
461
+ if (!fromDisk) writeProjectInspectCache(folder, cached);
462
+ clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
463
+ return { ...cached, fingerprint, cached: true };
464
+ }
465
+ }
466
+
223
467
  try {
224
468
  const { inspectAndRepairWorkspace } = await loadAiCli();
225
469
  log(`asking ai-cli to inspect ${folder}`);
@@ -229,7 +473,9 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
229
473
  problem: opts.problem,
230
474
  extraContext: opts.extraContext,
231
475
  });
232
- applyProjectInfo(ws, info, opts.cfg);
476
+ const fingerprint = projectFingerprint(folder);
477
+ applyProjectInfo(ws, { ...info, fingerprint }, opts.cfg);
478
+ writeProjectInspectCache(folder, info);
233
479
  if (info.issues?.length) {
234
480
  recordProcessProblem({
235
481
  sandboxId: ws.sandboxId,
@@ -239,17 +485,26 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
239
485
  message: info.issues.join(" "),
240
486
  resolution: info.fixes?.length
241
487
  ? info.fixes.join(" ")
242
- : "Fix the project in that folder, then use Start chat server.",
488
+ : "Fix the project in that folder, then use Start Apps.",
243
489
  });
244
490
  } else {
245
491
  clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
246
492
  }
247
493
  if (info.summary) log(`ai-cli: ${info.summary}`);
248
494
  if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
249
- return info;
495
+ return { ...info, fingerprint, cached: false };
250
496
  } catch (err) {
251
497
  const message = err instanceof Error ? err.message : String(err);
252
498
  warn(`ai-cli inspect failed: ${message}`);
499
+ // Fall back to any stale cache so Start Apps can still try.
500
+ const fallback =
501
+ readProjectInspectCache(folder) ||
502
+ (usableCachedProjectInfo(ws.projectInfo) ? ws.projectInfo : null);
503
+ if (fallback) {
504
+ warn(`falling back to cached project inspect after ai-cli error`);
505
+ applyProjectInfo(ws, fallback, opts.cfg);
506
+ return { ...fallback, cached: true };
507
+ }
253
508
  recordProcessProblem({
254
509
  sandboxId: ws.sandboxId,
255
510
  code: "project_issue",
@@ -287,152 +542,1326 @@ async function findFreePort(preferred = 3100, reserved = new Set()) {
287
542
  reserved.add(port);
288
543
  return port;
289
544
  }
290
- port += 1;
545
+ port += 1;
546
+ }
547
+ throw new Error("No free TCP port found");
548
+ }
549
+
550
+ function urlWithPort(url, port) {
551
+ try {
552
+ const parsed = new URL(url);
553
+ parsed.port = String(port);
554
+ return parsed.toString().replace(/\/$/, "");
555
+ } catch {
556
+ return `http://localhost:${port}`;
557
+ }
558
+ }
559
+
560
+ function isLocalAppUrl(url) {
561
+ try {
562
+ const host = new URL(url).hostname;
563
+ return host === "localhost" || host === "127.0.0.1";
564
+ } catch {
565
+ return true;
566
+ }
567
+ }
568
+
569
+ function persistWorkspaceEntry(cfg, ws) {
570
+ if (!cfg || !ws?.sandboxId) return;
571
+ cfg.workspaces = cfg.workspaces || [];
572
+ const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
573
+ if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
574
+ else cfg.workspaces.push(ws);
575
+ saveConfig(cfg);
576
+ }
577
+
578
+ function probeUrl(url, timeoutMs = 2500) {
579
+ return new Promise((resolve) => {
580
+ let settled = false;
581
+ const done = (ok) => {
582
+ if (settled) return;
583
+ settled = true;
584
+ resolve(ok);
585
+ };
586
+ try {
587
+ const parsed = new URL(String(url));
588
+ const lib = parsed.protocol === "https:" ? https : http;
589
+ const req = lib.get(
590
+ parsed,
591
+ {
592
+ timeout: timeoutMs,
593
+ rejectUnauthorized: true,
594
+ headers: { Accept: "*/*" },
595
+ },
596
+ (res) => {
597
+ res.resume();
598
+ done(Boolean(res.statusCode && res.statusCode < 500));
599
+ }
600
+ );
601
+ req.on("error", () => done(false));
602
+ req.on("timeout", () => {
603
+ req.destroy();
604
+ done(false);
605
+ });
606
+ } catch {
607
+ done(false);
608
+ }
609
+ });
610
+ }
611
+
612
+ function fetchText(url, timeoutMs = 8000) {
613
+ return new Promise((resolve) => {
614
+ try {
615
+ const parsed = new URL(String(url));
616
+ const lib = parsed.protocol === "https:" ? https : http;
617
+ const req = lib.get(
618
+ parsed,
619
+ {
620
+ timeout: timeoutMs,
621
+ rejectUnauthorized: true,
622
+ headers: { Accept: "*/*" },
623
+ },
624
+ (res) => {
625
+ /** @type {Buffer[]} */
626
+ const chunks = [];
627
+ res.on("data", (chunk) => chunks.push(chunk));
628
+ res.on("end", () => {
629
+ if (!res.statusCode || res.statusCode >= 500) {
630
+ resolve(null);
631
+ return;
632
+ }
633
+ resolve(Buffer.concat(chunks).toString("utf8"));
634
+ });
635
+ }
636
+ );
637
+ req.on("error", () => resolve(null));
638
+ req.on("timeout", () => {
639
+ req.destroy();
640
+ resolve(null);
641
+ });
642
+ } catch {
643
+ resolve(null);
644
+ }
645
+ });
646
+ }
647
+
648
+ function listDriveRoots() {
649
+ if (process.platform !== "win32") return ["/"];
650
+ const roots = [];
651
+ for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
652
+ const root = `${letter}:\\`;
653
+ try {
654
+ if (fs.existsSync(root)) roots.push(root);
655
+ } catch {
656
+ /* skip */
657
+ }
658
+ }
659
+ return roots.length ? roots : ["C:\\"];
660
+ }
661
+
662
+ function listDirEntries(dirPath) {
663
+ const raw = String(dirPath || "").trim();
664
+ const home = os.homedir();
665
+ if (!raw || raw === "roots") {
666
+ const roots = listDriveRoots();
667
+ return {
668
+ path: "",
669
+ parent: null,
670
+ home,
671
+ entries: roots.map((root) => ({
672
+ name: root,
673
+ path: root,
674
+ isDir: true,
675
+ })),
676
+ };
677
+ }
678
+
679
+ const resolved = path.resolve(raw);
680
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
681
+ return {
682
+ error: "Not a directory",
683
+ path: resolved,
684
+ parent: path.dirname(resolved),
685
+ home,
686
+ entries: [],
687
+ };
688
+ }
689
+ const names = fs.readdirSync(resolved);
690
+ const entries = [];
691
+ for (const name of names) {
692
+ if (name === "node_modules" || name === ".git") continue;
693
+ const full = path.join(resolved, name);
694
+ try {
695
+ const st = fs.statSync(full);
696
+ entries.push({
697
+ name,
698
+ path: full,
699
+ isDir: st.isDirectory(),
700
+ });
701
+ } catch {
702
+ /* skip */
703
+ }
704
+ }
705
+ entries.sort((a, b) => {
706
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
707
+ return a.name.localeCompare(b.name);
708
+ });
709
+ const parent = path.dirname(resolved);
710
+ return {
711
+ path: resolved,
712
+ parent: parent === resolved ? null : parent,
713
+ home,
714
+ entries: entries.slice(0, 400),
715
+ };
716
+ }
717
+
718
+ function readEnvFile(file) {
719
+ /** @type {Record<string, string>} */
720
+ const map = {};
721
+ if (!fs.existsSync(file)) return map;
722
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
723
+ const line = raw.trim();
724
+ if (!line || line.startsWith("#")) continue;
725
+ const eq = line.indexOf("=");
726
+ if (eq < 1) continue;
727
+ map[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
728
+ }
729
+ return map;
730
+ }
731
+
732
+ function mergeEnvFile(file, values, opts = {}) {
733
+ const map = readEnvFile(file);
734
+ for (const key of opts.remove || []) {
735
+ delete map[key];
736
+ }
737
+ for (const [k, v] of Object.entries(values)) {
738
+ map[k] = String(v);
739
+ }
740
+ const body = Object.entries(map)
741
+ .map(([k, v]) => `${k}=${v}`)
742
+ .join("\n");
743
+ fs.writeFileSync(file, body + "\n", "utf8");
744
+ }
745
+
746
+ function writeProjectEnv(folder, values, opts = {}) {
747
+ const remove = opts.remove || [];
748
+ const next = values && typeof values === "object" ? values : {};
749
+ if (!folder || (!Object.keys(next).length && !remove.length)) return;
750
+ mergeEnvFile(path.join(folder, ".env"), next, { remove });
751
+ const localValues = {};
752
+ for (const [key, value] of Object.entries(next)) {
753
+ if (
754
+ key === "PORT" ||
755
+ key === "APP_URL" ||
756
+ key === "PUBLIC_URL" ||
757
+ key === "CORS_ORIGIN" ||
758
+ key === "AI_SERVER_URL" ||
759
+ key.startsWith("NEXT_PUBLIC_") ||
760
+ key.startsWith("VITE_") ||
761
+ key.startsWith("REACT_APP_")
762
+ ) {
763
+ localValues[key] = value;
764
+ }
765
+ }
766
+ const localRemove = remove.filter(
767
+ (key) =>
768
+ key === "PORT" ||
769
+ key === "APP_URL" ||
770
+ key === "PUBLIC_URL" ||
771
+ key === "CORS_ORIGIN" ||
772
+ key === "AI_SERVER_URL" ||
773
+ key.startsWith("NEXT_PUBLIC_") ||
774
+ key.startsWith("VITE_") ||
775
+ key.startsWith("REACT_APP_")
776
+ );
777
+ if (Object.keys(localValues).length || localRemove.length) {
778
+ mergeEnvFile(path.join(folder, ".env.local"), localValues, {
779
+ remove: localRemove,
780
+ });
781
+ }
782
+ const keys = Object.entries(next)
783
+ .map(([key, value]) => `${key}=${value}`)
784
+ .join(" ");
785
+ log(
786
+ `env write ${folder} .env${
787
+ Object.keys(localValues).length || localRemove.length
788
+ ? " +.env.local"
789
+ : ""
790
+ }${remove.length ? ` remove=${remove.join(",")}` : ""}${
791
+ keys ? ` ${keys}` : ""
792
+ }`
793
+ );
794
+ }
795
+
796
+ function originFromUrl(value) {
797
+ try {
798
+ return new URL(String(value || "").trim()).origin;
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+
804
+ function readProjectEnvValues(folder) {
805
+ /** @type {Record<string, string>} */
806
+ const map = {};
807
+ if (!folder) return map;
808
+ const resolved = path.resolve(folder);
809
+ for (const name of [".env", ".env.local"]) {
810
+ Object.assign(map, readEnvFile(path.join(resolved, name)));
811
+ }
812
+ return map;
813
+ }
814
+
815
+ function isTryCloudflareUrl(value) {
816
+ try {
817
+ return new URL(String(value || "").trim()).hostname.endsWith(
818
+ ".trycloudflare.com"
819
+ );
820
+ } catch {
821
+ return false;
822
+ }
823
+ }
824
+
825
+ function normalizePublicOrigin(value) {
826
+ const origin = originFromUrl(value);
827
+ return origin || null;
828
+ }
829
+
830
+ function readCloudflareTunnelFile(folder) {
831
+ if (!folder) return null;
832
+ const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
833
+ if (!fs.existsSync(file)) return null;
834
+ /** @type {Record<string, string>} */
835
+ const tunnels = {};
836
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
837
+ const line = raw.trim();
838
+ if (!line || line.startsWith("#")) continue;
839
+ const eq = line.indexOf("=");
840
+ if (eq < 1) continue;
841
+ const role = line.slice(0, eq).trim();
842
+ const url = line.slice(eq + 1).trim().replace(/\/$/, "");
843
+ if (role && isTryCloudflareUrl(url)) tunnels[role] = url;
844
+ }
845
+ return Object.keys(tunnels).length ? tunnels : null;
846
+ }
847
+
848
+ function clearCloudflareTunnelFile(folder) {
849
+ if (!folder) return;
850
+ const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
851
+ try {
852
+ if (fs.existsSync(file)) fs.unlinkSync(file);
853
+ } catch {
854
+ /* ignore */
855
+ }
856
+ }
857
+
858
+ /** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
859
+ function archiveStaleCloudflareLogs(folder) {
860
+ if (!folder) return;
861
+ const logDir = path.join(path.resolve(folder), ".maintainer-pro");
862
+ if (!fs.existsSync(logDir)) return;
863
+ let names = [];
864
+ try {
865
+ names = fs.readdirSync(logDir);
866
+ } catch {
867
+ return;
868
+ }
869
+ for (const name of names) {
870
+ if (!/^cf-.*\.log$/i.test(name) || name.endsWith(".stale")) continue;
871
+ try {
872
+ fs.renameSync(
873
+ path.join(logDir, name),
874
+ path.join(logDir, `${name}.stale`)
875
+ );
876
+ } catch {
877
+ /* ignore */
878
+ }
879
+ }
880
+ }
881
+
882
+ const CLOUDFLARE_ENV_KEYS = [
883
+ "APP_URL",
884
+ "PUBLIC_URL",
885
+ "CORS_ORIGIN",
886
+ "NEXT_PUBLIC_APP_URL",
887
+ "VITE_APP_URL",
888
+ "REACT_APP_APP_URL",
889
+ "AI_SERVER_URL",
890
+ "NEXT_PUBLIC_AI_SERVER_URL",
891
+ "VITE_AI_SERVER_URL",
892
+ "REACT_APP_AI_SERVER_URL",
893
+ "API_URL",
894
+ "API_BASE_URL",
895
+ "VITE_API_URL",
896
+ "VITE_API_BASE_URL",
897
+ "NEXT_PUBLIC_API_URL",
898
+ "NEXT_PUBLIC_API_BASE_URL",
899
+ "BACKEND_URL",
900
+ ];
901
+
902
+ /**
903
+ * Remove dead trycloudflare URLs from workspace state, tunnel file, env, and
904
+ * archived logs so heartbeats stop re-probing them.
905
+ */
906
+ function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
907
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
908
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
909
+ const deadUrls = (opts.deadUrls || [])
910
+ .map((u) => String(u || "").replace(/\/$/, ""))
911
+ .filter((u) => isTryCloudflareUrl(u));
912
+ const keep = opts.keep && typeof opts.keep === "object" ? opts.keep : null;
913
+
914
+ if (deadUrls.length) {
915
+ log(
916
+ `clearing stale Cloudflare for ${label}: ${deadUrls.join(", ")}`
917
+ );
918
+ } else {
919
+ log(`clearing stale Cloudflare for ${label}`);
920
+ }
921
+
922
+ ws.cloudflareUrl = null;
923
+ ws.cloudflare = keep && Object.keys(keep).length ? { ...keep } : null;
924
+ if (keep?.ui || keep?.ai) {
925
+ ws.cloudflareUrl = keep.ui || keep.ai;
926
+ ws.appUrl = keep.ui || keep.ai;
927
+ } else if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
928
+ ws.appUrl = null;
929
+ }
930
+
931
+ if (ws.sandboxId && !(keep && Object.keys(keep).length)) {
932
+ cloudflareTunnels.delete(ws.sandboxId);
933
+ }
934
+
935
+ if (!folder || !fs.existsSync(folder)) {
936
+ persistWorkspaceEntry(cfg, ws);
937
+ return;
938
+ }
939
+
940
+ // Always archive cf-*.log so dead trycloudflare hosts are not rediscovered.
941
+ archiveStaleCloudflareLogs(folder);
942
+ if (keep && Object.keys(keep).length) {
943
+ writeTunnelEnv(ws, keep);
944
+ } else {
945
+ clearCloudflareTunnelFile(folder);
946
+ }
947
+
948
+ const current = readProjectEnvValues(folder);
949
+ /** @type {string[]} */
950
+ const remove = [];
951
+ for (const key of CLOUDFLARE_ENV_KEYS) {
952
+ const value = current[key];
953
+ if (!isTryCloudflareUrl(value)) continue;
954
+ const normalized = String(value).replace(/\/$/, "");
955
+ if (keep && Object.values(keep).some((u) => String(u).replace(/\/$/, "") === normalized)) {
956
+ continue;
957
+ }
958
+ remove.push(key);
959
+ }
960
+ if (remove.length) {
961
+ /** @type {Record<string, string>} */
962
+ const localFallback = {};
963
+ if (opts.localEnv && typeof opts.localEnv === "object") {
964
+ Object.assign(localFallback, opts.localEnv);
965
+ }
966
+ writeProjectEnv(folder, localFallback, { remove });
967
+ }
968
+
969
+ persistWorkspaceEntry(cfg, ws);
970
+ }
971
+
972
+ function parseAllTryCloudflareUrls(text) {
973
+ const matches = [
974
+ ...String(text || "").matchAll(
975
+ /https:\/\/[a-z0-9-]+\.trycloudflare\.com/gi
976
+ ),
977
+ ];
978
+ return matches.map((m) => m[0].replace(/\/$/, ""));
979
+ }
980
+
981
+ function lastTryCloudflareUrl(text) {
982
+ const all = parseAllTryCloudflareUrls(text);
983
+ return all.length ? all[all.length - 1] : null;
984
+ }
985
+
986
+ /**
987
+ * Discover Cloudflare tunnel URLs from bridge state, tunnel file, project env,
988
+ * and cloudflared log files — even if this bridge process did not start them.
989
+ * @returns {Record<string, string> | null}
990
+ */
991
+ function discoverCloudflareTunnels(ws) {
992
+ if (!ws?.folderPath) return null;
993
+ const folder = path.resolve(ws.folderPath);
994
+ /** @type {Record<string, string>} */
995
+ const tunnels = {};
996
+ const setRole = (role, value) => {
997
+ if (!role || tunnels[role] || !isTryCloudflareUrl(value)) return;
998
+ tunnels[role] = String(value).replace(/\/$/, "");
999
+ };
1000
+
1001
+ if (ws.cloudflare && typeof ws.cloudflare === "object") {
1002
+ for (const [role, value] of Object.entries(ws.cloudflare)) {
1003
+ setRole(role, value);
1004
+ }
1005
+ }
1006
+ if (ws.cloudflareUrl) setRole("ui", ws.cloudflareUrl);
1007
+
1008
+ const fromFile = readCloudflareTunnelFile(folder);
1009
+ if (fromFile) {
1010
+ for (const [role, value] of Object.entries(fromFile)) setRole(role, value);
1011
+ }
1012
+
1013
+ const env = readProjectEnvValues(folder);
1014
+ const envRoles = [
1015
+ ["ai", env.NEXT_PUBLIC_AI_SERVER_URL],
1016
+ ["ai", env.VITE_AI_SERVER_URL],
1017
+ ["ai", env.AI_SERVER_URL],
1018
+ ["ai", env.REACT_APP_AI_SERVER_URL],
1019
+ ["ui", env.NEXT_PUBLIC_APP_URL],
1020
+ ["ui", env.VITE_APP_URL],
1021
+ ["ui", env.APP_URL],
1022
+ ["ui", env.PUBLIC_URL],
1023
+ ["backend", env.NEXT_PUBLIC_API_URL],
1024
+ ["backend", env.VITE_API_URL],
1025
+ ["backend", env.API_URL],
1026
+ ];
1027
+ for (const [role, value] of envRoles) setRole(role, value);
1028
+
1029
+ const logDir = path.join(folder, ".maintainer-pro");
1030
+ if (fs.existsSync(logDir)) {
1031
+ const sandboxPrefix = `cf-${String(ws.sandboxId || "").slice(0, 8)}-`;
1032
+ let names = [];
1033
+ try {
1034
+ names = fs.readdirSync(logDir);
1035
+ } catch {
1036
+ names = [];
1037
+ }
1038
+ // Prefer sandbox-scoped logs, then any cf-*-role.log in the project.
1039
+ const ranked = names
1040
+ .filter((name) => /^cf-.*\.log$/i.test(name) && !name.endsWith(".stale"))
1041
+ .sort((a, b) => {
1042
+ const aScore = a.startsWith(sandboxPrefix) ? 0 : 1;
1043
+ const bScore = b.startsWith(sandboxPrefix) ? 0 : 1;
1044
+ return aScore - bScore || a.localeCompare(b);
1045
+ });
1046
+ for (const name of ranked) {
1047
+ const roleMatch = name.match(
1048
+ /cf-(?:[a-f0-9]{6,}-)?(ai|ui|backend|app)\.log$/i
1049
+ );
1050
+ if (!roleMatch) continue;
1051
+ let role = roleMatch[1].toLowerCase();
1052
+ if (role === "app") role = "ui";
1053
+ if (tunnels[role]) continue;
1054
+ try {
1055
+ const text = fs.readFileSync(path.join(logDir, name), "utf8");
1056
+ const url = lastTryCloudflareUrl(text);
1057
+ if (url) setRole(role, url);
1058
+ } catch {
1059
+ /* ignore unreadable logs */
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ return Object.keys(tunnels).length ? tunnels : null;
1065
+ }
1066
+
1067
+ function rememberCloudflareTunnels(sandboxId, tunnels) {
1068
+ if (!sandboxId || !tunnels) return;
1069
+ cloudflareTunnels.set(sandboxId, {
1070
+ tunnels: Object.entries(tunnels)
1071
+ .filter(([, url]) => Boolean(url))
1072
+ .map(([role, publicUrl]) => ({
1073
+ role,
1074
+ localUrl: "",
1075
+ publicUrl: String(publicUrl),
1076
+ logFile: "",
1077
+ })),
1078
+ });
1079
+ }
1080
+
1081
+ function cloudflareLive(ws) {
1082
+ if (!ws?.sandboxId) return false;
1083
+ if (ws.cloudflarePending) return false;
1084
+ // Only tunnels managed in this process count as live. Persisted
1085
+ // cloudflareUrl / discovered log URLs go stale when apps stop.
1086
+ const row = cloudflareTunnels.get(ws.sandboxId);
1087
+ return Boolean(
1088
+ row?.tunnels?.some((t) => isTryCloudflareUrl(t.publicUrl))
1089
+ );
1090
+ }
1091
+
1092
+ function workspaceHostReport(ws) {
1093
+ /** @type {string[]} */
1094
+ const origins = [];
1095
+ const add = (value) => {
1096
+ const origin = originFromUrl(value);
1097
+ if (origin && !origins.includes(origin)) origins.push(origin);
1098
+ };
1099
+ const env = readProjectEnvValues(ws.folderPath);
1100
+ const discovered = discoverCloudflareTunnels(ws);
1101
+ const liveCf = cloudflareLive(ws);
1102
+ const chatPort = Number(env.AI_SERVER_PORT || ws.port);
1103
+ const uiPort = Number(
1104
+ env.PORT || ws.projectInfo?.ports?.ui || ws.projectInfo?.ports?.app
1105
+ );
1106
+ const cf =
1107
+ liveCf || discovered
1108
+ ? {
1109
+ ...(typeof ws.cloudflare === "object" && ws.cloudflare
1110
+ ? ws.cloudflare
1111
+ : {}),
1112
+ ...(discovered || {}),
1113
+ }
1114
+ : null;
1115
+
1116
+ if (cf && Object.keys(cf).length) {
1117
+ add(ws.cloudflareUrl);
1118
+ for (const value of Object.values(cf)) add(value);
1119
+ add(env.NEXT_PUBLIC_AI_SERVER_URL);
1120
+ add(env.AI_SERVER_URL);
1121
+ add(env.APP_URL);
1122
+ add(env.NEXT_PUBLIC_APP_URL);
1123
+ } else {
1124
+ for (const value of [
1125
+ env.CORS_ORIGIN,
1126
+ env.APP_URL,
1127
+ env.NEXT_PUBLIC_APP_URL,
1128
+ env.PUBLIC_URL,
1129
+ ws.appUrl,
1130
+ ]) {
1131
+ if (value && isLocalAppUrl(value)) add(value);
1132
+ }
1133
+ add(env.AI_SERVER_URL);
1134
+ add(env.NEXT_PUBLIC_AI_SERVER_URL);
1135
+ }
1136
+ if (chatPort) {
1137
+ add(`http://localhost:${chatPort}`);
1138
+ add(`http://127.0.0.1:${chatPort}`);
1139
+ }
1140
+ if (uiPort) {
1141
+ add(`http://localhost:${uiPort}`);
1142
+ add(`http://127.0.0.1:${uiPort}`);
1143
+ }
1144
+
1145
+ let appUrl = null;
1146
+ if (cf?.ui && isTryCloudflareUrl(cf.ui)) {
1147
+ appUrl = normalizePublicOrigin(cf.ui);
1148
+ } else if (ws.cloudflareUrl && isTryCloudflareUrl(ws.cloudflareUrl)) {
1149
+ appUrl = normalizePublicOrigin(ws.cloudflareUrl);
1150
+ } else if (cf?.ai && isTryCloudflareUrl(cf.ai)) {
1151
+ appUrl = normalizePublicOrigin(cf.ai);
1152
+ } else if (env.APP_URL && isLocalAppUrl(env.APP_URL)) {
1153
+ appUrl = originFromUrl(env.APP_URL);
1154
+ } else if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
1155
+ appUrl = originFromUrl(ws.appUrl) || ws.appUrl;
1156
+ } else if (uiPort) {
1157
+ appUrl = `http://localhost:${uiPort}`;
1158
+ } else if (chatPort) {
1159
+ appUrl = `http://localhost:${chatPort}`;
1160
+ }
1161
+ return { appUrl, origins };
1162
+ }
1163
+
1164
+ /**
1165
+ * Probe discovered public URLs and keep only ones that respond.
1166
+ * @returns {Promise<{ live: Record<string, string> | null, dead: Record<string, string> }>}
1167
+ */
1168
+ async function filterReachableCloudflareTunnels(tunnels) {
1169
+ /** @type {Record<string, string>} */
1170
+ const live = {};
1171
+ /** @type {Record<string, string>} */
1172
+ const dead = {};
1173
+ if (!tunnels || !Object.keys(tunnels).length) {
1174
+ return { live: null, dead };
1175
+ }
1176
+ for (const [role, url] of Object.entries(tunnels)) {
1177
+ if (!isTryCloudflareUrl(url)) continue;
1178
+ const normalized = String(url).replace(/\/$/, "");
1179
+ const target =
1180
+ role === "ai"
1181
+ ? `${normalized}/embed-config.js`
1182
+ : normalized;
1183
+ if (await probeUrl(target, 8_000)) {
1184
+ live[role] = normalized;
1185
+ } else {
1186
+ dead[role] = normalized;
1187
+ }
1188
+ }
1189
+ return {
1190
+ live: Object.keys(live).length ? live : null,
1191
+ dead,
1192
+ };
1193
+ }
1194
+
1195
+ function envNeedsUpdate(folder, desired) {
1196
+ if (!desired || !Object.keys(desired).length) return false;
1197
+ const current = readProjectEnvValues(folder);
1198
+ return Object.entries(desired).some(
1199
+ ([key, value]) => String(current[key] || "") !== String(value)
1200
+ );
1201
+ }
1202
+
1203
+ /**
1204
+ * Status-only reconcile for a workspace:
1205
+ * 1) probe chat / ui / backend
1206
+ * 2) detect reachable Cloudflare (never starts tunnels)
1207
+ * 3) sync base URLs in env when mode or ports changed
1208
+ * 4) compute host appUrl + CORS origins for Maintainer Pro
1209
+ *
1210
+ * Does not start apps or Cloudflare.
1211
+ */
1212
+ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1213
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1214
+ const writeEnv = opts.writeEnv !== false;
1215
+ const timeoutMs = opts.timeoutMs || 800;
1216
+ const folder = path.resolve(ws.folderPath || "");
1217
+
1218
+ // 1. Local process probes
1219
+ const probe = await probeRunningApps(ws, timeoutMs);
1220
+ if (probe.chatUp && Number(ws.port) !== probe.chatPort) {
1221
+ ws.port = probe.chatPort;
1222
+ }
1223
+
1224
+ // 2. Cloudflare detection (never starts tunnels). Purge stale URLs so we
1225
+ // do not keep re-probing dead trycloudflare links from env/logs every tick.
1226
+ const discovered = discoverCloudflareTunnels(ws);
1227
+ const cfCheck = discovered
1228
+ ? await filterReachableCloudflareTunnels(discovered)
1229
+ : { live: null, dead: {} };
1230
+ const reachableCf = cfCheck.live;
1231
+ const deadCf = cfCheck.dead;
1232
+ const managedLive = cloudflareLive(ws);
1233
+
1234
+ if (Object.keys(deadCf).length > 0) {
1235
+ /** @type {Record<string, string>} */
1236
+ let localEnv = {};
1237
+ if (probe.running) {
1238
+ const jobs = probe.hosts
1239
+ .filter((h) => h.up)
1240
+ .map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
1241
+ localEnv = envForWorkspacePorts(ws, jobs);
1242
+ }
1243
+ /** @type {Record<string, string> | null} */
1244
+ let keep = reachableCf;
1245
+ if (!keep && managedLive) {
1246
+ const managed = {};
1247
+ for (const t of cloudflareTunnels.get(ws.sandboxId)?.tunnels || []) {
1248
+ if (t?.role && isTryCloudflareUrl(t.publicUrl)) {
1249
+ managed[t.role] = String(t.publicUrl).replace(/\/$/, "");
1250
+ }
1251
+ }
1252
+ keep = Object.keys(managed).length ? managed : null;
1253
+ }
1254
+ purgeUnreachableCloudflare(ws, cfg, {
1255
+ deadUrls: Object.values(deadCf),
1256
+ keep,
1257
+ localEnv,
1258
+ });
1259
+ }
1260
+
1261
+ const usingCloudflare = Boolean(
1262
+ managedLive || (reachableCf && Object.keys(reachableCf).length)
1263
+ );
1264
+
1265
+ if (reachableCf) {
1266
+ ws.cloudflare = { ...(ws.cloudflare || {}), ...reachableCf };
1267
+ ws.cloudflareUrl = reachableCf.ui || reachableCf.ai || ws.cloudflareUrl || null;
1268
+ if (reachableCf.ui || reachableCf.ai) {
1269
+ ws.appUrl = reachableCf.ui || reachableCf.ai;
1270
+ }
1271
+ } else if (!managedLive) {
1272
+ ws.cloudflareUrl = null;
1273
+ ws.cloudflare = null;
1274
+ if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) ws.appUrl = null;
1275
+ }
1276
+
1277
+ const cfTunnels =
1278
+ usingCloudflare
1279
+ ? {
1280
+ ...(typeof ws.cloudflare === "object" && ws.cloudflare
1281
+ ? ws.cloudflare
1282
+ : {}),
1283
+ ...(reachableCf || {}),
1284
+ }
1285
+ : null;
1286
+
1287
+ // 3. Sync env base URLs when apps are up or Cloudflare is live
1288
+ if (writeEnv && folder && fs.existsSync(folder)) {
1289
+ /** @type {Record<string, string>} */
1290
+ let desired = {};
1291
+ if (cfTunnels && Object.keys(cfTunnels).length) {
1292
+ desired = {
1293
+ ...uiPublicEnv(cfTunnels),
1294
+ AI_SERVER_PORT: String(probe.chatPort || ws.port || 3100),
1295
+ };
1296
+ if (probe.hosts.some((h) => h.role === "ui" || h.role === "app")) {
1297
+ const ui = probe.hosts.find(
1298
+ (h) => (h.role === "ui" || h.role === "app") && h.up
1299
+ );
1300
+ if (ui?.port) desired.PORT = String(ui.port);
1301
+ }
1302
+ if (envNeedsUpdate(folder, desired)) {
1303
+ log(
1304
+ `env sync ${label}: cloudflare urls (${Object.keys(cfTunnels).join(",")})`
1305
+ );
1306
+ writeTunnelEnv(ws, cfTunnels);
1307
+ writeProjectEnv(folder, desired);
1308
+ }
1309
+ } else if (probe.running) {
1310
+ const jobs = probe.hosts
1311
+ .filter((h) => h.up)
1312
+ .map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
1313
+ desired = envForWorkspacePorts(ws, jobs);
1314
+ // Drop stale trycloudflare values when running local-only.
1315
+ const current = readProjectEnvValues(folder);
1316
+ /** @type {string[]} */
1317
+ const remove = [];
1318
+ for (const key of [
1319
+ "APP_URL",
1320
+ "PUBLIC_URL",
1321
+ "CORS_ORIGIN",
1322
+ "NEXT_PUBLIC_APP_URL",
1323
+ "VITE_APP_URL",
1324
+ "AI_SERVER_URL",
1325
+ "NEXT_PUBLIC_AI_SERVER_URL",
1326
+ "VITE_AI_SERVER_URL",
1327
+ "REACT_APP_AI_SERVER_URL",
1328
+ "API_URL",
1329
+ "VITE_API_URL",
1330
+ "NEXT_PUBLIC_API_URL",
1331
+ ]) {
1332
+ if (isTryCloudflareUrl(current[key]) && desired[key]) {
1333
+ // overwritten by desired
1334
+ } else if (isTryCloudflareUrl(current[key]) && !desired[key]) {
1335
+ remove.push(key);
1336
+ }
1337
+ }
1338
+ if (envNeedsUpdate(folder, desired) || remove.length) {
1339
+ log(`env sync ${label}: local app urls`);
1340
+ writeProjectEnv(folder, desired, { remove });
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ // 4. Host + CORS origins for Maintainer Pro
1346
+ const host = workspaceHostReport(ws);
1347
+ if (cfTunnels?.ui && isTryCloudflareUrl(cfTunnels.ui)) {
1348
+ host.appUrl = normalizePublicOrigin(cfTunnels.ui);
1349
+ } else if (cfTunnels?.ai && isTryCloudflareUrl(cfTunnels.ai)) {
1350
+ host.appUrl = normalizePublicOrigin(cfTunnels.ai);
1351
+ } else if (probe.running) {
1352
+ const ui = probe.hosts.find(
1353
+ (h) => (h.role === "ui" || h.role === "app") && h.up
1354
+ );
1355
+ if (ui?.port) {
1356
+ host.appUrl = `http://localhost:${ui.port}`;
1357
+ } else if (probe.chatUp) {
1358
+ host.appUrl = `http://localhost:${probe.chatPort}`;
1359
+ }
1360
+ }
1361
+
1362
+ // Ensure probed local origins are always included when processes are up.
1363
+ for (const h of probe.hosts) {
1364
+ if (!h.up || !h.port) continue;
1365
+ const origin = `http://localhost:${h.port}`;
1366
+ if (!host.origins.includes(origin)) host.origins.push(origin);
1367
+ const loopback = `http://127.0.0.1:${h.port}`;
1368
+ if (!host.origins.includes(loopback)) host.origins.push(loopback);
1369
+ }
1370
+ if (probe.chatUp) {
1371
+ const chatOrigin = `http://localhost:${probe.chatPort}`;
1372
+ if (!host.origins.includes(chatOrigin)) host.origins.push(chatOrigin);
1373
+ }
1374
+ if (cfTunnels) {
1375
+ for (const value of Object.values(cfTunnels)) {
1376
+ const origin = normalizePublicOrigin(value);
1377
+ if (origin && !host.origins.includes(origin)) host.origins.push(origin);
1378
+ }
1379
+ }
1380
+
1381
+ if (host.appUrl) ws.appUrl = host.appUrl;
1382
+
1383
+ if (probe.running) {
1384
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1385
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
1386
+ clearProcessProblem(ws.sandboxId, "ai_server_down", "ai");
1387
+ for (const h of probe.hosts) {
1388
+ if (!h.up) continue;
1389
+ clearProcessProblem(ws.sandboxId, "host_process_launch", h.role);
1390
+ clearProcessProblem(ws.sandboxId, "host_process_down", h.role);
1391
+ }
1392
+ }
1393
+
1394
+ persistWorkspaceEntry(cfg, ws);
1395
+
1396
+ return {
1397
+ probe,
1398
+ usingCloudflare,
1399
+ cloudflare: cfTunnels,
1400
+ host,
1401
+ appsRunning: probe.running,
1402
+ aiServerUp: probe.chatUp,
1403
+ };
1404
+ }
1405
+
1406
+ async function reconcileCloudflareState(ws, cfg) {
1407
+ const status = await reconcileWorkspacePresence(ws, cfg, {
1408
+ writeEnv: true,
1409
+ timeoutMs: 2500,
1410
+ });
1411
+ return status.usingCloudflare;
1412
+ }
1413
+
1414
+ /**
1415
+ * If Cloudflare is already running for this folder, attach and share with MP
1416
+ * instead of creating new tunnels.
1417
+ */
1418
+ async function tryAttachExistingCloudflare(ws, cfg, opts = {}) {
1419
+ const progress =
1420
+ typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
1421
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1422
+ await progress(
1423
+ "Looking for Cloudflare tunnels that are already running for this project…"
1424
+ );
1425
+ const discovered = discoverCloudflareTunnels(ws);
1426
+ if (!discovered) {
1427
+ await progress("No existing Cloudflare tunnel URLs found yet.");
1428
+ return null;
1429
+ }
1430
+ await progress(
1431
+ `Found candidate tunnels: ${Object.entries(discovered)
1432
+ .map(([role, url]) => `${role}=${url}`)
1433
+ .join(", ")}`
1434
+ );
1435
+ const { live, dead } = await filterReachableCloudflareTunnels(discovered);
1436
+ if (!live || (!live.ai && !live.ui)) {
1437
+ if (Object.keys(dead).length) {
1438
+ purgeUnreachableCloudflare(ws, cfg, {
1439
+ deadUrls: Object.values(dead),
1440
+ });
1441
+ }
1442
+ await progress(
1443
+ "Those Cloudflare URLs did not respond — will create fresh tunnels."
1444
+ );
1445
+ return null;
1446
+ }
1447
+
1448
+ writeTunnelEnv(ws, live);
1449
+ // Make sure the chat script advertises the public AI URL when we have one.
1450
+ if (live.ai) {
1451
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
1452
+ const up = await probeUrl(
1453
+ `http://127.0.0.1:${Number(ws.port) || 3100}/embed-config.js`
1454
+ );
1455
+ if (up) {
1456
+ launchedAt.delete(`${ws.sandboxId}:${path.resolve(ws.folderPath)}:ai`);
1457
+ await killPort(ws.port);
1458
+ await sleep(1200);
1459
+ }
1460
+ await startAiServerForWorkspace(ws, {
1461
+ reserved,
1462
+ cfg,
1463
+ port: ws.port,
1464
+ env: uiPublicEnv(live),
1465
+ });
1466
+ await waitUntilReachable(
1467
+ `http://127.0.0.1:${ws.port}/embed-config.js`,
1468
+ 45_000,
1469
+ "the chat script",
1470
+ progress
1471
+ );
1472
+ }
1473
+
1474
+ const validation = await validateCloudflareGoLive(ws, live, {
1475
+ onProgress: progress,
1476
+ });
1477
+ if (!validation.ok) {
1478
+ await progress(
1479
+ "Existing tunnels failed go-live checks — will recreate if needed."
1480
+ );
1481
+ return null;
1482
+ }
1483
+
1484
+ const appUrl = live.ui || live.ai;
1485
+ ws.cloudflareUrl = appUrl;
1486
+ ws.cloudflare = live;
1487
+ ws.appUrl = appUrl;
1488
+ ws.cloudflarePending = false;
1489
+ ws.appsRequested = true;
1490
+ persistWorkspaceEntry(cfg, ws);
1491
+ rememberCloudflareTunnels(ws.sandboxId, live);
1492
+ clearProcessProblem(ws.sandboxId, "cloudflare_launch", "tunnel");
1493
+ const host = workspaceHostReport(ws);
1494
+ await progress(`Attached to existing Cloudflare: ${host.appUrl || appUrl}`);
1495
+ log(`cloudflare attached (reuse) ${label}: ${appUrl}`);
1496
+
1497
+ return {
1498
+ sandboxId: ws.sandboxId,
1499
+ folderPath: ws.folderPath,
1500
+ port: ws.port,
1501
+ appUrl: host.appUrl || appUrl,
1502
+ origins: host.origins.length
1503
+ ? host.origins
1504
+ : Object.values(live).filter(Boolean),
1505
+ tunnels: live,
1506
+ validation,
1507
+ cloudflare: true,
1508
+ reused: true,
1509
+ attached: true,
1510
+ };
1511
+ }
1512
+
1513
+ function clearStaleCloudflare(ws) {
1514
+ if (cloudflareLive(ws)) return;
1515
+ if (!ws.cloudflareUrl && !ws.cloudflare) return;
1516
+ if (discoverCloudflareTunnels(ws)) return;
1517
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1518
+ log(`clearing stale Cloudflare URL for ${label}`);
1519
+ ws.cloudflareUrl = null;
1520
+ ws.cloudflare = null;
1521
+ if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
1522
+ ws.appUrl = null;
1523
+ }
1524
+ }
1525
+
1526
+ async function probeHttpPaths(port, paths, timeoutMs = 2500) {
1527
+ const n = Number(port);
1528
+ if (!n) return false;
1529
+ for (const host of ["127.0.0.1", "localhost"]) {
1530
+ for (const suffix of paths) {
1531
+ if (await probeUrl(`http://${host}:${n}${suffix}`, timeoutMs)) return true;
1532
+ }
1533
+ }
1534
+ return false;
1535
+ }
1536
+
1537
+ async function discoverChatPort(ws, timeoutMs = 2500) {
1538
+ const env = readProjectEnvValues(ws.folderPath);
1539
+ const candidates = [
1540
+ Number(ws.port),
1541
+ Number(env.AI_SERVER_PORT),
1542
+ portFromText(env.AI_SERVER_URL, 0),
1543
+ portFromText(env.NEXT_PUBLIC_AI_SERVER_URL, 0),
1544
+ 3100,
1545
+ ].filter((port) => port >= 1024);
1546
+ const unique = [...new Set(candidates)];
1547
+ const chatPaths = ["/embed-config.js", "/", "/health"];
1548
+ for (const port of unique) {
1549
+ if (await probeHttpPaths(port, chatPaths, timeoutMs)) {
1550
+ return { port, up: true };
1551
+ }
1552
+ }
1553
+ return { port: unique[0] || Number(ws.port) || 3100, up: false };
1554
+ }
1555
+
1556
+ async function probeRunningApps(ws, timeoutMs = 2500) {
1557
+ const folder = path.resolve(ws.folderPath || "");
1558
+ const env = readProjectEnvValues(ws.folderPath);
1559
+ const chat = await discoverChatPort(ws, timeoutMs);
1560
+ if (chat.up && Number(ws.port) !== chat.port) {
1561
+ ws.port = chat.port;
1562
+ }
1563
+ const jobs = planHostJobs(
1564
+ folder,
1565
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1566
+ ws.projectInfo
1567
+ );
1568
+ /** @type {Array<{ role: string, port: number, up: boolean }>} */
1569
+ const hosts = [];
1570
+ for (const job of jobs) {
1571
+ const candidates = [
1572
+ Number(job.port),
1573
+ Number(job.preferredPort),
1574
+ Number(env.PORT),
1575
+ portFromText(env.APP_URL, 0),
1576
+ portFromText(ws.appUrl, 0),
1577
+ ].filter((port) => port >= 1024);
1578
+ const unique = [...new Set(candidates.length ? candidates : [3000])];
1579
+ let up = false;
1580
+ let port = unique[0];
1581
+ for (const candidate of unique) {
1582
+ const probe = (
1583
+ job.probeUrl || `http://127.0.0.1:${candidate}`
1584
+ ).replace("localhost", "127.0.0.1");
1585
+ const ok =
1586
+ (await probeUrl(probe, timeoutMs)) ||
1587
+ (await probeHttpPaths(candidate, ["/"], timeoutMs));
1588
+ if (ok) {
1589
+ up = true;
1590
+ port = candidate;
1591
+ break;
1592
+ }
1593
+ }
1594
+ hosts.push({ role: job.role, port, up });
291
1595
  }
292
- throw new Error("No free TCP port found");
1596
+ return {
1597
+ chatUp: chat.up,
1598
+ chatPort: chat.port,
1599
+ hosts,
1600
+ running: chat.up || hosts.some((host) => host.up),
1601
+ };
293
1602
  }
294
1603
 
295
- function urlWithPort(url, port) {
296
- try {
297
- const parsed = new URL(url);
298
- parsed.port = String(port);
299
- return parsed.toString().replace(/\/$/, "");
300
- } catch {
301
- return `http://localhost:${port}`;
1604
+ async function restoreHostsAfterReconnect(cfg) {
1605
+ for (const ws of cfg.workspaces || []) {
1606
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1607
+ // Status-only: probe apps + Cloudflare, sync env/origins for MP.
1608
+ // Never start apps or tunnels on reconnect.
1609
+ const status = await reconcileWorkspacePresence(ws, cfg, {
1610
+ writeEnv: true,
1611
+ timeoutMs: 2500,
1612
+ });
1613
+ const hostSummary = status.probe.hosts
1614
+ .map((host) => `${host.role}:${host.port}${host.up ? "(up)" : "(down)"}`)
1615
+ .join(" ");
1616
+ if (status.appsRunning) {
1617
+ log(
1618
+ `found running apps for ${label}: chat=${status.probe.chatPort}${
1619
+ status.probe.chatUp ? "(up)" : "(down)"
1620
+ }${hostSummary ? ` ${hostSummary}` : ""} cloudflare=${
1621
+ status.usingCloudflare ? "yes" : "no"
1622
+ } app=${status.host.appUrl || "(none)"} origins=${
1623
+ status.host.origins.join(",") || "none"
1624
+ }`
1625
+ );
1626
+ } else {
1627
+ log(
1628
+ `no apps running for ${label}${
1629
+ status.usingCloudflare ? " (cloudflare urls present)" : ""
1630
+ } — waiting for Start Apps from Maintainer Pro`
1631
+ );
1632
+ }
302
1633
  }
303
1634
  }
304
1635
 
305
- function isLocalAppUrl(url) {
306
- try {
307
- const host = new URL(url).hostname;
308
- return host === "localhost" || host === "127.0.0.1";
309
- } catch {
310
- return true;
1636
+ function envForWorkspacePorts(ws, jobs) {
1637
+ const aiPort = Number(ws.port) || 3100;
1638
+ const publicAi =
1639
+ (typeof ws.cloudflare?.ai === "string" &&
1640
+ isTryCloudflareUrl(ws.cloudflare.ai) &&
1641
+ String(ws.cloudflare.ai).replace(/\/$/, "")) ||
1642
+ "";
1643
+ const ai = publicAi || `http://localhost:${aiPort}`;
1644
+ /** @type {Record<string, string>} */
1645
+ const env = {
1646
+ AI_SERVER_PORT: String(aiPort),
1647
+ AI_SERVER_URL: ai,
1648
+ NEXT_PUBLIC_AI_SERVER_URL: ai,
1649
+ VITE_AI_SERVER_URL: ai,
1650
+ REACT_APP_AI_SERVER_URL: ai,
1651
+ };
1652
+ const ui = jobs.find((job) => job.role === "ui" || job.role === "app");
1653
+ const publicUi =
1654
+ (typeof ws.cloudflare?.ui === "string" &&
1655
+ isTryCloudflareUrl(ws.cloudflare.ui) &&
1656
+ String(ws.cloudflare.ui).replace(/\/$/, "")) ||
1657
+ "";
1658
+ if (publicUi) {
1659
+ env.PORT = ui?.port ? String(ui.port) : env.PORT;
1660
+ env.APP_URL = publicUi;
1661
+ env.CORS_ORIGIN = publicUi;
1662
+ env.PUBLIC_URL = publicUi;
1663
+ env.NEXT_PUBLIC_APP_URL = publicUi;
1664
+ env.VITE_APP_URL = publicUi;
1665
+ } else if (ui?.port) {
1666
+ const app = `http://localhost:${ui.port}`;
1667
+ env.PORT = String(ui.port);
1668
+ env.APP_URL = app;
1669
+ env.CORS_ORIGIN = app;
1670
+ env.NEXT_PUBLIC_APP_URL = app;
1671
+ env.VITE_APP_URL = app;
311
1672
  }
1673
+ const backend = jobs.find((job) => job.role === "backend");
1674
+ if (backend?.port) {
1675
+ const publicBackend =
1676
+ (typeof ws.cloudflare?.backend === "string" &&
1677
+ isTryCloudflareUrl(ws.cloudflare.backend) &&
1678
+ String(ws.cloudflare.backend).replace(/\/$/, "")) ||
1679
+ "";
1680
+ const api = publicBackend || `http://localhost:${backend.port}`;
1681
+ env.API_URL = api;
1682
+ env.API_PORT = String(backend.port);
1683
+ env.VITE_API_URL = api;
1684
+ env.NEXT_PUBLIC_API_URL = api;
1685
+ }
1686
+ return env;
312
1687
  }
313
1688
 
314
- function persistWorkspaceEntry(cfg, ws) {
315
- if (!cfg || !ws?.sandboxId) return;
316
- cfg.workspaces = cfg.workspaces || [];
317
- const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
318
- if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
319
- else cfg.workspaces.push(ws);
320
- saveConfig(cfg);
321
- }
322
-
323
- function probeUrl(url, timeoutMs = 2500) {
324
- return new Promise((resolve) => {
325
- let settled = false;
326
- const done = (ok) => {
327
- if (settled) return;
328
- settled = true;
329
- resolve(ok);
330
- };
331
- try {
332
- const req = http.get(url, { timeout: timeoutMs }, (res) => {
333
- res.resume();
334
- done(Boolean(res.statusCode && res.statusCode < 500));
335
- });
336
- req.on("error", () => done(false));
337
- req.on("timeout", () => {
338
- req.destroy();
339
- done(false);
340
- });
341
- } catch {
342
- done(false);
343
- }
344
- });
345
- }
1689
+ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1690
+ const folder = path.resolve(ws.folderPath || "");
1691
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1692
+ if (!folder || !fs.existsSync(folder)) {
1693
+ const aiPort = Number(ws.port) || 3100;
1694
+ log(`ports skip ${label}: folder missing (${folder || "none"})`);
1695
+ return { aiPort, jobs: [], env: {} };
1696
+ }
1697
+ log(`ports pick ${label} in ${folder}`);
1698
+ const preferredAi = Number(ws.port) || 3100;
1699
+ const aiUp = await probeUrl(
1700
+ `http://127.0.0.1:${preferredAi}/embed-config.js`
1701
+ );
1702
+ const aiPort = aiUp
1703
+ ? (reserved.add(preferredAi), preferredAi)
1704
+ : await findFreePort(preferredAi, reserved);
1705
+ log(
1706
+ aiUp
1707
+ ? `ports chat ${preferredAi} already up`
1708
+ : aiPort === preferredAi
1709
+ ? `ports chat ${aiPort} free`
1710
+ : `ports chat ${preferredAi} busy; using ${aiPort}`
1711
+ );
1712
+ ws.port = aiPort;
346
1713
 
347
- function listDriveRoots() {
348
- if (process.platform !== "win32") return ["/"];
349
- const roots = [];
350
- for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
351
- const root = `${letter}:\\`;
352
- try {
353
- if (fs.existsSync(root)) roots.push(root);
354
- } catch {
355
- /* skip */
1714
+ const jobs = planHostJobs(
1715
+ folder,
1716
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1717
+ ws.projectInfo
1718
+ );
1719
+ log(
1720
+ jobs.length
1721
+ ? `ports jobs ${jobs.map((job) => `${job.role}:${job.script}:${job.preferredPort}`).join(" ")}`
1722
+ : `ports jobs none`
1723
+ );
1724
+ const planned = [];
1725
+ for (const job of jobs) {
1726
+ const preferred = Number(job.preferredPort) || 3000;
1727
+ const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1728
+ "localhost",
1729
+ "127.0.0.1"
1730
+ );
1731
+ const up = await probeUrl(probe);
1732
+ const port = up
1733
+ ? (reserved.add(portFromText(probe, preferred)), portFromText(probe, preferred))
1734
+ : await findFreePort(preferred, reserved);
1735
+ log(
1736
+ up
1737
+ ? `ports ${job.role} ${port} already up (${probe})`
1738
+ : port === preferred
1739
+ ? `ports ${job.role} ${port} free for ${job.script}`
1740
+ : `ports ${job.role} ${preferred} busy; using ${port} for ${job.script}`
1741
+ );
1742
+ planned.push({ ...job, port, up });
1743
+ if (
1744
+ (job.role === "ui" || job.role === "app") &&
1745
+ (!ws.appUrl || isLocalAppUrl(ws.appUrl))
1746
+ ) {
1747
+ ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
356
1748
  }
357
1749
  }
358
- return roots.length ? roots : ["C:\\"];
359
- }
360
1750
 
361
- function listDirEntries(dirPath) {
362
- const raw = String(dirPath || "").trim();
363
- const home = os.homedir();
364
- if (!raw || raw === "roots") {
365
- const roots = listDriveRoots();
366
- return {
367
- path: "",
368
- parent: null,
369
- home,
370
- entries: roots.map((root) => ({
371
- name: root,
372
- path: root,
373
- isDir: true,
374
- })),
1751
+ const env = envForWorkspacePorts(ws, planned);
1752
+ writeProjectEnv(folder, env);
1753
+ if (planned.length) {
1754
+ ws.projectInfo = {
1755
+ ...(ws.projectInfo || {}),
1756
+ ports: {
1757
+ ...(ws.projectInfo?.ports || {}),
1758
+ ...Object.fromEntries(planned.map((job) => [job.role, job.port])),
1759
+ },
375
1760
  };
376
1761
  }
1762
+ persistWorkspaceEntry(cfg, ws);
1763
+ log(
1764
+ `ports ready ${label}: chat=${aiPort}${planned
1765
+ .map((job) => ` ${job.role}=${job.port}${job.up ? "(up)" : ""}`)
1766
+ .join("")}`
1767
+ );
1768
+ return { aiPort, jobs: planned, env };
1769
+ }
377
1770
 
378
- const resolved = path.resolve(raw);
379
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
380
- return {
381
- error: "Not a directory",
382
- path: resolved,
383
- parent: path.dirname(resolved),
384
- home,
385
- entries: [],
386
- };
387
- }
388
- const names = fs.readdirSync(resolved);
389
- const entries = [];
390
- for (const name of names) {
391
- if (name === "node_modules" || name === ".git") continue;
392
- const full = path.join(resolved, name);
393
- try {
394
- const st = fs.statSync(full);
395
- entries.push({
396
- name,
397
- path: full,
398
- isDir: st.isDirectory(),
399
- });
400
- } catch {
401
- /* skip */
402
- }
1771
+ const ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
1772
+ const ACCESS_IGNORE_END = "# maintainer-pro:access-end";
1773
+ const DEFAULT_AI_IGNORE_PATHS = [
1774
+ ".env",
1775
+ ".env.*",
1776
+ "**/.env",
1777
+ "**/.env.*",
1778
+ ".maintainer-pro/",
1779
+ ];
1780
+
1781
+ function normalizeIgnorePaths(paths) {
1782
+ /** @type {string[]} */
1783
+ const out = [];
1784
+ const seen = new Set();
1785
+ for (const raw of paths || []) {
1786
+ const p = String(raw || "")
1787
+ .trim()
1788
+ .replace(/\\/g, "/");
1789
+ if (!p || p.startsWith("/") || p.includes("..")) continue;
1790
+ if (seen.has(p)) continue;
1791
+ seen.add(p);
1792
+ out.push(p);
403
1793
  }
404
- entries.sort((a, b) => {
405
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
406
- return a.name.localeCompare(b.name);
407
- });
408
- const parent = path.dirname(resolved);
409
- return {
410
- path: resolved,
411
- parent: parent === resolved ? null : parent,
412
- home,
413
- entries: entries.slice(0, 400),
414
- };
1794
+ return out;
415
1795
  }
416
1796
 
417
- function mergeEnvFile(file, values) {
418
- /** @type {Record<string, string>} */
419
- const map = {};
420
- if (fs.existsSync(file)) {
421
- for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
422
- const line = raw.trim();
423
- if (!line || line.startsWith("#")) continue;
424
- const eq = line.indexOf("=");
425
- if (eq < 1) continue;
426
- map[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
427
- }
1797
+ function resolveIgnorePaths(partnerPaths) {
1798
+ return normalizeIgnorePaths([...DEFAULT_AI_IGNORE_PATHS, ...(partnerPaths || [])]);
1799
+ }
1800
+
1801
+ function upsertManagedIgnoreFile(existing, ignorePaths) {
1802
+ const block = [
1803
+ ACCESS_IGNORE_BEGIN,
1804
+ "# Managed by Maintainer Pro — do not edit this block by hand.",
1805
+ ...ignorePaths,
1806
+ ACCESS_IGNORE_END,
1807
+ "",
1808
+ ].join("\n");
1809
+ const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
1810
+ const end = existing.indexOf(ACCESS_IGNORE_END);
1811
+ if (begin >= 0 && end > begin) {
1812
+ const afterEnd = end + ACCESS_IGNORE_END.length;
1813
+ const before = existing.slice(0, begin).replace(/\s+$/, "");
1814
+ const after = existing.slice(afterEnd).replace(/^\r?\n/, "");
1815
+ const parts = [before, block.trimEnd(), after.trimStart()].filter(Boolean);
1816
+ return `${parts.join("\n\n")}\n`;
428
1817
  }
429
- for (const [k, v] of Object.entries(values)) {
430
- map[k] = String(v);
1818
+ const trimmed = existing.replace(/\s+$/, "");
1819
+ return trimmed ? `${trimmed}\n\n${block}` : block;
1820
+ }
1821
+
1822
+ /**
1823
+ * Restrict AI CLI to this project folder and write partner ignore paths.
1824
+ * @param {string} folder
1825
+ * @param {string[]} partnerIgnorePaths
1826
+ */
1827
+ function applyAccessPolicy(folder, partnerIgnorePaths) {
1828
+ const resolved = path.resolve(folder);
1829
+ fs.mkdirSync(resolved, { recursive: true });
1830
+ const ignorePaths = resolveIgnorePaths(partnerIgnorePaths);
1831
+ const envPath = path.join(resolved, ".env");
1832
+ mergeEnvFile(envPath, {
1833
+ AI_CLI_WORKSPACE: ".",
1834
+ AI_CLI_IGNORE_PATHS: JSON.stringify(
1835
+ normalizeIgnorePaths(partnerIgnorePaths || [])
1836
+ ),
1837
+ });
1838
+
1839
+ const mpDir = path.join(resolved, ".maintainer-pro");
1840
+ fs.mkdirSync(mpDir, { recursive: true });
1841
+ fs.writeFileSync(
1842
+ path.join(mpDir, "access.json"),
1843
+ JSON.stringify(
1844
+ {
1845
+ workspace: ".",
1846
+ ignorePaths,
1847
+ partnerIgnorePaths: normalizeIgnorePaths(partnerIgnorePaths || []),
1848
+ updatedAt: new Date().toISOString(),
1849
+ },
1850
+ null,
1851
+ 2
1852
+ ) + "\n",
1853
+ "utf8"
1854
+ );
1855
+
1856
+ for (const name of [".cursorignore"]) {
1857
+ const file = path.join(resolved, name);
1858
+ const existing = fs.existsSync(file)
1859
+ ? fs.readFileSync(file, "utf8")
1860
+ : "";
1861
+ fs.writeFileSync(file, upsertManagedIgnoreFile(existing, ignorePaths), "utf8");
431
1862
  }
432
- const body = Object.entries(map)
433
- .map(([k, v]) => `${k}=${v}`)
434
- .join("\n");
435
- fs.writeFileSync(file, body + "\n", "utf8");
1863
+
1864
+ return { ignorePaths };
436
1865
  }
437
1866
 
438
1867
  const IGNORE_NAMES = new Set([
@@ -846,6 +2275,8 @@ function collectOfferedFolders(cfg) {
846
2275
 
847
2276
  /** Prevents opening a new window on every heartbeat while a process is starting. */
848
2277
  const launchedAt = new Map();
2278
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
2279
+ const cloudflareTunnels = new Map();
849
2280
 
850
2281
  /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
851
2282
  const processProblems = new Map();
@@ -1081,12 +2512,67 @@ function planHostJobs(dir, appUrl, hints = null) {
1081
2512
 
1082
2513
  function friendlyLaunchError(raw, title) {
1083
2514
  const text = String(raw || "").trim();
1084
- if (/ENOENT/i.test(text)) {
2515
+ if (/^spawn\b.*\bENOENT\b/i.test(text)) {
1085
2516
  return `Could not find a program to open a terminal for "${title}". ${text}`;
1086
2517
  }
1087
2518
  return text || `Could not open a terminal for "${title}".`;
1088
2519
  }
1089
2520
 
2521
+ function writeWinLaunchScript(folder, title, command, env) {
2522
+ const dir = path.join(folder, ".maintainer-pro");
2523
+ fs.mkdirSync(dir, { recursive: true });
2524
+ const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
2525
+ const file = path.join(dir, `launch-${safe}.cmd`);
2526
+ const folderArg = String(folder).replace(/"/g, "");
2527
+ const safeTitle = String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ");
2528
+ const lines = [
2529
+ "@echo off",
2530
+ `title ${safeTitle}`,
2531
+ `cd /d "${folderArg}"`,
2532
+ "if errorlevel 1 (",
2533
+ " echo Could not open the project folder.",
2534
+ " pause",
2535
+ " exit /b 1",
2536
+ ")",
2537
+ ...Object.entries(env).map(
2538
+ ([key, value]) => `set "${key}=${String(value).replace(/"/g, "")}"`
2539
+ ),
2540
+ "echo %CD%",
2541
+ `echo ${command}`,
2542
+ command,
2543
+ "if errorlevel 1 pause",
2544
+ ];
2545
+ fs.writeFileSync(file, `${lines.join("\r\n")}\r\n`, "utf8");
2546
+ return file;
2547
+ }
2548
+
2549
+ function openWindowsConsole(scriptPath, folder) {
2550
+ return new Promise((resolve) => {
2551
+ let child;
2552
+ try {
2553
+ child = spawn(process.env.ComSpec || "cmd.exe", ["/k", scriptPath], {
2554
+ cwd: folder,
2555
+ detached: true,
2556
+ stdio: "ignore",
2557
+ windowsHide: false,
2558
+ });
2559
+ } catch (err) {
2560
+ resolve({
2561
+ ok: false,
2562
+ error: err instanceof Error ? err.message : String(err),
2563
+ });
2564
+ return;
2565
+ }
2566
+ child.once("error", (err) => {
2567
+ resolve({ ok: false, error: err.message });
2568
+ });
2569
+ child.once("spawn", () => {
2570
+ child.unref();
2571
+ resolve({ ok: true });
2572
+ });
2573
+ });
2574
+ }
2575
+
1090
2576
  function runLauncher(command, args, extra = {}) {
1091
2577
  return new Promise((resolve) => {
1092
2578
  let settled = false;
@@ -1136,6 +2622,7 @@ async function openInNewTerminal(opts) {
1136
2622
  const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
1137
2623
  if (launchKey) {
1138
2624
  if (!opts.force && recentlyLaunched(launchKey)) {
2625
+ log(`terminal skip [${title}]: launched recently`);
1139
2626
  return { ok: true, skipped: true };
1140
2627
  }
1141
2628
  launchedAt.set(launchKey, Date.now());
@@ -1153,9 +2640,6 @@ async function openInNewTerminal(opts) {
1153
2640
  title
1154
2641
  );
1155
2642
 
1156
- const envWin = Object.entries(env)
1157
- .map(([key, value]) => `set ${key}=${value}`)
1158
- .join("&& ");
1159
2643
  const envUnix = Object.entries(env)
1160
2644
  .map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
1161
2645
  .join(" && ");
@@ -1167,17 +2651,14 @@ async function openInNewTerminal(opts) {
1167
2651
  const safeTitle =
1168
2652
  String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
1169
2653
  "Maintainer Pro";
1170
- const body = [envWin, `title ${safeTitle}`, command]
1171
- .filter(Boolean)
1172
- .join("&& ");
1173
- const opened = await runLauncher(
1174
- process.env.ComSpec || "cmd.exe",
1175
- ["/d", "/s", "/c", `start "${safeTitle}" /D "${folder}" cmd.exe /k ${body}`],
1176
- { windowsVerbatimArguments: true }
1177
- );
2654
+ const script = writeWinLaunchScript(folder, safeTitle, command, env);
2655
+ log(`terminal script [${title}] ${script}`);
2656
+ const opened = await openWindowsConsole(script, folder);
1178
2657
  if (!opened.ok) {
2658
+ warn(`terminal failed [${title}]: ${opened.error || "unknown"}`);
1179
2659
  return { ok: false, error: friendlyLaunchError(opened.error, title) };
1180
2660
  }
2661
+ log(`terminal opened [${title}]`);
1181
2662
  return { ok: true };
1182
2663
  }
1183
2664
 
@@ -1250,23 +2731,22 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1250
2731
  if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
1251
2732
  reserved.add(preferred);
1252
2733
  clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2734
+ log(`start chat skip ${label}: already up on ${preferred}`);
1253
2735
  return { port: preferred, up: true, launched: false };
1254
2736
  }
1255
2737
 
1256
2738
  if (recentlyLaunched(launchKey)) {
1257
2739
  reserved.add(preferred);
2740
+ log(`start chat skip ${label}: already launching on ${preferred}`);
1258
2741
  return { port: preferred, up: false, launched: false, starting: true };
1259
2742
  }
1260
2743
 
1261
- let port = preferred;
2744
+ let port = Number(opts.port) || preferred;
1262
2745
  try {
1263
- if (reserved.has(preferred)) {
1264
- if (!(await isPortFree(preferred))) {
1265
- reserved.delete(preferred);
1266
- port = await findFreePort(preferred, reserved);
1267
- }
1268
- } else {
2746
+ if (!opts.port) {
1269
2747
  port = await findFreePort(preferred, reserved);
2748
+ } else {
2749
+ reserved.add(port);
1270
2750
  }
1271
2751
  } catch (err) {
1272
2752
  recordProcessProblem({
@@ -1277,23 +2757,32 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1277
2757
  message: `No free port found (tried from ${preferred}). ${
1278
2758
  err instanceof Error ? err.message : String(err)
1279
2759
  }`,
1280
- resolution: "Close other local servers, then use Start chat server.",
2760
+ resolution: "Close other local servers, then use Start Apps.",
1281
2761
  });
1282
2762
  return { port: preferred, up: false, launched: false };
1283
2763
  }
1284
2764
  if (port !== preferred) {
1285
- log(`port ${preferred} busy; using ${port} for ai-server`);
1286
- ws.port = port;
1287
- const envPath = path.join(folder, ".env");
1288
- if (fs.existsSync(envPath)) {
1289
- mergeEnvFile(envPath, {
1290
- PORT: String(port),
1291
- AI_SERVER_URL: `http://localhost:${port}`,
1292
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1293
- });
1294
- }
1295
- persistWorkspaceEntry(cfg, ws);
2765
+ log(`port ${preferred} busy; using ${port} for chat script`);
1296
2766
  }
2767
+ ws.port = port;
2768
+ const localAi = `http://localhost:${port}`;
2769
+ const overrideEnv =
2770
+ opts.env && typeof opts.env === "object" ? opts.env : {};
2771
+ const publicAi =
2772
+ (typeof overrideEnv.AI_SERVER_URL === "string" &&
2773
+ overrideEnv.AI_SERVER_URL.trim()) ||
2774
+ (typeof overrideEnv.NEXT_PUBLIC_AI_SERVER_URL === "string" &&
2775
+ overrideEnv.NEXT_PUBLIC_AI_SERVER_URL.trim()) ||
2776
+ (typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
2777
+ "";
2778
+ const aiUrl = publicAi || localAi;
2779
+ writeProjectEnv(folder, {
2780
+ AI_SERVER_URL: aiUrl,
2781
+ NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
2782
+ VITE_AI_SERVER_URL: aiUrl,
2783
+ ...overrideEnv,
2784
+ });
2785
+ persistWorkspaceEntry(cfg, ws);
1297
2786
 
1298
2787
  const localCli = path.resolve(
1299
2788
  __dirname,
@@ -1315,9 +2804,11 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1315
2804
  folder,
1316
2805
  command: run,
1317
2806
  env: {
1318
- PORT: String(port),
1319
- AI_SERVER_URL: `http://localhost:${port}`,
1320
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
2807
+ AI_SERVER_PORT: String(port),
2808
+ AI_SERVER_URL: aiUrl,
2809
+ NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
2810
+ VITE_AI_SERVER_URL: aiUrl,
2811
+ ...overrideEnv,
1321
2812
  },
1322
2813
  launchKey,
1323
2814
  sandboxId: ws.sandboxId,
@@ -1344,18 +2835,12 @@ function sleep(ms) {
1344
2835
  return new Promise((resolve) => setTimeout(resolve, ms));
1345
2836
  }
1346
2837
 
1347
- /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
1348
- const cloudflareTunnels = new Map();
1349
-
1350
2838
  function stopAllCloudflare() {
1351
2839
  for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1352
2840
  }
1353
2841
 
1354
2842
  function parseTryCloudflareUrl(text) {
1355
- const match = String(text || "").match(
1356
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1357
- );
1358
- return match ? match[0].replace(/\/$/, "") : null;
2843
+ return lastTryCloudflareUrl(text);
1359
2844
  }
1360
2845
 
1361
2846
  function killProcessesByCommand(fragment) {
@@ -1456,22 +2941,38 @@ async function stopWorkspaceApps(ws) {
1456
2941
  await sleep(400);
1457
2942
  }
1458
2943
 
1459
- async function waitUntilReachable(url, timeoutMs, label) {
2944
+ async function waitUntilReachable(url, timeoutMs, label, onWait) {
1460
2945
  const start = Date.now();
2946
+ let last = 0;
1461
2947
  while (Date.now() - start < timeoutMs) {
1462
2948
  if (await probeUrl(url)) return true;
2949
+ const elapsed = Date.now() - start;
2950
+ if (onWait && elapsed - last >= 8_000) {
2951
+ last = elapsed;
2952
+ await onWait(
2953
+ `Still waiting for ${label} (${Math.round(elapsed / 1000)}s)…`
2954
+ );
2955
+ }
1463
2956
  await sleep(600);
1464
2957
  }
1465
2958
  throw new Error(`${label} did not become reachable at ${url}`);
1466
2959
  }
1467
2960
 
1468
- async function waitForUrlInFile(file, timeoutMs = 90_000) {
2961
+ async function waitForUrlInFile(file, timeoutMs = 90_000, onWait) {
1469
2962
  const start = Date.now();
2963
+ let last = 0;
1470
2964
  while (Date.now() - start < timeoutMs) {
1471
2965
  if (fs.existsSync(file)) {
1472
2966
  const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
1473
2967
  if (url) return url;
1474
2968
  }
2969
+ const elapsed = Date.now() - start;
2970
+ if (onWait && elapsed - last >= 8_000) {
2971
+ last = elapsed;
2972
+ await onWait(
2973
+ `Still waiting for a Cloudflare URL (${Math.round(elapsed / 1000)}s)…`
2974
+ );
2975
+ }
1475
2976
  await sleep(500);
1476
2977
  }
1477
2978
  throw new Error(
@@ -1486,7 +2987,7 @@ function cloudflaredCommand(localUrl, logFile) {
1486
2987
  return `${run} 2>&1 | tee ${logArg}`;
1487
2988
  }
1488
2989
 
1489
- async function startCloudflareTerminal(ws, role, localUrl) {
2990
+ async function startCloudflareTerminal(ws, role, localUrl, onWait) {
1490
2991
  const folder = path.resolve(ws.folderPath);
1491
2992
  const logDir = path.join(folder, ".maintainer-pro");
1492
2993
  fs.mkdirSync(logDir, { recursive: true });
@@ -1510,7 +3011,7 @@ async function startCloudflareTerminal(ws, role, localUrl) {
1510
3011
  if (!opened.ok) {
1511
3012
  throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
1512
3013
  }
1513
- const publicUrl = await waitForUrlInFile(logFile);
3014
+ const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
1514
3015
  return { role, localUrl, publicUrl, logFile };
1515
3016
  }
1516
3017
 
@@ -1518,53 +3019,180 @@ function uiPublicEnv(tunnels) {
1518
3019
  /** @type {Record<string, string>} */
1519
3020
  const env = {};
1520
3021
  if (tunnels.ai) {
1521
- env.AI_SERVER_URL = tunnels.ai;
1522
- env.NEXT_PUBLIC_AI_SERVER_URL = tunnels.ai;
1523
- env.VITE_AI_SERVER_URL = tunnels.ai;
3022
+ const ai = String(tunnels.ai).replace(/\/$/, "");
3023
+ env.AI_SERVER_URL = ai;
3024
+ env.NEXT_PUBLIC_AI_SERVER_URL = ai;
3025
+ env.VITE_AI_SERVER_URL = ai;
3026
+ env.REACT_APP_AI_SERVER_URL = ai;
1524
3027
  }
1525
3028
  if (tunnels.backend) {
1526
- env.API_URL = tunnels.backend;
1527
- env.API_BASE_URL = tunnels.backend;
1528
- env.VITE_API_URL = tunnels.backend;
1529
- env.VITE_API_BASE_URL = tunnels.backend;
1530
- env.NEXT_PUBLIC_API_URL = tunnels.backend;
1531
- env.NEXT_PUBLIC_API_BASE_URL = tunnels.backend;
1532
- env.BACKEND_URL = tunnels.backend;
3029
+ const backend = String(tunnels.backend).replace(/\/$/, "");
3030
+ env.API_URL = backend;
3031
+ env.API_BASE_URL = backend;
3032
+ env.VITE_API_URL = backend;
3033
+ env.VITE_API_BASE_URL = backend;
3034
+ env.NEXT_PUBLIC_API_URL = backend;
3035
+ env.NEXT_PUBLIC_API_BASE_URL = backend;
3036
+ env.BACKEND_URL = backend;
1533
3037
  }
1534
3038
  if (tunnels.ui) {
1535
- env.APP_URL = tunnels.ui;
1536
- env.PUBLIC_URL = tunnels.ui;
1537
- env.CORS_ORIGIN = tunnels.ui;
3039
+ const ui = String(tunnels.ui).replace(/\/$/, "");
3040
+ env.APP_URL = ui;
3041
+ env.PUBLIC_URL = ui;
3042
+ env.CORS_ORIGIN = ui;
3043
+ env.NEXT_PUBLIC_APP_URL = ui;
3044
+ env.VITE_APP_URL = ui;
3045
+ env.REACT_APP_APP_URL = ui;
1538
3046
  } else if (tunnels.ai) {
1539
- env.CORS_ORIGIN = tunnels.ai;
3047
+ const ai = String(tunnels.ai).replace(/\/$/, "");
3048
+ env.CORS_ORIGIN = ai;
3049
+ env.APP_URL = ai;
3050
+ env.PUBLIC_URL = ai;
3051
+ env.NEXT_PUBLIC_APP_URL = ai;
3052
+ env.VITE_APP_URL = ai;
1540
3053
  }
1541
3054
  return env;
1542
3055
  }
1543
3056
 
1544
3057
  function writeTunnelEnv(ws, tunnels) {
1545
3058
  const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1546
- if (!folder || !fs.existsSync(folder)) return;
3059
+ if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
1547
3060
  const env = uiPublicEnv(tunnels);
1548
3061
  const lines = Object.entries(tunnels)
1549
3062
  .filter(([, url]) => url)
1550
- .map(([role, url]) => `${role}=${url}`);
3063
+ .map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
1551
3064
  fs.writeFileSync(
1552
3065
  path.join(folder, ".cloudflare-tunnel-url"),
1553
3066
  `${lines.join("\n")}\n`,
1554
3067
  "utf8"
1555
3068
  );
1556
- const envPath = path.join(folder, ".env");
1557
- if (Object.keys(env).length) mergeEnvFile(envPath, env);
1558
- const localEnv = {};
1559
- if (env.NEXT_PUBLIC_AI_SERVER_URL) {
1560
- localEnv.NEXT_PUBLIC_AI_SERVER_URL = env.NEXT_PUBLIC_AI_SERVER_URL;
3069
+ if (Object.keys(env).length) writeProjectEnv(folder, env);
3070
+ return { ok: true, env };
3071
+ }
3072
+
3073
+ /**
3074
+ * Validate tunnels + frontend env before sharing URLs with Maintainer Pro.
3075
+ * @returns {Promise<{ ok: boolean, checks: Array<{ id: string, ok: boolean, detail: string }> }>}
3076
+ */
3077
+ async function validateCloudflareGoLive(ws, tunnels, opts = {}) {
3078
+ const folder = path.resolve(ws.folderPath || "");
3079
+ const progress =
3080
+ typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
3081
+ /** @type {Array<{ id: string, ok: boolean, detail: string }>} */
3082
+ const checks = [];
3083
+ const add = (id, ok, detail) => {
3084
+ checks.push({ id, ok, detail });
3085
+ log(`go-live ${ok ? "ok" : "FAIL"} ${id}: ${detail}`);
3086
+ };
3087
+
3088
+ await progress(
3089
+ "Validating Cloudflare setup before sharing with Maintainer Pro…"
3090
+ );
3091
+
3092
+ writeTunnelEnv(ws, tunnels);
3093
+ const env = readProjectEnvValues(folder);
3094
+
3095
+ if (!tunnels.ai) {
3096
+ add("ai_tunnel", false, "Missing chat-script Cloudflare URL");
3097
+ } else {
3098
+ const ai = String(tunnels.ai).replace(/\/$/, "");
3099
+ const embed = `${ai}/embed-config.js`;
3100
+ const reachable = await probeUrl(embed, 10_000);
3101
+ add(
3102
+ "ai_tunnel",
3103
+ reachable,
3104
+ reachable ? `Reachable ${embed}` : `Unreachable ${embed}`
3105
+ );
3106
+ if (reachable) {
3107
+ const body = await fetchText(embed, 10_000);
3108
+ const advertises =
3109
+ Boolean(body) &&
3110
+ (body.includes(ai) ||
3111
+ body.includes(ai.replace(/^https:\/\//, "")));
3112
+ add(
3113
+ "embed_config",
3114
+ advertises,
3115
+ advertises
3116
+ ? "embed-config.js advertises the public AI URL"
3117
+ : `embed-config.js does not advertise ${ai}`
3118
+ );
3119
+ }
3120
+ }
3121
+
3122
+ if (tunnels.ui) {
3123
+ const ui = String(tunnels.ui).replace(/\/$/, "");
3124
+ const reachable = await probeUrl(ui, 10_000);
3125
+ add(
3126
+ "ui_tunnel",
3127
+ reachable,
3128
+ reachable ? `Reachable ${ui}` : `Unreachable ${ui}`
3129
+ );
3130
+ } else {
3131
+ add(
3132
+ "ui_tunnel",
3133
+ true,
3134
+ "No separate UI tunnel (AI-only / same-origin share)"
3135
+ );
3136
+ }
3137
+
3138
+ const ai = String(tunnels.ai || "").replace(/\/$/, "");
3139
+ if (ai) {
3140
+ const envAi = [
3141
+ env.NEXT_PUBLIC_AI_SERVER_URL,
3142
+ env.VITE_AI_SERVER_URL,
3143
+ env.AI_SERVER_URL,
3144
+ env.REACT_APP_AI_SERVER_URL,
3145
+ ]
3146
+ .map((value) => String(value || "").replace(/\/$/, ""))
3147
+ .filter(Boolean);
3148
+ const envOk = envAi.some((value) => value === ai);
3149
+ add(
3150
+ "frontend_env",
3151
+ envOk,
3152
+ envOk
3153
+ ? `Frontend env has AI URL ${ai}`
3154
+ : `Frontend env missing AI URL (NEXT_PUBLIC=${
3155
+ env.NEXT_PUBLIC_AI_SERVER_URL || "(empty)"
3156
+ }, VITE=${env.VITE_AI_SERVER_URL || "(empty)"}, AI_SERVER_URL=${
3157
+ env.AI_SERVER_URL || "(empty)"
3158
+ })`
3159
+ );
1561
3160
  }
1562
- if (env.NEXT_PUBLIC_API_URL) {
1563
- localEnv.NEXT_PUBLIC_API_URL = env.NEXT_PUBLIC_API_URL;
3161
+
3162
+ if (tunnels.ui) {
3163
+ const ui = String(tunnels.ui).replace(/\/$/, "");
3164
+ const envApp = [
3165
+ env.APP_URL,
3166
+ env.NEXT_PUBLIC_APP_URL,
3167
+ env.PUBLIC_URL,
3168
+ env.VITE_APP_URL,
3169
+ env.CORS_ORIGIN,
3170
+ ]
3171
+ .map((value) => String(value || "").replace(/\/$/, ""))
3172
+ .filter(Boolean);
3173
+ const appOk = envApp.some((value) => value === ui);
3174
+ add(
3175
+ "app_url_env",
3176
+ appOk,
3177
+ appOk
3178
+ ? `App URL env has ${ui}`
3179
+ : `App URL env missing ${ui} (APP_URL=${env.APP_URL || "(empty)"}, NEXT_PUBLIC_APP_URL=${
3180
+ env.NEXT_PUBLIC_APP_URL || "(empty)"
3181
+ })`
3182
+ );
1564
3183
  }
1565
- if (Object.keys(localEnv).length) {
1566
- mergeEnvFile(path.join(folder, ".env.local"), localEnv);
3184
+
3185
+ const ok = checks.every((check) => check.ok);
3186
+ if (ok) {
3187
+ await progress("Validation passed — sharing public URLs with Maintainer Pro.");
3188
+ } else {
3189
+ const failed = checks
3190
+ .filter((check) => !check.ok)
3191
+ .map((check) => `${check.id}: ${check.detail}`)
3192
+ .join(" | ");
3193
+ await progress(`Validation failed — not sharing yet. ${failed}`);
1567
3194
  }
3195
+ return { ok, checks };
1568
3196
  }
1569
3197
 
1570
3198
  function reservedPortsFor(cfg, sandboxId) {
@@ -1581,17 +3209,39 @@ function appsWanted(ws) {
1581
3209
  return Boolean(ws?.appsRequested);
1582
3210
  }
1583
3211
 
1584
- async function configureCloudflareForWorkspace(ws, cfg) {
3212
+ async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
1585
3213
  const sandboxId = ws.sandboxId;
1586
3214
  const label = ws.sandboxName || "this sandbox";
3215
+ const progress = (message) =>
3216
+ reportActionProgress(cfg, opts.actionId, message);
3217
+
3218
+ // If tunnels are already up, attach instead of tearing them down.
3219
+ const attached = await tryAttachExistingCloudflare(ws, cfg, {
3220
+ onProgress: progress,
3221
+ });
3222
+ if (attached) {
3223
+ return {
3224
+ ...attached,
3225
+ pending: false,
3226
+ cloudflarePending: false,
3227
+ waitingForStart: false,
3228
+ warning: null,
3229
+ };
3230
+ }
1587
3231
 
1588
- log(`Cloudflare queued for ${label}: stop apps and wait for Start`);
3232
+ await progress(
3233
+ `Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
3234
+ );
1589
3235
  await stopCloudflare(sandboxId);
3236
+ await progress("Stopping local app terminals and freeing their ports…");
1590
3237
  await stopWorkspaceApps(ws);
1591
3238
  await forgetLaunch(sandboxId);
1592
3239
  ws.cloudflarePending = true;
1593
3240
  ws.appsRequested = false;
1594
3241
  persistWorkspaceEntry(cfg, ws);
3242
+ await progress(
3243
+ "Cloudflare is queued. Use Start Apps to create the public URLs."
3244
+ );
1595
3245
 
1596
3246
  return {
1597
3247
  sandboxId,
@@ -1601,43 +3251,61 @@ async function configureCloudflareForWorkspace(ws, cfg) {
1601
3251
  cloudflarePending: true,
1602
3252
  waitingForStart: true,
1603
3253
  warning:
1604
- "Cloudflare is ready in Maintainer Pro. Use Start chat server when you want to launch the apps and create the public URLs.",
3254
+ "Cloudflare is ready in Maintainer Pro. Use Start Apps when you want to launch the apps and create the public URLs.",
1605
3255
  };
1606
3256
  }
1607
3257
 
1608
- async function launchCloudflareTunnels(ws, cfg) {
3258
+ async function launchCloudflareTunnels(ws, cfg, opts = {}) {
1609
3259
  const sandboxId = ws.sandboxId;
1610
3260
  const label = ws.sandboxName || "this sandbox";
1611
3261
  const folder = path.resolve(ws.folderPath || "");
1612
3262
  const reserved = reservedPortsFor(cfg, sandboxId);
3263
+ const progress = (message) =>
3264
+ reportActionProgress(cfg, opts.actionId, message);
1613
3265
 
1614
- log(`Cloudflare start for ${label}: tunnel chat script/backend before UI`);
3266
+ const attached = await tryAttachExistingCloudflare(ws, cfg, {
3267
+ onProgress: progress,
3268
+ });
3269
+ if (attached) return attached;
3270
+
3271
+ await progress(
3272
+ `Creating Cloudflare tunnels for ${label}. This usually takes 1–2 minutes.`
3273
+ );
1615
3274
  try {
3275
+ const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
1616
3276
  if (!cfg.noAiServer) {
1617
- await startAiServerForWorkspace(ws, { reserved, cfg });
3277
+ await progress(`Starting the chat script on port ${plan.aiPort}…`);
3278
+ await startAiServerForWorkspace(ws, {
3279
+ reserved,
3280
+ cfg,
3281
+ port: plan.aiPort,
3282
+ });
1618
3283
  await waitUntilReachable(
1619
3284
  `http://127.0.0.1:${ws.port}/embed-config.js`,
1620
3285
  45_000,
1621
- "Chat script"
3286
+ "the chat script",
3287
+ progress
1622
3288
  );
1623
3289
  }
1624
3290
 
1625
- const jobs = planHostJobs(folder, null, ws.projectInfo);
1626
- const backendJob = jobs.find((job) => job.role === "backend");
1627
- const uiJob = jobs.find((job) => job.role === "ui" || job.role === "app");
3291
+ const backendJob = plan.jobs.find((job) => job.role === "backend");
3292
+ const uiJob = plan.jobs.find((job) => job.role === "ui" || job.role === "app");
1628
3293
 
1629
3294
  if (backendJob) {
3295
+ await progress(`Starting the backend on port ${backendJob.port}…`);
1630
3296
  await ensureHostProcesses(ws, {
1631
3297
  reserved,
1632
3298
  cfg,
1633
3299
  onlyRoles: ["backend"],
1634
3300
  force: true,
3301
+ plannedJobs: plan.jobs,
1635
3302
  });
1636
- const backendPort = Number(backendJob.preferredPort) || 4100;
3303
+ const backendPort = Number(backendJob.port) || 4100;
1637
3304
  await waitUntilReachable(
1638
3305
  `http://127.0.0.1:${backendPort}`,
1639
3306
  45_000,
1640
- "Backend"
3307
+ "the backend",
3308
+ progress
1641
3309
  );
1642
3310
  }
1643
3311
 
@@ -1647,45 +3315,125 @@ async function launchCloudflareTunnels(ws, cfg) {
1647
3315
  const started = [];
1648
3316
 
1649
3317
  const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
1650
- const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal);
3318
+ await progress(`Opening a Cloudflare tunnel for the chat script (${aiLocal})…`);
3319
+ const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal, progress);
1651
3320
  tunnels.ai = aiTunnel.publicUrl;
1652
3321
  started.push(aiTunnel);
1653
- log(`Cloudflare chat script: ${aiTunnel.publicUrl}`);
3322
+ await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
1654
3323
 
1655
3324
  if (backendJob) {
1656
- const backendLocal = `http://127.0.0.1:${Number(backendJob.preferredPort) || 4100}`;
1657
- const backendTunnel = await startCloudflareTerminal(ws, "backend", backendLocal);
3325
+ const backendLocal = `http://127.0.0.1:${Number(backendJob.port) || 4100}`;
3326
+ await progress(`Opening a Cloudflare tunnel for the backend (${backendLocal})…`);
3327
+ const backendTunnel = await startCloudflareTerminal(
3328
+ ws,
3329
+ "backend",
3330
+ backendLocal,
3331
+ progress
3332
+ );
1658
3333
  tunnels.backend = backendTunnel.publicUrl;
1659
3334
  started.push(backendTunnel);
1660
- log(`Cloudflare backend: ${backendTunnel.publicUrl}`);
3335
+ await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
1661
3336
  }
1662
3337
 
1663
3338
  writeTunnelEnv(ws, tunnels);
1664
- const uiEnv = uiPublicEnv(tunnels);
3339
+ let uiEnv = uiPublicEnv(tunnels);
3340
+ const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
1665
3341
 
1666
3342
  if (uiJob) {
3343
+ await progress(`Starting the app UI on port ${uiPort}…`);
1667
3344
  await ensureHostProcesses(ws, {
1668
3345
  reserved,
1669
3346
  cfg,
1670
3347
  onlyRoles: ["ui", "app"],
1671
3348
  extraEnv: uiEnv,
1672
3349
  force: true,
3350
+ plannedJobs: plan.jobs,
1673
3351
  });
1674
- const uiPort = Number(uiJob.preferredPort) || 5173;
1675
- await waitUntilReachable(`http://127.0.0.1:${uiPort}`, 60_000, "App UI");
3352
+ await waitUntilReachable(
3353
+ `http://127.0.0.1:${uiPort}`,
3354
+ 60_000,
3355
+ "the app UI",
3356
+ progress
3357
+ );
3358
+ await progress(
3359
+ `Opening a Cloudflare tunnel for the app UI (http://127.0.0.1:${uiPort})…`
3360
+ );
1676
3361
  const uiTunnel = await startCloudflareTerminal(
1677
3362
  ws,
1678
3363
  "ui",
1679
- `http://127.0.0.1:${uiPort}`
3364
+ `http://127.0.0.1:${uiPort}`,
3365
+ progress
1680
3366
  );
1681
3367
  tunnels.ui = uiTunnel.publicUrl;
1682
3368
  started.push(uiTunnel);
1683
- log(`Cloudflare UI: ${uiTunnel.publicUrl}`);
3369
+ await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
1684
3370
  writeTunnelEnv(ws, tunnels);
3371
+ uiEnv = uiPublicEnv(tunnels);
3372
+
3373
+ // Restart UI so Vite/Next pick up APP_URL + public AI URL from env files.
3374
+ await progress(
3375
+ "Restarting the app UI so it loads the public base URL from env…"
3376
+ );
3377
+ for (const job of plan.jobs) {
3378
+ if (job.role === "ui" || job.role === "app") {
3379
+ launchedAt.delete(`${sandboxId}:${folder}:${job.script}`);
3380
+ }
3381
+ }
3382
+ await killPort(uiPort);
3383
+ await sleep(1500);
3384
+ await ensureHostProcesses(ws, {
3385
+ reserved,
3386
+ cfg,
3387
+ onlyRoles: ["ui", "app"],
3388
+ extraEnv: uiEnv,
3389
+ force: true,
3390
+ plannedJobs: plan.jobs,
3391
+ });
3392
+ await waitUntilReachable(
3393
+ `http://127.0.0.1:${uiPort}`,
3394
+ 60_000,
3395
+ "the app UI",
3396
+ progress
3397
+ );
3398
+ }
3399
+
3400
+ // Restart chat script with the public AI URL (and CORS/app origins).
3401
+ if (tunnels.ai) {
3402
+ await progress("Restarting the chat script with the public AI URL…");
1685
3403
  launchedAt.delete(`${sandboxId}:${folder}:ai`);
1686
3404
  await killPort(ws.port);
1687
3405
  await sleep(1500);
1688
- await startAiServerForWorkspace(ws, { reserved, cfg });
3406
+ await startAiServerForWorkspace(ws, {
3407
+ reserved,
3408
+ cfg,
3409
+ port: ws.port,
3410
+ env: uiPublicEnv(tunnels),
3411
+ });
3412
+ writeTunnelEnv(ws, tunnels);
3413
+ await waitUntilReachable(
3414
+ `http://127.0.0.1:${ws.port}/embed-config.js`,
3415
+ 45_000,
3416
+ "the chat script",
3417
+ progress
3418
+ );
3419
+ }
3420
+
3421
+ const validation = await validateCloudflareGoLive(ws, tunnels, {
3422
+ onProgress: progress,
3423
+ });
3424
+ if (!validation.ok) {
3425
+ const failed = validation.checks
3426
+ .filter((check) => !check.ok)
3427
+ .map((check) => `${check.id}: ${check.detail}`)
3428
+ .join("\n");
3429
+ ws.cloudflarePending = true;
3430
+ ws.cloudflare = tunnels;
3431
+ ws.cloudflareUrl = null;
3432
+ persistWorkspaceEntry(cfg, ws);
3433
+ rememberCloudflareTunnels(sandboxId, tunnels);
3434
+ throw new Error(
3435
+ `Cloudflare validation failed — public URLs were not shared with Maintainer Pro yet.\n${failed}`
3436
+ );
1689
3437
  }
1690
3438
 
1691
3439
  const appUrl = tunnels.ui || tunnels.ai;
@@ -1695,16 +3443,22 @@ async function launchCloudflareTunnels(ws, cfg) {
1695
3443
  ws.cloudflarePending = false;
1696
3444
  ws.appsRequested = true;
1697
3445
  persistWorkspaceEntry(cfg, ws);
3446
+ rememberCloudflareTunnels(sandboxId, tunnels);
1698
3447
  cloudflareTunnels.set(sandboxId, { tunnels: started });
1699
3448
  clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
3449
+ const host = workspaceHostReport(ws);
3450
+ await progress(`Cloudflare is ready: ${host.appUrl || appUrl}`);
1700
3451
 
1701
3452
  return {
1702
3453
  sandboxId,
1703
3454
  folderPath: ws.folderPath,
1704
3455
  port: ws.port,
1705
- appUrl,
1706
- origins: Object.values(tunnels).filter(Boolean),
3456
+ appUrl: host.appUrl || appUrl,
3457
+ origins: host.origins.length
3458
+ ? host.origins
3459
+ : Object.values(tunnels).filter(Boolean),
1707
3460
  tunnels,
3461
+ validation,
1708
3462
  cloudflare: true,
1709
3463
  reused: false,
1710
3464
  };
@@ -1717,47 +3471,120 @@ async function launchCloudflareTunnels(ws, cfg) {
1717
3471
  title: `Could not start Cloudflare (${label})`,
1718
3472
  message,
1719
3473
  resolution:
1720
- "Install cloudflared or allow npx to download it, then use Start chat server again.",
3474
+ "Install cloudflared or allow npx to download it, then use Start Apps again.",
1721
3475
  actionCode: "start_ai_server",
1722
3476
  });
1723
3477
  throw err;
1724
3478
  }
1725
3479
  }
1726
3480
 
1727
- async function startAppsForWorkspace(ws, cfg) {
3481
+ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3482
+ const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
3483
+ await reportActionProgress(
3484
+ cfg,
3485
+ opts.actionId,
3486
+ `Checking running apps and Cloudflare for ${label}…`
3487
+ );
3488
+
3489
+ // Status + env sync first (never starts Cloudflare here).
3490
+ let status = await reconcileWorkspacePresence(ws, cfg, {
3491
+ writeEnv: true,
3492
+ timeoutMs: 2500,
3493
+ });
3494
+ log(
3495
+ `start apps ${label}: chat=${status.probe.chatUp ? "up" : "down"}@${
3496
+ status.probe.chatPort
3497
+ } hosts=${
3498
+ status.probe.hosts
3499
+ .map((h) => `${h.role}:${h.up ? "up" : "down"}`)
3500
+ .join(",") || "none"
3501
+ } cloudflare=${status.usingCloudflare ? "yes" : "no"}`
3502
+ );
3503
+
3504
+ // Cloudflare tunnels only when Maintainer Pro left a pending Share signal.
1728
3505
  if (ws.cloudflarePending) {
1729
- return launchCloudflareTunnels(ws, cfg);
3506
+ log(`start apps ${label}: Cloudflare pending, launching tunnels`);
3507
+ return launchCloudflareTunnels(ws, cfg, opts);
1730
3508
  }
3509
+
3510
+ await reportActionProgress(
3511
+ cfg,
3512
+ opts.actionId,
3513
+ `Starting local apps for ${label}…`
3514
+ );
1731
3515
  ws.appsRequested = true;
1732
- persistWorkspaceEntry(cfg, ws);
1733
3516
  const reserved = reservedPortsFor(cfg, ws.sandboxId);
3517
+ const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
3518
+
3519
+ // Re-sync env after port assignment (local or cloudflare urls).
3520
+ status = await reconcileWorkspacePresence(ws, cfg, {
3521
+ writeEnv: true,
3522
+ timeoutMs: 800,
3523
+ });
3524
+
3525
+ const cfEnv =
3526
+ status.usingCloudflare && status.cloudflare
3527
+ ? uiPublicEnv(status.cloudflare)
3528
+ : {};
3529
+
1734
3530
  if (!cfg.noAiServer) {
1735
- await startAiServerForWorkspace(ws, { reserved, cfg });
1736
- await sleep(1500);
3531
+ if (status.probe.chatUp) {
3532
+ log(`chat already running on ${status.probe.chatPort} — not restarting`);
3533
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
3534
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
3535
+ ws.port = status.probe.chatPort;
3536
+ } else {
3537
+ await startAiServerForWorkspace(ws, {
3538
+ reserved,
3539
+ cfg,
3540
+ port: plan.aiPort,
3541
+ env: cfEnv,
3542
+ });
3543
+ await sleep(1500);
3544
+ }
1737
3545
  }
3546
+
3547
+ // ensureHostProcesses skips roles that are already answering HTTP.
1738
3548
  const startedHosts = await ensureHostProcesses(ws, {
1739
3549
  reserved,
1740
3550
  cfg,
1741
3551
  force: true,
3552
+ plannedJobs: plan.jobs.map((job) => {
3553
+ const probed = status.probe.hosts.find((h) => h.role === job.role);
3554
+ return probed?.up ? { ...job, up: true, port: probed.port } : job;
3555
+ }),
3556
+ extraEnv: cfEnv,
1742
3557
  });
1743
3558
  await sleep(800);
3559
+
3560
+ status = await reconcileWorkspacePresence(ws, cfg, {
3561
+ writeEnv: true,
3562
+ timeoutMs: 2500,
3563
+ });
1744
3564
  await inspectHostJobs(ws);
1745
- const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
1746
- if (up) {
1747
- clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1748
- clearProcessProblem(ws.sandboxId, "apps_not_started");
1749
- }
3565
+
1750
3566
  const processIssues = issuesForSandbox(ws.sandboxId).map(
1751
3567
  ({ role: _role, ...issue }) => issue
1752
3568
  );
1753
3569
  const warning = processIssues[0]?.message || null;
3570
+ log(
3571
+ `start apps done ${label}: chat=${ws.port} chatUp=${
3572
+ status.aiServerUp
3573
+ } hosts=${startedHosts.join(",") || "none"} cloudflare=${
3574
+ status.usingCloudflare ? "yes" : "no"
3575
+ }${status.host.appUrl ? ` app=${status.host.appUrl}` : ""} origins=${
3576
+ status.host.origins.join(",") || "none"
3577
+ }${warning ? ` warning=${warning}` : ""}`
3578
+ );
1754
3579
  return {
1755
- up,
3580
+ up: status.aiServerUp,
1756
3581
  startedHosts,
1757
3582
  sandboxId: ws.sandboxId,
1758
3583
  folderPath: ws.folderPath,
1759
3584
  port: ws.port,
1760
- appUrl: ws.appUrl,
3585
+ appUrl: status.host.appUrl || ws.appUrl,
3586
+ origins: status.host.origins,
3587
+ cloudflare: status.usingCloudflare,
1761
3588
  processIssues,
1762
3589
  warning,
1763
3590
  };
@@ -1768,11 +3595,13 @@ async function ensureHostProcesses(ws, opts = {}) {
1768
3595
  const cfg = opts.cfg || null;
1769
3596
  const folder = path.resolve(ws.folderPath);
1770
3597
  const scripts = readPackageJson(folder)?.scripts || {};
1771
- const jobs = planHostJobs(
1772
- folder,
1773
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1774
- ws.projectInfo
1775
- );
3598
+ const jobs = Array.isArray(opts.plannedJobs)
3599
+ ? opts.plannedJobs
3600
+ : planHostJobs(
3601
+ folder,
3602
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3603
+ ws.projectInfo
3604
+ );
1776
3605
  const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
1777
3606
  const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
1778
3607
  const started = [];
@@ -1791,14 +3620,26 @@ async function ensureHostProcesses(ws, opts = {}) {
1791
3620
  return started;
1792
3621
  }
1793
3622
 
3623
+ log(
3624
+ `start hosts ${label}: ${
3625
+ jobs.length
3626
+ ? jobs
3627
+ .filter((job) => !onlyRoles || onlyRoles.has(job.role))
3628
+ .map((job) => `${job.role}:${job.script}:${job.port || job.preferredPort}`)
3629
+ .join(" ")
3630
+ : "none"
3631
+ }`
3632
+ );
3633
+
1794
3634
  for (const job of jobs) {
1795
3635
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
1796
- const preferred = Number(job.preferredPort) || 3000;
1797
- const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1798
- "localhost",
1799
- "127.0.0.1"
1800
- );
1801
- if (await probeUrl(probe)) {
3636
+ const preferred = Number(job.port || job.preferredPort) || 3000;
3637
+ const probe = (
3638
+ job.port
3639
+ ? `http://127.0.0.1:${job.port}`
3640
+ : job.probeUrl || `http://127.0.0.1:${preferred}`
3641
+ ).replace("localhost", "127.0.0.1");
3642
+ if (job.up || (await probeUrl(probe))) {
1802
3643
  reserved.add(portFromText(probe, preferred));
1803
3644
  clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1804
3645
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
@@ -1808,12 +3649,17 @@ async function ensureHostProcesses(ws, opts = {}) {
1808
3649
  const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1809
3650
  if (!opts.force && recentlyLaunched(launchKey)) {
1810
3651
  reserved.add(preferred);
3652
+ log(`start ${job.role} skip ${label}: launched recently`);
1811
3653
  continue;
1812
3654
  }
1813
3655
 
1814
- let port = preferred;
3656
+ let port = Number(job.port) || preferred;
1815
3657
  try {
1816
- port = await findFreePort(preferred, reserved);
3658
+ if (!job.port) {
3659
+ port = await findFreePort(preferred, reserved);
3660
+ } else {
3661
+ reserved.add(port);
3662
+ }
1817
3663
  } catch (err) {
1818
3664
  recordProcessProblem({
1819
3665
  sandboxId: ws.sandboxId,
@@ -1823,7 +3669,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1823
3669
  message: `No free port found for "${job.script}" (tried from ${preferred}). ${
1824
3670
  err instanceof Error ? err.message : String(err)
1825
3671
  }`,
1826
- resolution: "Close other local servers, then use Start chat server.",
3672
+ resolution: "Close other local servers, then use Start Apps.",
1827
3673
  });
1828
3674
  continue;
1829
3675
  }
@@ -1845,7 +3691,10 @@ async function ensureHostProcesses(ws, opts = {}) {
1845
3691
  sandboxId: ws.sandboxId,
1846
3692
  force: Boolean(opts.force),
1847
3693
  });
1848
- if (opened.skipped) continue;
3694
+ if (opened.skipped) {
3695
+ log(`start ${job.role} skip ${label}: terminal already opening`);
3696
+ continue;
3697
+ }
1849
3698
  if (!opened.ok) {
1850
3699
  recordProcessProblem({
1851
3700
  sandboxId: ws.sandboxId,
@@ -1859,6 +3708,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1859
3708
  continue;
1860
3709
  }
1861
3710
  started.push(job.role);
3711
+ log(`start ${job.role} launched ${label} on ${port}: ${command}`);
1862
3712
  if (
1863
3713
  (job.role === "ui" || job.role === "app") &&
1864
3714
  (!ws.appUrl || isLocalAppUrl(ws.appUrl))
@@ -1918,7 +3768,7 @@ async function inspectHostJobs(ws) {
1918
3768
  ? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
1919
3769
  : `Nothing is running at ${probe} for "${job.script}".`,
1920
3770
  resolution:
1921
- "Fix the error in that terminal, then use Start chat server to try again.",
3771
+ "Fix the error in that terminal, then use Start Apps to try again.",
1922
3772
  });
1923
3773
  }
1924
3774
  return hosts;
@@ -1929,6 +3779,7 @@ async function setupWorkspace(cfg, action) {
1929
3779
  const sandboxId = String(
1930
3780
  action.sandboxId || action.payload?.sandboxId || ""
1931
3781
  );
3782
+ log(`setup begin sandbox=${shortId(sandboxId)} folder=${folderPath || "(none)"}`);
1932
3783
  const requestedPort = Number(action.payload?.port) || 3100;
1933
3784
  const reserved = new Set();
1934
3785
  for (const other of cfg.workspaces || []) {
@@ -1939,10 +3790,13 @@ async function setupWorkspace(cfg, action) {
1939
3790
  let port = requestedPort;
1940
3791
  if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
1941
3792
  reserved.add(requestedPort);
3793
+ log(`setup chat port ${requestedPort} already up`);
1942
3794
  } else {
1943
3795
  port = await findFreePort(requestedPort, reserved);
1944
3796
  if (port !== requestedPort) {
1945
- log(`port ${requestedPort} busy; using ${port} for ai-server`);
3797
+ log(`setup chat port ${requestedPort} busy; using ${port}`);
3798
+ } else {
3799
+ log(`setup chat port ${port} free`);
1946
3800
  }
1947
3801
  }
1948
3802
  const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
@@ -1983,18 +3837,29 @@ async function setupWorkspace(cfg, action) {
1983
3837
  const appUrl = client.appUrl || corsOrigin;
1984
3838
 
1985
3839
  const envPath = path.join(resolved, ".env");
3840
+ const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3841
+ ? config.aiIgnorePaths
3842
+ : [];
1986
3843
  const envValues = {
1987
3844
  ...config.env,
1988
3845
  AI_CLI_WORKSPACE: ".",
3846
+ AI_CLI_IGNORE_PATHS: JSON.stringify(
3847
+ normalizeIgnorePaths(partnerIgnorePaths)
3848
+ ),
1989
3849
  AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
1990
- PORT: String(port),
3850
+ AI_SERVER_PORT: String(port),
1991
3851
  AI_SERVER_URL: aiOrigin,
1992
3852
  NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
1993
3853
  CORS_ORIGIN: corsOrigin,
1994
3854
  APP_URL: appUrl,
1995
3855
  AI_SERVER_PRODUCT_DESCRIPTION: appName,
1996
3856
  };
1997
- mergeEnvFile(envPath, envValues);
3857
+ mergeEnvFile(envPath, envValues, { remove: ["PORT"] });
3858
+ const envLocal = path.join(resolved, ".env.local");
3859
+ if (fs.existsSync(envLocal)) {
3860
+ mergeEnvFile(envLocal, {}, { remove: ["PORT"] });
3861
+ }
3862
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths);
1998
3863
 
1999
3864
  cfg.workspaces = cfg.workspaces || [];
2000
3865
  const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
@@ -2028,6 +3893,12 @@ async function setupWorkspace(cfg, action) {
2028
3893
  });
2029
3894
 
2030
3895
  await inspectHostJobs(entry);
3896
+ const planned = planHostJobs(
3897
+ resolved,
3898
+ entry.appUrl && isLocalAppUrl(entry.appUrl) ? entry.appUrl : null,
3899
+ entry.projectInfo
3900
+ ).map((job) => ({ ...job, port: job.preferredPort }));
3901
+ writeProjectEnv(resolved, envForWorkspacePorts(entry, planned));
2031
3902
 
2032
3903
  const openUrl = client.sameOrigin
2033
3904
  ? `http://localhost:${entry.port}`
@@ -2043,22 +3914,25 @@ async function setupWorkspace(cfg, action) {
2043
3914
  );
2044
3915
  const waitingForStart = !aiServerUp;
2045
3916
  const warning = waitingForStart
2046
- ? "Folder is attached in Maintainer Pro. Use Start chat server when you want to launch the apps."
3917
+ ? "Folder is attached in Maintainer Pro. Use Start Apps when you want to launch the apps."
2047
3918
  : processIssues[0]?.message || null;
2048
3919
 
2049
- for (const note of client.notes) log(note);
2050
- if (waitingForStart) {
2051
- log(`folder attached — waiting for Start (${openUrl})`);
2052
- } else {
2053
- log(`ai-server already up — open ${openUrl}`);
2054
- }
3920
+ for (const note of client.notes) log(`setup note ${note}`);
3921
+ log(
3922
+ waitingForStart
3923
+ ? `setup done — waiting for Start (${openUrl}) kind=${client.kind}`
3924
+ : `setup done — chat already up (${openUrl}) kind=${client.kind}`
3925
+ );
2055
3926
 
3927
+ const host = workspaceHostReport(entry);
2056
3928
  return {
2057
3929
  sandboxId,
2058
3930
  folderPath: resolved,
2059
3931
  port: entry.port,
2060
- appUrl: entry.appUrl || appUrl,
2061
- origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
3932
+ appUrl: host.appUrl || entry.appUrl || appUrl,
3933
+ origins: host.origins.length
3934
+ ? host.origins
3935
+ : [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
2062
3936
  wroteEnv: true,
2063
3937
  clientKind: client.kind,
2064
3938
  clientFiles: client.filesWritten,
@@ -2070,26 +3944,64 @@ async function setupWorkspace(cfg, action) {
2070
3944
  warning,
2071
3945
  waitingForStart,
2072
3946
  projectInfo,
3947
+ ignorePaths: access.ignorePaths,
2073
3948
  };
2074
3949
  }
2075
3950
 
2076
3951
  async function runActions(cfg, actions) {
3952
+ if (!actions.length) return;
3953
+ log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
2077
3954
  for (const action of actions) {
2078
- log(`action ${action.code} (${action.id})`);
3955
+ const startedAt = Date.now();
3956
+ const label = actionLabel(action);
3957
+ log(`${label} start`);
2079
3958
  let ok = true;
2080
3959
  /** @type {Record<string, unknown>} */
2081
3960
  let result = {};
2082
3961
  try {
2083
3962
  if (action.code === "browse") {
2084
3963
  const p = String(action.payload?.path || process.cwd());
3964
+ log(`${label} browse ${p}`);
2085
3965
  result = listDirEntries(p);
3966
+ log(`${label} browse ${result.entries?.length ?? 0} entries`);
2086
3967
  } else if (action.code === "setup_workspace") {
2087
3968
  result = await setupWorkspace(cfg, action);
3969
+ } else if (action.code === "sync_access_policy") {
3970
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
3971
+ const ws =
3972
+ (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
3973
+ (action.payload?.folderPath
3974
+ ? {
3975
+ sandboxId,
3976
+ folderPath: String(action.payload.folderPath),
3977
+ }
3978
+ : null);
3979
+ if (!ws?.folderPath) {
3980
+ ok = false;
3981
+ result = { error: "No workspace folder for access policy sync" };
3982
+ warn(`${label} skipped: ${result.error}`);
3983
+ } else {
3984
+ const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
3985
+ ? action.payload.aiIgnorePaths
3986
+ : [];
3987
+ const access = applyAccessPolicy(ws.folderPath, partnerIgnorePaths);
3988
+ log(
3989
+ `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
3990
+ );
3991
+ result = {
3992
+ folderPath: path.resolve(ws.folderPath),
3993
+ ignorePaths: access.ignorePaths,
3994
+ syncedAt: new Date().toISOString(),
3995
+ };
3996
+ }
2088
3997
  } else if (action.code === "recheck") {
2089
3998
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2090
3999
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4000
+ if (!ws) {
4001
+ log(`${label} recheck: no local workspace`);
4002
+ }
2091
4003
  const projectInfo = ws
2092
- ? await inspectProjectWithAiCli(ws, { cfg })
4004
+ ? await inspectProjectWithAiCli(ws, { cfg, force: true })
2093
4005
  : null;
2094
4006
  result = {
2095
4007
  recheckedAt: new Date().toISOString(),
@@ -2109,18 +4021,57 @@ async function runActions(cfg, actions) {
2109
4021
  if (!ws || cfg.noAiServer) {
2110
4022
  ok = false;
2111
4023
  result = { error: "No workspace or --no-ai-server" };
4024
+ warn(`${label} skipped: ${result.error}`);
2112
4025
  } else {
2113
- const problem = issuesForSandbox(ws.sandboxId)
2114
- .map((issue) => issue.message)
2115
- .join("\n");
2116
- const projectInfo = await inspectProjectWithAiCli(ws, {
2117
- cfg,
2118
- problem:
2119
- problem ||
2120
- "Local processes are not running or the project setup looks incomplete.",
4026
+ // Prefer cached project inspect; only call ai-cli when missing or
4027
+ // after a failed start that used the cache.
4028
+ log(`${label} start ${ws.folderPath}`);
4029
+ let projectInfo = await inspectProjectWithAiCli(ws, { cfg });
4030
+ let started = await startAppsForWorkspace(ws, cfg, {
4031
+ actionId: action.id,
2121
4032
  });
4033
+ const launchFailed =
4034
+ Array.isArray(started.processIssues) &&
4035
+ started.processIssues.some(
4036
+ (issue) =>
4037
+ issue.code === "ai_server_launch" ||
4038
+ issue.code === "host_process_launch" ||
4039
+ issue.code === "host_process_down" ||
4040
+ issue.code === "project_issue"
4041
+ );
4042
+ const nothingUp =
4043
+ !started.up &&
4044
+ !(
4045
+ Array.isArray(started.startedHosts) &&
4046
+ started.startedHosts.length
4047
+ );
4048
+ if ((launchFailed || nothingUp) && projectInfo?.cached) {
4049
+ const problem = [
4050
+ ...issuesForSandbox(ws.sandboxId).map((issue) => issue.message),
4051
+ started.warning,
4052
+ "Local processes did not come up using the cached project setup.",
4053
+ ]
4054
+ .filter(Boolean)
4055
+ .join("\n");
4056
+ log(
4057
+ `${label} start incomplete — re-inspecting project with ai-cli`
4058
+ );
4059
+ await reportActionProgress(
4060
+ cfg,
4061
+ action.id,
4062
+ "Start did not fully succeed — re-analyzing the project…"
4063
+ );
4064
+ projectInfo = await inspectProjectWithAiCli(ws, {
4065
+ cfg,
4066
+ force: true,
4067
+ problem,
4068
+ });
4069
+ started = await startAppsForWorkspace(ws, cfg, {
4070
+ actionId: action.id,
4071
+ });
4072
+ }
2122
4073
  result = {
2123
- ...(await startAppsForWorkspace(ws, cfg)),
4074
+ ...started,
2124
4075
  projectInfo,
2125
4076
  };
2126
4077
  if (
@@ -2134,14 +4085,23 @@ async function runActions(cfg, actions) {
2134
4085
  } else if (action.code === "refresh_public_url") {
2135
4086
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2136
4087
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4088
+ const host = ws ? workspaceHostReport(ws) : { appUrl: null, origins: [] };
2137
4089
  result = {
4090
+ sandboxId: sandboxId || ws?.sandboxId || null,
2138
4091
  appUrl:
4092
+ host.appUrl ||
2139
4093
  ws?.cloudflareUrl ||
2140
4094
  ws?.appUrl ||
2141
4095
  process.env.APP_URL ||
2142
4096
  process.env.PUBLIC_URL ||
2143
4097
  null,
4098
+ origins: host.origins,
2144
4099
  };
4100
+ log(
4101
+ `${label} public url ${result.appUrl || "(none)"} origins=${
4102
+ host.origins.join(",") || "none"
4103
+ }`
4104
+ );
2145
4105
  } else if (action.code === "configure_cloudflare") {
2146
4106
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2147
4107
  const ws =
@@ -2161,8 +4121,11 @@ async function runActions(cfg, actions) {
2161
4121
  if (!ws) {
2162
4122
  ok = false;
2163
4123
  result = { error: "No folder is attached for this sandbox" };
4124
+ warn(`${label} skipped: ${result.error}`);
2164
4125
  } else {
2165
- result = await configureCloudflareForWorkspace(ws, cfg);
4126
+ result = await configureCloudflareForWorkspace(ws, cfg, {
4127
+ actionId: action.id,
4128
+ });
2166
4129
  }
2167
4130
  } else if (action.code === "remove_workspace") {
2168
4131
  const sandboxId = String(
@@ -2174,15 +4137,26 @@ async function runActions(cfg, actions) {
2174
4137
  );
2175
4138
  saveConfig(cfg);
2176
4139
  result = { removedSandboxId: sandboxId };
4140
+ log(`${label} removed workspace`);
2177
4141
  } else {
2178
4142
  ok = false;
2179
4143
  result = { error: `Unknown action ${action.code}` };
4144
+ warn(`${label} skipped: ${result.error}`);
2180
4145
  }
2181
4146
  } catch (err) {
2182
4147
  ok = false;
2183
4148
  result = { error: err instanceof Error ? err.message : String(err) };
4149
+ warn(`${label} threw: ${result.error}`);
2184
4150
  }
2185
4151
 
4152
+ const elapsed = Date.now() - startedAt;
4153
+ const summary = resultSummary(result);
4154
+ log(
4155
+ `${label} ${ok ? "ok" : "failed"} ${elapsed}ms${
4156
+ summary ? ` ${summary}` : ""
4157
+ }`
4158
+ );
4159
+
2186
4160
  try {
2187
4161
  await api(
2188
4162
  cfg.adminUrl,
@@ -2191,9 +4165,10 @@ async function runActions(cfg, actions) {
2191
4165
  `/api/v1/bridge/machine/actions/${action.id}/complete`,
2192
4166
  { ok, result }
2193
4167
  );
4168
+ log(`${label} reported to admin`);
2194
4169
  } catch (err) {
2195
4170
  warn(
2196
- `failed to complete action: ${
4171
+ `${label} report failed: ${
2197
4172
  err instanceof Error ? err.message : String(err)
2198
4173
  }`
2199
4174
  );
@@ -2202,50 +4177,155 @@ async function runActions(cfg, actions) {
2202
4177
  }
2203
4178
 
2204
4179
  async function collectWorkspaceStates(cfg) {
2205
- /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
4180
+ /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null,origins?:string[]}>} */
2206
4181
  const localStates = [];
2207
4182
  for (const ws of cfg.workspaces || []) {
2208
4183
  const folder = path.resolve(ws.folderPath || "");
2209
- const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
2210
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2211
- await inspectHostJobs(ws);
4184
+ const status = await reconcileWorkspacePresence(ws, cfg, {
4185
+ writeEnv: true,
4186
+ timeoutMs: 800,
4187
+ });
2212
4188
  localStates.push({
2213
4189
  sandboxId: ws.sandboxId,
2214
4190
  sandboxName: ws.sandboxName,
2215
- port: ws.port,
4191
+ port: status.probe.chatPort || ws.port,
2216
4192
  folderPath: ws.folderPath,
2217
- aiServerUp: up,
4193
+ aiServerUp: status.aiServerUp,
4194
+ appsRunning: status.appsRunning,
2218
4195
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2219
- appUrl: ws.appUrl || null,
2220
- appsRequested: appsWanted(ws),
4196
+ appUrl:
4197
+ status.appsRunning || status.usingCloudflare
4198
+ ? status.host.appUrl || ws.appUrl || null
4199
+ : null,
4200
+ origins:
4201
+ status.appsRunning || status.usingCloudflare
4202
+ ? status.host.origins
4203
+ : [],
4204
+ appsRequested: appsWanted(ws) || status.appsRunning,
2221
4205
  });
2222
4206
  }
2223
4207
  return localStates;
2224
4208
  }
2225
4209
 
4210
+ /** @type {Map<string, string>} */
4211
+ const lastHostReports = new Map();
4212
+
4213
+ function syncAssignedWorkspaces(cfg, remotes) {
4214
+ if (!Array.isArray(remotes)) return;
4215
+ for (const remote of remotes) {
4216
+ const local = (cfg.workspaces || []).find(
4217
+ (w) => w.sandboxId === remote.sandboxId
4218
+ );
4219
+ if (!local) {
4220
+ cfg.workspaces = cfg.workspaces || [];
4221
+ cfg.workspaces.push({
4222
+ sandboxId: remote.sandboxId,
4223
+ folderPath: remote.folderPath,
4224
+ port: remote.port,
4225
+ sandboxName: remote.sandboxName,
4226
+ applicationName: remote.applicationName,
4227
+ });
4228
+ saveConfig(cfg);
4229
+ } else if (local.folderPath !== remote.folderPath) {
4230
+ local.folderPath = remote.folderPath;
4231
+ local.port = remote.port;
4232
+ saveConfig(cfg);
4233
+ }
4234
+ }
4235
+ }
4236
+
2226
4237
  async function sendHeartbeat(cfg, folders, localStates) {
4238
+ for (const st of localStates) {
4239
+ if (!st.appsRunning) continue;
4240
+ const key = `${st.appUrl || ""}|${(st.origins || []).join(",")}`;
4241
+ if (lastHostReports.get(st.sandboxId) === key) continue;
4242
+ lastHostReports.set(st.sandboxId, key);
4243
+ log(
4244
+ `host report sandbox=${shortId(st.sandboxId)} app=${
4245
+ st.appUrl || "(none)"
4246
+ } origins=${(st.origins || []).join(",") || "none"}`
4247
+ );
4248
+ }
4249
+ const body = {
4250
+ hostname: os.hostname(),
4251
+ platform: `${os.platform()}-${os.arch()}`,
4252
+ bridgeVersion: PACKAGE_VERSION,
4253
+ folders,
4254
+ issues: await buildIssues(cfg, localStates),
4255
+ workspaces: localStates.map((st) => ({
4256
+ sandboxId: st.sandboxId,
4257
+ aiServerUp: st.aiServerUp,
4258
+ appsRunning: Boolean(st.appsRunning),
4259
+ port: st.port,
4260
+ appUrl: st.appsRunning && st.appUrl ? st.appUrl : undefined,
4261
+ origins:
4262
+ st.appsRunning && Array.isArray(st.origins) && st.origins.length
4263
+ ? st.origins
4264
+ : undefined,
4265
+ appsRequested: Boolean(st.appsRequested),
4266
+ })),
4267
+ };
2227
4268
  return api(
2228
4269
  cfg.adminUrl,
2229
4270
  cfg.token,
2230
4271
  "POST",
2231
4272
  "/api/v1/bridge/machine/heartbeat",
2232
- {
2233
- hostname: os.hostname(),
2234
- platform: `${os.platform()}-${os.arch()}`,
2235
- bridgeVersion: PACKAGE_VERSION,
2236
- folders,
2237
- issues: await buildIssues(cfg, localStates),
2238
- workspaces: localStates.map((st) => ({
2239
- sandboxId: st.sandboxId,
2240
- aiServerUp: st.aiServerUp,
2241
- port: st.port,
2242
- appUrl: st.appUrl || undefined,
2243
- appsRequested: Boolean(st.appsRequested),
2244
- })),
2245
- }
4273
+ body
2246
4274
  );
2247
4275
  }
2248
4276
 
4277
+ function buildLightHeartbeatPayload(cfg, folders) {
4278
+ return {
4279
+ type: "heartbeat",
4280
+ hostname: os.hostname(),
4281
+ platform: `${os.platform()}-${os.arch()}`,
4282
+ bridgeVersion: PACKAGE_VERSION,
4283
+ folders,
4284
+ };
4285
+ }
4286
+
4287
+ async function buildHeartbeatPayload(cfg, folders, localStates) {
4288
+ for (const st of localStates) {
4289
+ if (!st.appsRunning) continue;
4290
+ const key = `${st.appUrl || ""}|${(st.origins || []).join(",")}`;
4291
+ if (lastHostReports.get(st.sandboxId) === key) continue;
4292
+ lastHostReports.set(st.sandboxId, key);
4293
+ log(
4294
+ `host report sandbox=${shortId(st.sandboxId)} app=${
4295
+ st.appUrl || "(none)"
4296
+ } origins=${(st.origins || []).join(",") || "none"}`
4297
+ );
4298
+ }
4299
+ return {
4300
+ type: "heartbeat",
4301
+ hostname: os.hostname(),
4302
+ platform: `${os.platform()}-${os.arch()}`,
4303
+ bridgeVersion: PACKAGE_VERSION,
4304
+ folders,
4305
+ issues: await buildIssues(cfg, localStates),
4306
+ workspaces: localStates.map((st) => ({
4307
+ sandboxId: st.sandboxId,
4308
+ aiServerUp: st.aiServerUp,
4309
+ appsRunning: Boolean(st.appsRunning),
4310
+ port: st.port,
4311
+ appUrl: st.appsRunning && st.appUrl ? st.appUrl : undefined,
4312
+ origins:
4313
+ st.appsRunning && Array.isArray(st.origins) && st.origins.length
4314
+ ? st.origins
4315
+ : undefined,
4316
+ appsRequested: Boolean(st.appsRequested),
4317
+ })),
4318
+ };
4319
+ }
4320
+
4321
+ function adminWsUrl(adminUrl, token) {
4322
+ const base = String(adminUrl || "").replace(/\/$/, "");
4323
+ const u = new URL(`${base}/api/v1/ws`);
4324
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
4325
+ u.searchParams.set("token", token);
4326
+ return u.toString();
4327
+ }
4328
+
2249
4329
  async function buildIssues(cfg, workspaceStates) {
2250
4330
  /** @type {Array<Record<string, unknown>>} */
2251
4331
  const issues = [];
@@ -2263,15 +4343,15 @@ async function buildIssues(cfg, workspaceStates) {
2263
4343
  });
2264
4344
  }
2265
4345
  for (const st of workspaceStates) {
2266
- if (st.aiServerUp || st.startingAi) continue;
4346
+ if (st.aiServerUp || st.appsRunning || st.startingAi) continue;
2267
4347
  if (!st.appsRequested) {
2268
4348
  issues.push({
2269
4349
  code: "apps_not_started",
2270
4350
  severity: "info",
2271
4351
  title: `Apps are not running (${st.sandboxName || "sandbox"})`,
2272
4352
  message:
2273
- "This folder is attached in Maintainer Pro. Use Start chat server when you want to launch the local apps.",
2274
- resolution: "Use Start chat server.",
4353
+ "This folder is attached in Maintainer Pro. Use Start Apps when you want to launch the local apps.",
4354
+ resolution: "Use Start Apps.",
2275
4355
  actionCode: "start_ai_server",
2276
4356
  sandboxId: st.sandboxId,
2277
4357
  });
@@ -2292,7 +4372,7 @@ async function buildIssues(cfg, workspaceStates) {
2292
4372
  `Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
2293
4373
  resolution:
2294
4374
  launch?.resolution ||
2295
- "Read the error in that terminal, then use Start chat server.",
4375
+ "Read the error in that terminal, then use Start Apps.",
2296
4376
  actionCode: "start_ai_server",
2297
4377
  sandboxId: st.sandboxId,
2298
4378
  });
@@ -2369,12 +4449,22 @@ async function pairFlow(args) {
2369
4449
  }
2370
4450
 
2371
4451
  async function main() {
4452
+ if (!process.env.NODE_ENV?.trim()) {
4453
+ process.env.NODE_ENV = "development";
4454
+ }
4455
+ logger = createLogger("ai-bridge");
4456
+
2372
4457
  const args = parseArgs(process.argv.slice(2));
2373
4458
  if (args.help) {
2374
4459
  printHelp();
2375
4460
  process.exit(0);
2376
4461
  }
2377
4462
 
4463
+ logger.debug(
4464
+ { logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
4465
+ "bridge starting"
4466
+ );
4467
+
2378
4468
  let cfg = loadConfig() || {};
2379
4469
  ensureMachineId(cfg);
2380
4470
 
@@ -2398,113 +4488,307 @@ async function main() {
2398
4488
  log(`machine ${cfg.machineId}`);
2399
4489
  log(`admin ${cfg.adminUrl}`);
2400
4490
  log(`config ${configPath()}`);
4491
+ log(
4492
+ `online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
4493
+ );
4494
+ await restoreHostsAfterReconnect(cfg);
2401
4495
 
2402
- const tick = async () => {
2403
- const remoteWorkspaces = [];
4496
+ /** @type {unknown[]} */
4497
+ const claimedActions = [];
4498
+ let workBusy = false;
4499
+
4500
+ const runWork = async () => {
4501
+ if (workBusy) return;
4502
+ workBusy = true;
2404
4503
  try {
2405
- // Heartbeat first — server returns assigned workspaces
2406
- const folders = collectOfferedFolders(cfg);
2407
- /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
2408
- const localStates = [];
2409
-
2410
- const reserved = new Set();
2411
- for (const ws of cfg.workspaces || []) {
2412
- const folder = path.resolve(ws.folderPath || "");
2413
- const up = await probeUrl(
2414
- `http://127.0.0.1:${ws.port}/embed-config.js`
2415
- );
2416
- if (appsWanted(ws) && !cfg.noAiServer && !up) {
2417
- await startAiServerForWorkspace(ws, { reserved, cfg });
2418
- } else if (ws.port) {
2419
- reserved.add(Number(ws.port));
2420
- if (up) {
2421
- clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2422
- clearProcessProblem(ws.sandboxId, "apps_not_started");
2423
- }
2424
- }
2425
- if (appsWanted(ws)) {
2426
- await ensureHostProcesses(ws, { reserved, cfg });
2427
- }
2428
- await inspectHostJobs(ws);
2429
- localStates.push({
2430
- sandboxId: ws.sandboxId,
2431
- sandboxName: ws.sandboxName,
2432
- port: ws.port,
2433
- folderPath: ws.folderPath,
2434
- aiServerUp: up,
2435
- startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2436
- appUrl: ws.appUrl || null,
2437
- appsRequested: appsWanted(ws),
2438
- });
4504
+ while (claimedActions.length) {
4505
+ const batch = claimedActions.splice(0, claimedActions.length);
4506
+ await runActions(cfg, batch);
2439
4507
  }
4508
+ } catch (err) {
4509
+ warn(err instanceof Error ? err.message : String(err));
4510
+ } finally {
4511
+ workBusy = false;
4512
+ if (claimedActions.length) void runWork();
4513
+ }
4514
+ };
2440
4515
 
2441
- const hb = await sendHeartbeat(cfg, folders, localStates);
4516
+ const queueActions = (actions) => {
4517
+ if (!Array.isArray(actions) || actions.length === 0) return;
4518
+ claimedActions.push(...actions);
4519
+ void runWork();
4520
+ };
2442
4521
 
2443
- // Sync local workspace list from server assignments
2444
- if (Array.isArray(hb.workspaces)) {
2445
- for (const remote of hb.workspaces) {
2446
- remoteWorkspaces.push(remote);
2447
- const local = (cfg.workspaces || []).find(
2448
- (w) => w.sandboxId === remote.sandboxId
2449
- );
2450
- if (!local) {
2451
- cfg.workspaces = cfg.workspaces || [];
2452
- cfg.workspaces.push({
2453
- sandboxId: remote.sandboxId,
2454
- folderPath: remote.folderPath,
2455
- port: remote.port,
2456
- sandboxName: remote.sandboxName,
2457
- applicationName: remote.applicationName,
2458
- });
2459
- saveConfig(cfg);
2460
- } else if (local.folderPath !== remote.folderPath) {
2461
- local.folderPath = remote.folderPath;
2462
- local.port = remote.port;
2463
- saveConfig(cfg);
2464
- }
2465
- }
2466
- }
4522
+ /** @type {import('ws').WebSocket | null} */
4523
+ let socket = null;
4524
+ let heartbeatTimer = null;
4525
+ let pingTimer = null;
4526
+ let reconnectTimer = null;
4527
+ let wsGeneration = 0;
4528
+ let reconnectAttempt = 0;
4529
+ let stopped = false;
4530
+ let presenceBusy = false;
4531
+
4532
+ const sendJson = (payload) => {
4533
+ if (!socket || socket.readyState !== 1) return false;
4534
+ try {
4535
+ socket.send(JSON.stringify(payload));
4536
+ return true;
4537
+ } catch {
4538
+ return false;
4539
+ }
4540
+ };
2467
4541
 
2468
- if (Array.isArray(hb.actions) && hb.actions.length > 0) {
2469
- await runActions(cfg, hb.actions);
2470
- await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
2471
- }
4542
+ const clearHeartbeatTimer = () => {
4543
+ if (heartbeatTimer) {
4544
+ clearInterval(heartbeatTimer);
4545
+ heartbeatTimer = null;
4546
+ }
4547
+ };
4548
+
4549
+ const clearPingTimer = () => {
4550
+ if (pingTimer) {
4551
+ clearInterval(pingTimer);
4552
+ pingTimer = null;
4553
+ }
4554
+ };
4555
+
4556
+ const clearReconnectTimer = () => {
4557
+ if (reconnectTimer) {
4558
+ clearTimeout(reconnectTimer);
4559
+ reconnectTimer = null;
4560
+ }
4561
+ };
4562
+
4563
+ const sendPing = () => {
4564
+ sendJson({ type: "ping" });
4565
+ sendLightPresence();
4566
+ };
4567
+
4568
+ const sendLightPresence = () => {
4569
+ const folders = collectOfferedFolders(cfg);
4570
+ sendJson(buildLightHeartbeatPayload(cfg, folders));
4571
+ };
4572
+
4573
+ const sendPresenceOverWs = async () => {
4574
+ if (!socket || socket.readyState !== 1 || presenceBusy) return;
4575
+ presenceBusy = true;
4576
+ try {
4577
+ const folders = collectOfferedFolders(cfg);
4578
+ const localStates = await collectWorkspaceStates(cfg);
4579
+ const payload = await buildHeartbeatPayload(cfg, folders, localStates);
4580
+ sendJson(payload);
2472
4581
  } catch (err) {
2473
4582
  warn(err instanceof Error ? err.message : String(err));
4583
+ } finally {
4584
+ presenceBusy = false;
2474
4585
  }
2475
4586
  };
2476
4587
 
2477
- let cycleBusy = false;
2478
- const runLocked = async (fn) => {
2479
- if (cycleBusy) return;
2480
- cycleBusy = true;
4588
+ const handleChatRun = async (msg) => {
4589
+ const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
4590
+ const conversationId =
4591
+ typeof msg.conversationId === "string" ? msg.conversationId : "";
4592
+ const userMessageId =
4593
+ typeof msg.userMessageId === "string" ? msg.userMessageId : "";
4594
+ const content = typeof msg.content === "string" ? msg.content : "";
4595
+ const messages = Array.isArray(msg.messages)
4596
+ ? msg.messages.filter(
4597
+ (row) =>
4598
+ row &&
4599
+ (row.role === "user" || row.role === "assistant") &&
4600
+ typeof row.content === "string"
4601
+ )
4602
+ : [];
4603
+ const payloadMessages = messages.length
4604
+ ? messages
4605
+ : content
4606
+ ? [{ role: "user", content }]
4607
+ : [];
4608
+ const fail = (error) => {
4609
+ warn(`chat.run: ${error}`);
4610
+ sendJson({
4611
+ type: "chat.run.result",
4612
+ ok: false,
4613
+ sandboxId,
4614
+ conversationId,
4615
+ userMessageId,
4616
+ error,
4617
+ });
4618
+ };
4619
+ if (!conversationId || payloadMessages.length === 0) {
4620
+ fail("missing conversation or messages");
4621
+ return;
4622
+ }
4623
+ const ws = (cfg.workspaces || []).find(
4624
+ (row) => row.sandboxId === sandboxId
4625
+ );
4626
+ if (!ws) {
4627
+ fail(`no local workspace for sandbox ${sandboxId || "?"}`);
4628
+ return;
4629
+ }
4630
+ const chat = await discoverChatPort(ws, 2500);
4631
+ if (chat.up) ws.port = chat.port;
4632
+ const port = Number(chat.port || ws.port) || 3100;
4633
+ const url = `http://127.0.0.1:${port}/api/chat`;
4634
+ log(`chat.run → ${url} (${conversationId})`);
2481
4635
  try {
2482
- await fn();
2483
- } finally {
2484
- cycleBusy = false;
4636
+ const res = await fetch(url, {
4637
+ method: "POST",
4638
+ headers: {
4639
+ "content-type": "application/json",
4640
+ accept: "application/json",
4641
+ },
4642
+ body: JSON.stringify({
4643
+ conversationId,
4644
+ messages: payloadMessages,
4645
+ userMessage: content || undefined,
4646
+ skipPersistUser: true,
4647
+ userMessageId: userMessageId || undefined,
4648
+ senderType: msg.senderType === "client" ? "client" : undefined,
4649
+ senderName:
4650
+ typeof msg.senderName === "string" ? msg.senderName : undefined,
4651
+ }),
4652
+ });
4653
+ const text = await res.text();
4654
+ let data = null;
4655
+ try {
4656
+ data = text ? JSON.parse(text) : null;
4657
+ } catch {
4658
+ data = { raw: text };
4659
+ }
4660
+ if (!res.ok) {
4661
+ fail(data?.error || `AI server ${res.status}`);
4662
+ return;
4663
+ }
4664
+ log(`chat.run ok (${conversationId})`);
4665
+ sendJson({
4666
+ type: "chat.run.result",
4667
+ ok: true,
4668
+ sandboxId,
4669
+ conversationId,
4670
+ userMessageId,
4671
+ });
4672
+ } catch (err) {
4673
+ fail(err instanceof Error ? err.message : String(err));
2485
4674
  }
2486
4675
  };
2487
4676
 
2488
- await runLocked(tick);
2489
- setInterval(() => {
2490
- void runLocked(tick);
2491
- }, HEARTBEAT_MS);
2492
- setInterval(() => {
2493
- void runLocked(async () => {
2494
- const pending = await api(
2495
- cfg.adminUrl,
2496
- cfg.token,
2497
- "GET",
2498
- "/api/v1/bridge/machine/actions"
2499
- );
2500
- if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
2501
- await runActions(cfg, pending.actions);
4677
+ const handleWsMessage = (raw) => {
4678
+ let msg;
4679
+ try {
4680
+ msg = JSON.parse(String(raw));
4681
+ } catch {
4682
+ return;
4683
+ }
4684
+ if (!msg || typeof msg !== "object") return;
4685
+ if (msg.type === "pong" || msg.type === "hello") return;
4686
+ if (msg.type === "actions") {
4687
+ queueActions(msg.actions);
4688
+ return;
4689
+ }
4690
+ if (msg.type === "heartbeat.ok") {
4691
+ syncAssignedWorkspaces(cfg, msg.workspaces);
4692
+ queueActions(msg.actions);
4693
+ return;
4694
+ }
4695
+ if (msg.type === "chat.run") {
4696
+ void handleChatRun(msg);
4697
+ return;
4698
+ }
4699
+ if (msg.type === "error") {
4700
+ warn(`ws: ${msg.error || "error"}`);
4701
+ }
4702
+ };
4703
+
4704
+ const scheduleReconnect = (code, reason) => {
4705
+ if (stopped || reconnectTimer) return;
4706
+ const delay = Math.min(
4707
+ WS_RECONNECT_MAX_MS,
4708
+ WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 4)
4709
+ );
4710
+ reconnectAttempt += 1;
4711
+ const detail = reason ? ` ${reason}` : "";
4712
+ const wait =
4713
+ delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
4714
+ warn(
4715
+ `websocket closed (${code || "?"}${detail}); reconnecting in ${wait}`
4716
+ );
4717
+ reconnectTimer = setTimeout(() => {
4718
+ reconnectTimer = null;
4719
+ connectWs();
4720
+ }, delay);
4721
+ };
4722
+
4723
+ const connectWs = () => {
4724
+ if (stopped) return;
4725
+ clearReconnectTimer();
4726
+ const generation = ++wsGeneration;
4727
+ if (socket) {
4728
+ try {
4729
+ socket.close();
4730
+ } catch {
4731
+ /* ignore */
2502
4732
  }
4733
+ socket = null;
4734
+ }
4735
+ const url = adminWsUrl(cfg.adminUrl, cfg.token);
4736
+ log(
4737
+ reconnectAttempt
4738
+ ? `websocket connecting (attempt ${reconnectAttempt + 1})…`
4739
+ : "websocket connecting…"
4740
+ );
4741
+ /** @type {WebSocket} */
4742
+ const ws = new WebSocket(url);
4743
+ socket = ws;
4744
+
4745
+ ws.addEventListener("open", () => {
4746
+ if (generation !== wsGeneration) return;
4747
+ reconnectAttempt = 0;
4748
+ log("websocket connected");
4749
+ clearHeartbeatTimer();
4750
+ clearPingTimer();
4751
+ sendPing();
4752
+ sendLightPresence();
4753
+ void sendPresenceOverWs();
4754
+ pingTimer = setInterval(sendPing, WS_PING_MS);
4755
+ heartbeatTimer = setInterval(() => {
4756
+ void sendPresenceOverWs();
4757
+ }, HEARTBEAT_MS);
4758
+ });
4759
+
4760
+ ws.addEventListener("message", (event) => {
4761
+ if (generation !== wsGeneration) return;
4762
+ handleWsMessage(event.data);
4763
+ });
4764
+
4765
+ ws.addEventListener("close", (event) => {
4766
+ if (generation !== wsGeneration) return;
4767
+ clearHeartbeatTimer();
4768
+ clearPingTimer();
4769
+ if (socket === ws) socket = null;
4770
+ scheduleReconnect(event.code, event.reason);
2503
4771
  });
2504
- }, ACTION_POLL_MS);
4772
+
4773
+ ws.addEventListener("error", () => {
4774
+ // close handler drives reconnect
4775
+ });
4776
+ };
4777
+
4778
+ connectWs();
2505
4779
 
2506
4780
  const shutdown = () => {
4781
+ stopped = true;
4782
+ wsGeneration += 1;
4783
+ clearHeartbeatTimer();
4784
+ clearPingTimer();
4785
+ clearReconnectTimer();
2507
4786
  stopAllCloudflare();
4787
+ try {
4788
+ socket?.close();
4789
+ } catch {
4790
+ /* ignore */
4791
+ }
2508
4792
  log("shutting down (other terminals stay open)");
2509
4793
  process.exit(0);
2510
4794
  };