@maintainer-pro/ai-bridge 0.1.6 → 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 +2667 -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();
@@ -1093,13 +2524,21 @@ function writeWinLaunchScript(folder, title, command, env) {
1093
2524
  const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
1094
2525
  const file = path.join(dir, `launch-${safe}.cmd`);
1095
2526
  const folderArg = String(folder).replace(/"/g, "");
2527
+ const safeTitle = String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ");
1096
2528
  const lines = [
1097
2529
  "@echo off",
2530
+ `title ${safeTitle}`,
1098
2531
  `cd /d "${folderArg}"`,
2532
+ "if errorlevel 1 (",
2533
+ " echo Could not open the project folder.",
2534
+ " pause",
2535
+ " exit /b 1",
2536
+ ")",
1099
2537
  ...Object.entries(env).map(
1100
2538
  ([key, value]) => `set "${key}=${String(value).replace(/"/g, "")}"`
1101
2539
  ),
1102
- `title ${String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ")}`,
2540
+ "echo %CD%",
2541
+ `echo ${command}`,
1103
2542
  command,
1104
2543
  "if errorlevel 1 pause",
1105
2544
  ];
@@ -1107,6 +2546,33 @@ function writeWinLaunchScript(folder, title, command, env) {
1107
2546
  return file;
1108
2547
  }
1109
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
+
1110
2576
  function runLauncher(command, args, extra = {}) {
1111
2577
  return new Promise((resolve) => {
1112
2578
  let settled = false;
@@ -1156,6 +2622,7 @@ async function openInNewTerminal(opts) {
1156
2622
  const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
1157
2623
  if (launchKey) {
1158
2624
  if (!opts.force && recentlyLaunched(launchKey)) {
2625
+ log(`terminal skip [${title}]: launched recently`);
1159
2626
  return { ok: true, skipped: true };
1160
2627
  }
1161
2628
  launchedAt.set(launchKey, Date.now());
@@ -1185,20 +2652,13 @@ async function openInNewTerminal(opts) {
1185
2652
  String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
1186
2653
  "Maintainer Pro";
1187
2654
  const script = writeWinLaunchScript(folder, safeTitle, command, env);
1188
- const scriptName = path.basename(script);
1189
- const opened = await runLauncher(
1190
- process.env.ComSpec || "cmd.exe",
1191
- [
1192
- "/d",
1193
- "/s",
1194
- "/c",
1195
- `start "${safeTitle}" /D "${folder}" cmd.exe /k ".maintainer-pro\\${scriptName}"`,
1196
- ],
1197
- { windowsVerbatimArguments: true }
1198
- );
2655
+ log(`terminal script [${title}] ${script}`);
2656
+ const opened = await openWindowsConsole(script, folder);
1199
2657
  if (!opened.ok) {
2658
+ warn(`terminal failed [${title}]: ${opened.error || "unknown"}`);
1200
2659
  return { ok: false, error: friendlyLaunchError(opened.error, title) };
1201
2660
  }
2661
+ log(`terminal opened [${title}]`);
1202
2662
  return { ok: true };
1203
2663
  }
1204
2664
 
@@ -1271,23 +2731,22 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1271
2731
  if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
1272
2732
  reserved.add(preferred);
1273
2733
  clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2734
+ log(`start chat skip ${label}: already up on ${preferred}`);
1274
2735
  return { port: preferred, up: true, launched: false };
1275
2736
  }
1276
2737
 
1277
2738
  if (recentlyLaunched(launchKey)) {
1278
2739
  reserved.add(preferred);
2740
+ log(`start chat skip ${label}: already launching on ${preferred}`);
1279
2741
  return { port: preferred, up: false, launched: false, starting: true };
1280
2742
  }
1281
2743
 
1282
- let port = preferred;
2744
+ let port = Number(opts.port) || preferred;
1283
2745
  try {
1284
- if (reserved.has(preferred)) {
1285
- if (!(await isPortFree(preferred))) {
1286
- reserved.delete(preferred);
1287
- port = await findFreePort(preferred, reserved);
1288
- }
1289
- } else {
2746
+ if (!opts.port) {
1290
2747
  port = await findFreePort(preferred, reserved);
2748
+ } else {
2749
+ reserved.add(port);
1291
2750
  }
1292
2751
  } catch (err) {
1293
2752
  recordProcessProblem({
@@ -1298,23 +2757,32 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1298
2757
  message: `No free port found (tried from ${preferred}). ${
1299
2758
  err instanceof Error ? err.message : String(err)
1300
2759
  }`,
1301
- resolution: "Close other local servers, then use Start chat server.",
2760
+ resolution: "Close other local servers, then use Start Apps.",
1302
2761
  });
1303
2762
  return { port: preferred, up: false, launched: false };
1304
2763
  }
1305
2764
  if (port !== preferred) {
1306
- log(`port ${preferred} busy; using ${port} for ai-server`);
1307
- ws.port = port;
1308
- const envPath = path.join(folder, ".env");
1309
- if (fs.existsSync(envPath)) {
1310
- mergeEnvFile(envPath, {
1311
- PORT: String(port),
1312
- AI_SERVER_URL: `http://localhost:${port}`,
1313
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1314
- });
1315
- }
1316
- persistWorkspaceEntry(cfg, ws);
2765
+ log(`port ${preferred} busy; using ${port} for chat script`);
1317
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);
1318
2786
 
1319
2787
  const localCli = path.resolve(
1320
2788
  __dirname,
@@ -1336,9 +2804,11 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1336
2804
  folder,
1337
2805
  command: run,
1338
2806
  env: {
1339
- PORT: String(port),
1340
- AI_SERVER_URL: `http://localhost:${port}`,
1341
- 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,
1342
2812
  },
1343
2813
  launchKey,
1344
2814
  sandboxId: ws.sandboxId,
@@ -1365,18 +2835,12 @@ function sleep(ms) {
1365
2835
  return new Promise((resolve) => setTimeout(resolve, ms));
1366
2836
  }
1367
2837
 
1368
- /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
1369
- const cloudflareTunnels = new Map();
1370
-
1371
2838
  function stopAllCloudflare() {
1372
2839
  for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1373
2840
  }
1374
2841
 
1375
2842
  function parseTryCloudflareUrl(text) {
1376
- const match = String(text || "").match(
1377
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1378
- );
1379
- return match ? match[0].replace(/\/$/, "") : null;
2843
+ return lastTryCloudflareUrl(text);
1380
2844
  }
1381
2845
 
1382
2846
  function killProcessesByCommand(fragment) {
@@ -1477,22 +2941,38 @@ async function stopWorkspaceApps(ws) {
1477
2941
  await sleep(400);
1478
2942
  }
1479
2943
 
1480
- async function waitUntilReachable(url, timeoutMs, label) {
2944
+ async function waitUntilReachable(url, timeoutMs, label, onWait) {
1481
2945
  const start = Date.now();
2946
+ let last = 0;
1482
2947
  while (Date.now() - start < timeoutMs) {
1483
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
+ }
1484
2956
  await sleep(600);
1485
2957
  }
1486
2958
  throw new Error(`${label} did not become reachable at ${url}`);
1487
2959
  }
1488
2960
 
1489
- async function waitForUrlInFile(file, timeoutMs = 90_000) {
2961
+ async function waitForUrlInFile(file, timeoutMs = 90_000, onWait) {
1490
2962
  const start = Date.now();
2963
+ let last = 0;
1491
2964
  while (Date.now() - start < timeoutMs) {
1492
2965
  if (fs.existsSync(file)) {
1493
2966
  const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
1494
2967
  if (url) return url;
1495
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
+ }
1496
2976
  await sleep(500);
1497
2977
  }
1498
2978
  throw new Error(
@@ -1507,7 +2987,7 @@ function cloudflaredCommand(localUrl, logFile) {
1507
2987
  return `${run} 2>&1 | tee ${logArg}`;
1508
2988
  }
1509
2989
 
1510
- async function startCloudflareTerminal(ws, role, localUrl) {
2990
+ async function startCloudflareTerminal(ws, role, localUrl, onWait) {
1511
2991
  const folder = path.resolve(ws.folderPath);
1512
2992
  const logDir = path.join(folder, ".maintainer-pro");
1513
2993
  fs.mkdirSync(logDir, { recursive: true });
@@ -1531,7 +3011,7 @@ async function startCloudflareTerminal(ws, role, localUrl) {
1531
3011
  if (!opened.ok) {
1532
3012
  throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
1533
3013
  }
1534
- const publicUrl = await waitForUrlInFile(logFile);
3014
+ const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
1535
3015
  return { role, localUrl, publicUrl, logFile };
1536
3016
  }
1537
3017
 
@@ -1539,53 +3019,180 @@ function uiPublicEnv(tunnels) {
1539
3019
  /** @type {Record<string, string>} */
1540
3020
  const env = {};
1541
3021
  if (tunnels.ai) {
1542
- env.AI_SERVER_URL = tunnels.ai;
1543
- env.NEXT_PUBLIC_AI_SERVER_URL = tunnels.ai;
1544
- 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;
1545
3027
  }
1546
3028
  if (tunnels.backend) {
1547
- env.API_URL = tunnels.backend;
1548
- env.API_BASE_URL = tunnels.backend;
1549
- env.VITE_API_URL = tunnels.backend;
1550
- env.VITE_API_BASE_URL = tunnels.backend;
1551
- env.NEXT_PUBLIC_API_URL = tunnels.backend;
1552
- env.NEXT_PUBLIC_API_BASE_URL = tunnels.backend;
1553
- 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;
1554
3037
  }
1555
3038
  if (tunnels.ui) {
1556
- env.APP_URL = tunnels.ui;
1557
- env.PUBLIC_URL = tunnels.ui;
1558
- 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;
1559
3046
  } else if (tunnels.ai) {
1560
- 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;
1561
3053
  }
1562
3054
  return env;
1563
3055
  }
1564
3056
 
1565
3057
  function writeTunnelEnv(ws, tunnels) {
1566
3058
  const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1567
- if (!folder || !fs.existsSync(folder)) return;
3059
+ if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
1568
3060
  const env = uiPublicEnv(tunnels);
1569
3061
  const lines = Object.entries(tunnels)
1570
3062
  .filter(([, url]) => url)
1571
- .map(([role, url]) => `${role}=${url}`);
3063
+ .map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
1572
3064
  fs.writeFileSync(
1573
3065
  path.join(folder, ".cloudflare-tunnel-url"),
1574
3066
  `${lines.join("\n")}\n`,
1575
3067
  "utf8"
1576
3068
  );
1577
- const envPath = path.join(folder, ".env");
1578
- if (Object.keys(env).length) mergeEnvFile(envPath, env);
1579
- const localEnv = {};
1580
- if (env.NEXT_PUBLIC_AI_SERVER_URL) {
1581
- 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
+ );
1582
3160
  }
1583
- if (env.NEXT_PUBLIC_API_URL) {
1584
- 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
+ );
1585
3183
  }
1586
- if (Object.keys(localEnv).length) {
1587
- 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}`);
1588
3194
  }
3195
+ return { ok, checks };
1589
3196
  }
1590
3197
 
1591
3198
  function reservedPortsFor(cfg, sandboxId) {
@@ -1602,17 +3209,39 @@ function appsWanted(ws) {
1602
3209
  return Boolean(ws?.appsRequested);
1603
3210
  }
1604
3211
 
1605
- async function configureCloudflareForWorkspace(ws, cfg) {
3212
+ async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
1606
3213
  const sandboxId = ws.sandboxId;
1607
3214
  const label = ws.sandboxName || "this sandbox";
3215
+ const progress = (message) =>
3216
+ reportActionProgress(cfg, opts.actionId, message);
1608
3217
 
1609
- log(`Cloudflare queued for ${label}: stop apps and wait for Start`);
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
+ }
3231
+
3232
+ await progress(
3233
+ `Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
3234
+ );
1610
3235
  await stopCloudflare(sandboxId);
3236
+ await progress("Stopping local app terminals and freeing their ports…");
1611
3237
  await stopWorkspaceApps(ws);
1612
3238
  await forgetLaunch(sandboxId);
1613
3239
  ws.cloudflarePending = true;
1614
3240
  ws.appsRequested = false;
1615
3241
  persistWorkspaceEntry(cfg, ws);
3242
+ await progress(
3243
+ "Cloudflare is queued. Use Start Apps to create the public URLs."
3244
+ );
1616
3245
 
1617
3246
  return {
1618
3247
  sandboxId,
@@ -1622,43 +3251,61 @@ async function configureCloudflareForWorkspace(ws, cfg) {
1622
3251
  cloudflarePending: true,
1623
3252
  waitingForStart: true,
1624
3253
  warning:
1625
- "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.",
1626
3255
  };
1627
3256
  }
1628
3257
 
1629
- async function launchCloudflareTunnels(ws, cfg) {
3258
+ async function launchCloudflareTunnels(ws, cfg, opts = {}) {
1630
3259
  const sandboxId = ws.sandboxId;
1631
3260
  const label = ws.sandboxName || "this sandbox";
1632
3261
  const folder = path.resolve(ws.folderPath || "");
1633
3262
  const reserved = reservedPortsFor(cfg, sandboxId);
3263
+ const progress = (message) =>
3264
+ reportActionProgress(cfg, opts.actionId, message);
3265
+
3266
+ const attached = await tryAttachExistingCloudflare(ws, cfg, {
3267
+ onProgress: progress,
3268
+ });
3269
+ if (attached) return attached;
1634
3270
 
1635
- log(`Cloudflare start for ${label}: tunnel chat script/backend before UI`);
3271
+ await progress(
3272
+ `Creating Cloudflare tunnels for ${label}. This usually takes 1–2 minutes.`
3273
+ );
1636
3274
  try {
3275
+ const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
1637
3276
  if (!cfg.noAiServer) {
1638
- 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
+ });
1639
3283
  await waitUntilReachable(
1640
3284
  `http://127.0.0.1:${ws.port}/embed-config.js`,
1641
3285
  45_000,
1642
- "Chat script"
3286
+ "the chat script",
3287
+ progress
1643
3288
  );
1644
3289
  }
1645
3290
 
1646
- const jobs = planHostJobs(folder, null, ws.projectInfo);
1647
- const backendJob = jobs.find((job) => job.role === "backend");
1648
- 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");
1649
3293
 
1650
3294
  if (backendJob) {
3295
+ await progress(`Starting the backend on port ${backendJob.port}…`);
1651
3296
  await ensureHostProcesses(ws, {
1652
3297
  reserved,
1653
3298
  cfg,
1654
3299
  onlyRoles: ["backend"],
1655
3300
  force: true,
3301
+ plannedJobs: plan.jobs,
1656
3302
  });
1657
- const backendPort = Number(backendJob.preferredPort) || 4100;
3303
+ const backendPort = Number(backendJob.port) || 4100;
1658
3304
  await waitUntilReachable(
1659
3305
  `http://127.0.0.1:${backendPort}`,
1660
3306
  45_000,
1661
- "Backend"
3307
+ "the backend",
3308
+ progress
1662
3309
  );
1663
3310
  }
1664
3311
 
@@ -1668,45 +3315,125 @@ async function launchCloudflareTunnels(ws, cfg) {
1668
3315
  const started = [];
1669
3316
 
1670
3317
  const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
1671
- 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);
1672
3320
  tunnels.ai = aiTunnel.publicUrl;
1673
3321
  started.push(aiTunnel);
1674
- log(`Cloudflare chat script: ${aiTunnel.publicUrl}`);
3322
+ await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
1675
3323
 
1676
3324
  if (backendJob) {
1677
- const backendLocal = `http://127.0.0.1:${Number(backendJob.preferredPort) || 4100}`;
1678
- 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
+ );
1679
3333
  tunnels.backend = backendTunnel.publicUrl;
1680
3334
  started.push(backendTunnel);
1681
- log(`Cloudflare backend: ${backendTunnel.publicUrl}`);
3335
+ await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
1682
3336
  }
1683
3337
 
1684
3338
  writeTunnelEnv(ws, tunnels);
1685
- const uiEnv = uiPublicEnv(tunnels);
3339
+ let uiEnv = uiPublicEnv(tunnels);
3340
+ const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
1686
3341
 
1687
3342
  if (uiJob) {
3343
+ await progress(`Starting the app UI on port ${uiPort}…`);
1688
3344
  await ensureHostProcesses(ws, {
1689
3345
  reserved,
1690
3346
  cfg,
1691
3347
  onlyRoles: ["ui", "app"],
1692
3348
  extraEnv: uiEnv,
1693
3349
  force: true,
3350
+ plannedJobs: plan.jobs,
1694
3351
  });
1695
- const uiPort = Number(uiJob.preferredPort) || 5173;
1696
- 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
+ );
1697
3361
  const uiTunnel = await startCloudflareTerminal(
1698
3362
  ws,
1699
3363
  "ui",
1700
- `http://127.0.0.1:${uiPort}`
3364
+ `http://127.0.0.1:${uiPort}`,
3365
+ progress
1701
3366
  );
1702
3367
  tunnels.ui = uiTunnel.publicUrl;
1703
3368
  started.push(uiTunnel);
1704
- log(`Cloudflare UI: ${uiTunnel.publicUrl}`);
3369
+ await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
1705
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…");
1706
3403
  launchedAt.delete(`${sandboxId}:${folder}:ai`);
1707
3404
  await killPort(ws.port);
1708
3405
  await sleep(1500);
1709
- 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
+ );
1710
3437
  }
1711
3438
 
1712
3439
  const appUrl = tunnels.ui || tunnels.ai;
@@ -1716,16 +3443,22 @@ async function launchCloudflareTunnels(ws, cfg) {
1716
3443
  ws.cloudflarePending = false;
1717
3444
  ws.appsRequested = true;
1718
3445
  persistWorkspaceEntry(cfg, ws);
3446
+ rememberCloudflareTunnels(sandboxId, tunnels);
1719
3447
  cloudflareTunnels.set(sandboxId, { tunnels: started });
1720
3448
  clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
3449
+ const host = workspaceHostReport(ws);
3450
+ await progress(`Cloudflare is ready: ${host.appUrl || appUrl}`);
1721
3451
 
1722
3452
  return {
1723
3453
  sandboxId,
1724
3454
  folderPath: ws.folderPath,
1725
3455
  port: ws.port,
1726
- appUrl,
1727
- 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),
1728
3460
  tunnels,
3461
+ validation,
1729
3462
  cloudflare: true,
1730
3463
  reused: false,
1731
3464
  };
@@ -1738,47 +3471,120 @@ async function launchCloudflareTunnels(ws, cfg) {
1738
3471
  title: `Could not start Cloudflare (${label})`,
1739
3472
  message,
1740
3473
  resolution:
1741
- "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.",
1742
3475
  actionCode: "start_ai_server",
1743
3476
  });
1744
3477
  throw err;
1745
3478
  }
1746
3479
  }
1747
3480
 
1748
- 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.
1749
3505
  if (ws.cloudflarePending) {
1750
- return launchCloudflareTunnels(ws, cfg);
3506
+ log(`start apps ${label}: Cloudflare pending, launching tunnels`);
3507
+ return launchCloudflareTunnels(ws, cfg, opts);
1751
3508
  }
3509
+
3510
+ await reportActionProgress(
3511
+ cfg,
3512
+ opts.actionId,
3513
+ `Starting local apps for ${label}…`
3514
+ );
1752
3515
  ws.appsRequested = true;
1753
- persistWorkspaceEntry(cfg, ws);
1754
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
+
1755
3530
  if (!cfg.noAiServer) {
1756
- await startAiServerForWorkspace(ws, { reserved, cfg });
1757
- 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
+ }
1758
3545
  }
3546
+
3547
+ // ensureHostProcesses skips roles that are already answering HTTP.
1759
3548
  const startedHosts = await ensureHostProcesses(ws, {
1760
3549
  reserved,
1761
3550
  cfg,
1762
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,
1763
3557
  });
1764
3558
  await sleep(800);
3559
+
3560
+ status = await reconcileWorkspacePresence(ws, cfg, {
3561
+ writeEnv: true,
3562
+ timeoutMs: 2500,
3563
+ });
1765
3564
  await inspectHostJobs(ws);
1766
- const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
1767
- if (up) {
1768
- clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1769
- clearProcessProblem(ws.sandboxId, "apps_not_started");
1770
- }
3565
+
1771
3566
  const processIssues = issuesForSandbox(ws.sandboxId).map(
1772
3567
  ({ role: _role, ...issue }) => issue
1773
3568
  );
1774
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
+ );
1775
3579
  return {
1776
- up,
3580
+ up: status.aiServerUp,
1777
3581
  startedHosts,
1778
3582
  sandboxId: ws.sandboxId,
1779
3583
  folderPath: ws.folderPath,
1780
3584
  port: ws.port,
1781
- appUrl: ws.appUrl,
3585
+ appUrl: status.host.appUrl || ws.appUrl,
3586
+ origins: status.host.origins,
3587
+ cloudflare: status.usingCloudflare,
1782
3588
  processIssues,
1783
3589
  warning,
1784
3590
  };
@@ -1789,11 +3595,13 @@ async function ensureHostProcesses(ws, opts = {}) {
1789
3595
  const cfg = opts.cfg || null;
1790
3596
  const folder = path.resolve(ws.folderPath);
1791
3597
  const scripts = readPackageJson(folder)?.scripts || {};
1792
- const jobs = planHostJobs(
1793
- folder,
1794
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1795
- ws.projectInfo
1796
- );
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
+ );
1797
3605
  const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
1798
3606
  const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
1799
3607
  const started = [];
@@ -1812,14 +3620,26 @@ async function ensureHostProcesses(ws, opts = {}) {
1812
3620
  return started;
1813
3621
  }
1814
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
+
1815
3634
  for (const job of jobs) {
1816
3635
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
1817
- const preferred = Number(job.preferredPort) || 3000;
1818
- const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1819
- "localhost",
1820
- "127.0.0.1"
1821
- );
1822
- 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))) {
1823
3643
  reserved.add(portFromText(probe, preferred));
1824
3644
  clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1825
3645
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
@@ -1829,12 +3649,17 @@ async function ensureHostProcesses(ws, opts = {}) {
1829
3649
  const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1830
3650
  if (!opts.force && recentlyLaunched(launchKey)) {
1831
3651
  reserved.add(preferred);
3652
+ log(`start ${job.role} skip ${label}: launched recently`);
1832
3653
  continue;
1833
3654
  }
1834
3655
 
1835
- let port = preferred;
3656
+ let port = Number(job.port) || preferred;
1836
3657
  try {
1837
- port = await findFreePort(preferred, reserved);
3658
+ if (!job.port) {
3659
+ port = await findFreePort(preferred, reserved);
3660
+ } else {
3661
+ reserved.add(port);
3662
+ }
1838
3663
  } catch (err) {
1839
3664
  recordProcessProblem({
1840
3665
  sandboxId: ws.sandboxId,
@@ -1844,7 +3669,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1844
3669
  message: `No free port found for "${job.script}" (tried from ${preferred}). ${
1845
3670
  err instanceof Error ? err.message : String(err)
1846
3671
  }`,
1847
- resolution: "Close other local servers, then use Start chat server.",
3672
+ resolution: "Close other local servers, then use Start Apps.",
1848
3673
  });
1849
3674
  continue;
1850
3675
  }
@@ -1866,7 +3691,10 @@ async function ensureHostProcesses(ws, opts = {}) {
1866
3691
  sandboxId: ws.sandboxId,
1867
3692
  force: Boolean(opts.force),
1868
3693
  });
1869
- if (opened.skipped) continue;
3694
+ if (opened.skipped) {
3695
+ log(`start ${job.role} skip ${label}: terminal already opening`);
3696
+ continue;
3697
+ }
1870
3698
  if (!opened.ok) {
1871
3699
  recordProcessProblem({
1872
3700
  sandboxId: ws.sandboxId,
@@ -1880,6 +3708,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1880
3708
  continue;
1881
3709
  }
1882
3710
  started.push(job.role);
3711
+ log(`start ${job.role} launched ${label} on ${port}: ${command}`);
1883
3712
  if (
1884
3713
  (job.role === "ui" || job.role === "app") &&
1885
3714
  (!ws.appUrl || isLocalAppUrl(ws.appUrl))
@@ -1939,7 +3768,7 @@ async function inspectHostJobs(ws) {
1939
3768
  ? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
1940
3769
  : `Nothing is running at ${probe} for "${job.script}".`,
1941
3770
  resolution:
1942
- "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.",
1943
3772
  });
1944
3773
  }
1945
3774
  return hosts;
@@ -1950,6 +3779,7 @@ async function setupWorkspace(cfg, action) {
1950
3779
  const sandboxId = String(
1951
3780
  action.sandboxId || action.payload?.sandboxId || ""
1952
3781
  );
3782
+ log(`setup begin sandbox=${shortId(sandboxId)} folder=${folderPath || "(none)"}`);
1953
3783
  const requestedPort = Number(action.payload?.port) || 3100;
1954
3784
  const reserved = new Set();
1955
3785
  for (const other of cfg.workspaces || []) {
@@ -1960,10 +3790,13 @@ async function setupWorkspace(cfg, action) {
1960
3790
  let port = requestedPort;
1961
3791
  if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
1962
3792
  reserved.add(requestedPort);
3793
+ log(`setup chat port ${requestedPort} already up`);
1963
3794
  } else {
1964
3795
  port = await findFreePort(requestedPort, reserved);
1965
3796
  if (port !== requestedPort) {
1966
- 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`);
1967
3800
  }
1968
3801
  }
1969
3802
  const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
@@ -2004,18 +3837,29 @@ async function setupWorkspace(cfg, action) {
2004
3837
  const appUrl = client.appUrl || corsOrigin;
2005
3838
 
2006
3839
  const envPath = path.join(resolved, ".env");
3840
+ const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3841
+ ? config.aiIgnorePaths
3842
+ : [];
2007
3843
  const envValues = {
2008
3844
  ...config.env,
2009
3845
  AI_CLI_WORKSPACE: ".",
3846
+ AI_CLI_IGNORE_PATHS: JSON.stringify(
3847
+ normalizeIgnorePaths(partnerIgnorePaths)
3848
+ ),
2010
3849
  AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
2011
- PORT: String(port),
3850
+ AI_SERVER_PORT: String(port),
2012
3851
  AI_SERVER_URL: aiOrigin,
2013
3852
  NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
2014
3853
  CORS_ORIGIN: corsOrigin,
2015
3854
  APP_URL: appUrl,
2016
3855
  AI_SERVER_PRODUCT_DESCRIPTION: appName,
2017
3856
  };
2018
- 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);
2019
3863
 
2020
3864
  cfg.workspaces = cfg.workspaces || [];
2021
3865
  const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
@@ -2049,6 +3893,12 @@ async function setupWorkspace(cfg, action) {
2049
3893
  });
2050
3894
 
2051
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));
2052
3902
 
2053
3903
  const openUrl = client.sameOrigin
2054
3904
  ? `http://localhost:${entry.port}`
@@ -2064,22 +3914,25 @@ async function setupWorkspace(cfg, action) {
2064
3914
  );
2065
3915
  const waitingForStart = !aiServerUp;
2066
3916
  const warning = waitingForStart
2067
- ? "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."
2068
3918
  : processIssues[0]?.message || null;
2069
3919
 
2070
- for (const note of client.notes) log(note);
2071
- if (waitingForStart) {
2072
- log(`folder attached — waiting for Start (${openUrl})`);
2073
- } else {
2074
- log(`ai-server already up — open ${openUrl}`);
2075
- }
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
+ );
2076
3926
 
3927
+ const host = workspaceHostReport(entry);
2077
3928
  return {
2078
3929
  sandboxId,
2079
3930
  folderPath: resolved,
2080
3931
  port: entry.port,
2081
- appUrl: entry.appUrl || appUrl,
2082
- 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))],
2083
3936
  wroteEnv: true,
2084
3937
  clientKind: client.kind,
2085
3938
  clientFiles: client.filesWritten,
@@ -2091,26 +3944,64 @@ async function setupWorkspace(cfg, action) {
2091
3944
  warning,
2092
3945
  waitingForStart,
2093
3946
  projectInfo,
3947
+ ignorePaths: access.ignorePaths,
2094
3948
  };
2095
3949
  }
2096
3950
 
2097
3951
  async function runActions(cfg, actions) {
3952
+ if (!actions.length) return;
3953
+ log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
2098
3954
  for (const action of actions) {
2099
- log(`action ${action.code} (${action.id})`);
3955
+ const startedAt = Date.now();
3956
+ const label = actionLabel(action);
3957
+ log(`${label} start`);
2100
3958
  let ok = true;
2101
3959
  /** @type {Record<string, unknown>} */
2102
3960
  let result = {};
2103
3961
  try {
2104
3962
  if (action.code === "browse") {
2105
3963
  const p = String(action.payload?.path || process.cwd());
3964
+ log(`${label} browse ${p}`);
2106
3965
  result = listDirEntries(p);
3966
+ log(`${label} browse ${result.entries?.length ?? 0} entries`);
2107
3967
  } else if (action.code === "setup_workspace") {
2108
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
+ }
2109
3997
  } else if (action.code === "recheck") {
2110
3998
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2111
3999
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4000
+ if (!ws) {
4001
+ log(`${label} recheck: no local workspace`);
4002
+ }
2112
4003
  const projectInfo = ws
2113
- ? await inspectProjectWithAiCli(ws, { cfg })
4004
+ ? await inspectProjectWithAiCli(ws, { cfg, force: true })
2114
4005
  : null;
2115
4006
  result = {
2116
4007
  recheckedAt: new Date().toISOString(),
@@ -2130,18 +4021,57 @@ async function runActions(cfg, actions) {
2130
4021
  if (!ws || cfg.noAiServer) {
2131
4022
  ok = false;
2132
4023
  result = { error: "No workspace or --no-ai-server" };
4024
+ warn(`${label} skipped: ${result.error}`);
2133
4025
  } else {
2134
- const problem = issuesForSandbox(ws.sandboxId)
2135
- .map((issue) => issue.message)
2136
- .join("\n");
2137
- const projectInfo = await inspectProjectWithAiCli(ws, {
2138
- cfg,
2139
- problem:
2140
- problem ||
2141
- "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,
2142
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
+ }
2143
4073
  result = {
2144
- ...(await startAppsForWorkspace(ws, cfg)),
4074
+ ...started,
2145
4075
  projectInfo,
2146
4076
  };
2147
4077
  if (
@@ -2155,14 +4085,23 @@ async function runActions(cfg, actions) {
2155
4085
  } else if (action.code === "refresh_public_url") {
2156
4086
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2157
4087
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4088
+ const host = ws ? workspaceHostReport(ws) : { appUrl: null, origins: [] };
2158
4089
  result = {
4090
+ sandboxId: sandboxId || ws?.sandboxId || null,
2159
4091
  appUrl:
4092
+ host.appUrl ||
2160
4093
  ws?.cloudflareUrl ||
2161
4094
  ws?.appUrl ||
2162
4095
  process.env.APP_URL ||
2163
4096
  process.env.PUBLIC_URL ||
2164
4097
  null,
4098
+ origins: host.origins,
2165
4099
  };
4100
+ log(
4101
+ `${label} public url ${result.appUrl || "(none)"} origins=${
4102
+ host.origins.join(",") || "none"
4103
+ }`
4104
+ );
2166
4105
  } else if (action.code === "configure_cloudflare") {
2167
4106
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
2168
4107
  const ws =
@@ -2182,8 +4121,11 @@ async function runActions(cfg, actions) {
2182
4121
  if (!ws) {
2183
4122
  ok = false;
2184
4123
  result = { error: "No folder is attached for this sandbox" };
4124
+ warn(`${label} skipped: ${result.error}`);
2185
4125
  } else {
2186
- result = await configureCloudflareForWorkspace(ws, cfg);
4126
+ result = await configureCloudflareForWorkspace(ws, cfg, {
4127
+ actionId: action.id,
4128
+ });
2187
4129
  }
2188
4130
  } else if (action.code === "remove_workspace") {
2189
4131
  const sandboxId = String(
@@ -2195,15 +4137,26 @@ async function runActions(cfg, actions) {
2195
4137
  );
2196
4138
  saveConfig(cfg);
2197
4139
  result = { removedSandboxId: sandboxId };
4140
+ log(`${label} removed workspace`);
2198
4141
  } else {
2199
4142
  ok = false;
2200
4143
  result = { error: `Unknown action ${action.code}` };
4144
+ warn(`${label} skipped: ${result.error}`);
2201
4145
  }
2202
4146
  } catch (err) {
2203
4147
  ok = false;
2204
4148
  result = { error: err instanceof Error ? err.message : String(err) };
4149
+ warn(`${label} threw: ${result.error}`);
2205
4150
  }
2206
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
+
2207
4160
  try {
2208
4161
  await api(
2209
4162
  cfg.adminUrl,
@@ -2212,9 +4165,10 @@ async function runActions(cfg, actions) {
2212
4165
  `/api/v1/bridge/machine/actions/${action.id}/complete`,
2213
4166
  { ok, result }
2214
4167
  );
4168
+ log(`${label} reported to admin`);
2215
4169
  } catch (err) {
2216
4170
  warn(
2217
- `failed to complete action: ${
4171
+ `${label} report failed: ${
2218
4172
  err instanceof Error ? err.message : String(err)
2219
4173
  }`
2220
4174
  );
@@ -2223,50 +4177,155 @@ async function runActions(cfg, actions) {
2223
4177
  }
2224
4178
 
2225
4179
  async function collectWorkspaceStates(cfg) {
2226
- /** @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[]}>} */
2227
4181
  const localStates = [];
2228
4182
  for (const ws of cfg.workspaces || []) {
2229
4183
  const folder = path.resolve(ws.folderPath || "");
2230
- const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
2231
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2232
- await inspectHostJobs(ws);
4184
+ const status = await reconcileWorkspacePresence(ws, cfg, {
4185
+ writeEnv: true,
4186
+ timeoutMs: 800,
4187
+ });
2233
4188
  localStates.push({
2234
4189
  sandboxId: ws.sandboxId,
2235
4190
  sandboxName: ws.sandboxName,
2236
- port: ws.port,
4191
+ port: status.probe.chatPort || ws.port,
2237
4192
  folderPath: ws.folderPath,
2238
- aiServerUp: up,
4193
+ aiServerUp: status.aiServerUp,
4194
+ appsRunning: status.appsRunning,
2239
4195
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2240
- appUrl: ws.appUrl || null,
2241
- 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,
2242
4205
  });
2243
4206
  }
2244
4207
  return localStates;
2245
4208
  }
2246
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
+
2247
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
+ };
2248
4268
  return api(
2249
4269
  cfg.adminUrl,
2250
4270
  cfg.token,
2251
4271
  "POST",
2252
4272
  "/api/v1/bridge/machine/heartbeat",
2253
- {
2254
- hostname: os.hostname(),
2255
- platform: `${os.platform()}-${os.arch()}`,
2256
- bridgeVersion: PACKAGE_VERSION,
2257
- folders,
2258
- issues: await buildIssues(cfg, localStates),
2259
- workspaces: localStates.map((st) => ({
2260
- sandboxId: st.sandboxId,
2261
- aiServerUp: st.aiServerUp,
2262
- port: st.port,
2263
- appUrl: st.appUrl || undefined,
2264
- appsRequested: Boolean(st.appsRequested),
2265
- })),
2266
- }
4273
+ body
2267
4274
  );
2268
4275
  }
2269
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
+
2270
4329
  async function buildIssues(cfg, workspaceStates) {
2271
4330
  /** @type {Array<Record<string, unknown>>} */
2272
4331
  const issues = [];
@@ -2284,15 +4343,15 @@ async function buildIssues(cfg, workspaceStates) {
2284
4343
  });
2285
4344
  }
2286
4345
  for (const st of workspaceStates) {
2287
- if (st.aiServerUp || st.startingAi) continue;
4346
+ if (st.aiServerUp || st.appsRunning || st.startingAi) continue;
2288
4347
  if (!st.appsRequested) {
2289
4348
  issues.push({
2290
4349
  code: "apps_not_started",
2291
4350
  severity: "info",
2292
4351
  title: `Apps are not running (${st.sandboxName || "sandbox"})`,
2293
4352
  message:
2294
- "This folder is attached in Maintainer Pro. Use Start chat server when you want to launch the local apps.",
2295
- 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.",
2296
4355
  actionCode: "start_ai_server",
2297
4356
  sandboxId: st.sandboxId,
2298
4357
  });
@@ -2313,7 +4372,7 @@ async function buildIssues(cfg, workspaceStates) {
2313
4372
  `Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
2314
4373
  resolution:
2315
4374
  launch?.resolution ||
2316
- "Read the error in that terminal, then use Start chat server.",
4375
+ "Read the error in that terminal, then use Start Apps.",
2317
4376
  actionCode: "start_ai_server",
2318
4377
  sandboxId: st.sandboxId,
2319
4378
  });
@@ -2390,12 +4449,22 @@ async function pairFlow(args) {
2390
4449
  }
2391
4450
 
2392
4451
  async function main() {
4452
+ if (!process.env.NODE_ENV?.trim()) {
4453
+ process.env.NODE_ENV = "development";
4454
+ }
4455
+ logger = createLogger("ai-bridge");
4456
+
2393
4457
  const args = parseArgs(process.argv.slice(2));
2394
4458
  if (args.help) {
2395
4459
  printHelp();
2396
4460
  process.exit(0);
2397
4461
  }
2398
4462
 
4463
+ logger.debug(
4464
+ { logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
4465
+ "bridge starting"
4466
+ );
4467
+
2399
4468
  let cfg = loadConfig() || {};
2400
4469
  ensureMachineId(cfg);
2401
4470
 
@@ -2419,113 +4488,307 @@ async function main() {
2419
4488
  log(`machine ${cfg.machineId}`);
2420
4489
  log(`admin ${cfg.adminUrl}`);
2421
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);
2422
4495
 
2423
- const tick = async () => {
2424
- const remoteWorkspaces = [];
4496
+ /** @type {unknown[]} */
4497
+ const claimedActions = [];
4498
+ let workBusy = false;
4499
+
4500
+ const runWork = async () => {
4501
+ if (workBusy) return;
4502
+ workBusy = true;
2425
4503
  try {
2426
- // Heartbeat first — server returns assigned workspaces
2427
- const folders = collectOfferedFolders(cfg);
2428
- /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
2429
- const localStates = [];
2430
-
2431
- const reserved = new Set();
2432
- for (const ws of cfg.workspaces || []) {
2433
- const folder = path.resolve(ws.folderPath || "");
2434
- const up = await probeUrl(
2435
- `http://127.0.0.1:${ws.port}/embed-config.js`
2436
- );
2437
- if (appsWanted(ws) && !cfg.noAiServer && !up) {
2438
- await startAiServerForWorkspace(ws, { reserved, cfg });
2439
- } else if (ws.port) {
2440
- reserved.add(Number(ws.port));
2441
- if (up) {
2442
- clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2443
- clearProcessProblem(ws.sandboxId, "apps_not_started");
2444
- }
2445
- }
2446
- if (appsWanted(ws)) {
2447
- await ensureHostProcesses(ws, { reserved, cfg });
2448
- }
2449
- await inspectHostJobs(ws);
2450
- localStates.push({
2451
- sandboxId: ws.sandboxId,
2452
- sandboxName: ws.sandboxName,
2453
- port: ws.port,
2454
- folderPath: ws.folderPath,
2455
- aiServerUp: up,
2456
- startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2457
- appUrl: ws.appUrl || null,
2458
- appsRequested: appsWanted(ws),
2459
- });
4504
+ while (claimedActions.length) {
4505
+ const batch = claimedActions.splice(0, claimedActions.length);
4506
+ await runActions(cfg, batch);
2460
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
+ };
2461
4515
 
2462
- 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
+ };
2463
4521
 
2464
- // Sync local workspace list from server assignments
2465
- if (Array.isArray(hb.workspaces)) {
2466
- for (const remote of hb.workspaces) {
2467
- remoteWorkspaces.push(remote);
2468
- const local = (cfg.workspaces || []).find(
2469
- (w) => w.sandboxId === remote.sandboxId
2470
- );
2471
- if (!local) {
2472
- cfg.workspaces = cfg.workspaces || [];
2473
- cfg.workspaces.push({
2474
- sandboxId: remote.sandboxId,
2475
- folderPath: remote.folderPath,
2476
- port: remote.port,
2477
- sandboxName: remote.sandboxName,
2478
- applicationName: remote.applicationName,
2479
- });
2480
- saveConfig(cfg);
2481
- } else if (local.folderPath !== remote.folderPath) {
2482
- local.folderPath = remote.folderPath;
2483
- local.port = remote.port;
2484
- saveConfig(cfg);
2485
- }
2486
- }
2487
- }
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
+ };
2488
4541
 
2489
- if (Array.isArray(hb.actions) && hb.actions.length > 0) {
2490
- await runActions(cfg, hb.actions);
2491
- await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
2492
- }
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);
2493
4581
  } catch (err) {
2494
4582
  warn(err instanceof Error ? err.message : String(err));
4583
+ } finally {
4584
+ presenceBusy = false;
2495
4585
  }
2496
4586
  };
2497
4587
 
2498
- let cycleBusy = false;
2499
- const runLocked = async (fn) => {
2500
- if (cycleBusy) return;
2501
- 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})`);
2502
4635
  try {
2503
- await fn();
2504
- } finally {
2505
- 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));
2506
4674
  }
2507
4675
  };
2508
4676
 
2509
- await runLocked(tick);
2510
- setInterval(() => {
2511
- void runLocked(tick);
2512
- }, HEARTBEAT_MS);
2513
- setInterval(() => {
2514
- void runLocked(async () => {
2515
- const pending = await api(
2516
- cfg.adminUrl,
2517
- cfg.token,
2518
- "GET",
2519
- "/api/v1/bridge/machine/actions"
2520
- );
2521
- if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
2522
- 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 */
2523
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);
2524
4771
  });
2525
- }, ACTION_POLL_MS);
4772
+
4773
+ ws.addEventListener("error", () => {
4774
+ // close handler drives reconnect
4775
+ });
4776
+ };
4777
+
4778
+ connectWs();
2526
4779
 
2527
4780
  const shutdown = () => {
4781
+ stopped = true;
4782
+ wsGeneration += 1;
4783
+ clearHeartbeatTimer();
4784
+ clearPingTimer();
4785
+ clearReconnectTimer();
2528
4786
  stopAllCloudflare();
4787
+ try {
4788
+ socket?.close();
4789
+ } catch {
4790
+ /* ignore */
4791
+ }
2529
4792
  log("shutting down (other terminals stay open)");
2530
4793
  process.exit(0);
2531
4794
  };