@maintainer-pro/ai-bridge 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/daemon.mjs +517 -206
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
package/src/daemon.mjs CHANGED
@@ -850,7 +850,65 @@ const launchedAt = new Map();
850
850
  /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
851
851
  const processProblems = new Map();
852
852
 
853
- function forgetLaunch(sandboxId) {
853
+ /** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
854
+ const openedTerminalTitles = new Map();
855
+
856
+ function rememberTerminalTitle(sandboxId, title) {
857
+ const id = String(sandboxId || "").trim();
858
+ const name = String(title || "").trim();
859
+ if (!id || !name) return;
860
+ let titles = openedTerminalTitles.get(id);
861
+ if (!titles) {
862
+ titles = new Set();
863
+ openedTerminalTitles.set(id, titles);
864
+ }
865
+ titles.add(name);
866
+ }
867
+
868
+ function closeWindowsByTitle(title) {
869
+ const name = String(title || "").trim();
870
+ if (!name) return Promise.resolve();
871
+ return new Promise((resolve) => {
872
+ const done = () => resolve();
873
+ if (process.platform === "win32") {
874
+ const child = spawn(
875
+ "taskkill",
876
+ ["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
877
+ { windowsHide: true, stdio: "ignore" }
878
+ );
879
+ child.on("exit", done);
880
+ child.on("error", done);
881
+ setTimeout(done, 4000);
882
+ return;
883
+ }
884
+ if (process.platform === "darwin") {
885
+ const child = spawn(
886
+ "osascript",
887
+ [
888
+ "-e",
889
+ `tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
890
+ ],
891
+ { stdio: "ignore" }
892
+ );
893
+ child.on("exit", done);
894
+ child.on("error", done);
895
+ setTimeout(done, 4000);
896
+ return;
897
+ }
898
+ done();
899
+ });
900
+ }
901
+
902
+ async function closeRememberedTerminals(sandboxId) {
903
+ const titles = openedTerminalTitles.get(sandboxId);
904
+ if (!titles) return;
905
+ for (const title of titles) {
906
+ await closeWindowsByTitle(title);
907
+ }
908
+ openedTerminalTitles.delete(sandboxId);
909
+ }
910
+
911
+ async function forgetLaunch(sandboxId) {
854
912
  for (const key of [...launchedAt.keys()]) {
855
913
  if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
856
914
  launchedAt.delete(key);
@@ -859,7 +917,8 @@ function forgetLaunch(sandboxId) {
859
917
  for (const key of [...processProblems.keys()]) {
860
918
  if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
861
919
  }
862
- stopCloudflare(sandboxId);
920
+ await closeRememberedTerminals(sandboxId);
921
+ await stopCloudflare(sandboxId);
863
922
  }
864
923
 
865
924
  function problemKey(sandboxId, code, role = "") {
@@ -1073,9 +1132,11 @@ function runLauncher(command, args, extra = {}) {
1073
1132
  }
1074
1133
 
1075
1134
  async function openInNewTerminal(opts) {
1076
- const { title, folder, command, env = {}, launchKey } = opts;
1135
+ const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
1077
1136
  if (launchKey) {
1078
- if (recentlyLaunched(launchKey)) return { ok: true, skipped: true };
1137
+ if (!opts.force && recentlyLaunched(launchKey)) {
1138
+ return { ok: true, skipped: true };
1139
+ }
1079
1140
  launchedAt.set(launchKey, Date.now());
1080
1141
  }
1081
1142
  if (!fs.existsSync(folder)) {
@@ -1084,6 +1145,13 @@ async function openInNewTerminal(opts) {
1084
1145
  return { ok: false, error };
1085
1146
  }
1086
1147
 
1148
+ await closeWindowsByTitle(title);
1149
+ await sleep(250);
1150
+ rememberTerminalTitle(
1151
+ sandboxId || String(launchKey || "").split(":")[0],
1152
+ title
1153
+ );
1154
+
1087
1155
  const envWin = Object.entries(env)
1088
1156
  .map(([key, value]) => `set ${key}=${value}`)
1089
1157
  .join("&& ");
@@ -1096,18 +1164,14 @@ async function openInNewTerminal(opts) {
1096
1164
  try {
1097
1165
  if (process.platform === "win32") {
1098
1166
  const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
1099
- const escaped = inner.replace(/'/g, "''");
1100
- const opened = await runLauncher(
1101
- "powershell.exe",
1102
- [
1103
- "-NoProfile",
1104
- "-WindowStyle",
1105
- "Hidden",
1106
- "-Command",
1107
- `Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
1108
- ],
1109
- { windowsHide: true }
1110
- );
1167
+ const opened = await runLauncher(process.env.ComSpec || "cmd.exe", [
1168
+ "/c",
1169
+ "start",
1170
+ title,
1171
+ "cmd.exe",
1172
+ "/k",
1173
+ inner,
1174
+ ]);
1111
1175
  if (!opened.ok) {
1112
1176
  return { ok: false, error: friendlyLaunchError(opened.error, title) };
1113
1177
  }
@@ -1253,6 +1317,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
1253
1317
  NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1254
1318
  },
1255
1319
  launchKey,
1320
+ sandboxId: ws.sandboxId,
1256
1321
  });
1257
1322
  if (opened.skipped) {
1258
1323
  return { port, up: false, launched: false, starting: true };
@@ -1276,189 +1341,423 @@ function sleep(ms) {
1276
1341
  return new Promise((resolve) => setTimeout(resolve, ms));
1277
1342
  }
1278
1343
 
1279
- /** @type {Map<string, { child: import("node:child_process").ChildProcess, url: string | null, localUrl: string }>} */
1344
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
1280
1345
  const cloudflareTunnels = new Map();
1281
1346
 
1347
+ function stopAllCloudflare() {
1348
+ for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1349
+ }
1350
+
1351
+ function parseTryCloudflareUrl(text) {
1352
+ const match = String(text || "").match(
1353
+ /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1354
+ );
1355
+ return match ? match[0].replace(/\/$/, "") : null;
1356
+ }
1357
+
1358
+ function killProcessesByCommand(fragment) {
1359
+ if (!fragment) return Promise.resolve();
1360
+ return new Promise((resolve) => {
1361
+ const done = () => resolve();
1362
+ if (process.platform === "win32") {
1363
+ const escaped = String(fragment).replace(/'/g, "''");
1364
+ const child = spawn(
1365
+ "powershell.exe",
1366
+ [
1367
+ "-NoProfile",
1368
+ "-Command",
1369
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escaped}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
1370
+ ],
1371
+ { windowsHide: true, stdio: "ignore" }
1372
+ );
1373
+ child.on("exit", done);
1374
+ child.on("error", done);
1375
+ setTimeout(done, 8000);
1376
+ return;
1377
+ }
1378
+ const child = spawn("pkill", ["-f", String(fragment)], { stdio: "ignore" });
1379
+ child.on("exit", done);
1380
+ child.on("error", done);
1381
+ setTimeout(done, 4000);
1382
+ });
1383
+ }
1384
+
1385
+ function killPort(port) {
1386
+ const n = Number(port);
1387
+ if (!n) return Promise.resolve();
1388
+ return new Promise((resolve) => {
1389
+ const done = () => resolve();
1390
+ if (process.platform === "win32") {
1391
+ const child = spawn(
1392
+ "powershell.exe",
1393
+ [
1394
+ "-NoProfile",
1395
+ "-Command",
1396
+ `$ErrorActionPreference='SilentlyContinue'; $pids=@(); $pids += Get-NetTCPConnection -LocalPort ${n} -State Listen | Select-Object -ExpandProperty OwningProcess; if (-not $pids) { netstat -ano | Select-String ':${n}\\s' | ForEach-Object { if ($_ -match 'LISTENING\\s+(\\d+)') { $pids += [int]$Matches[1] } } }; $pids | Sort-Object -Unique | Where-Object { $_ -gt 0 -and $_ -ne ${process.pid} } | ForEach-Object { Stop-Process -Id $_ -Force }`,
1397
+ ],
1398
+ { windowsHide: true, stdio: "ignore" }
1399
+ );
1400
+ child.on("exit", done);
1401
+ child.on("error", done);
1402
+ setTimeout(done, 8000);
1403
+ return;
1404
+ }
1405
+ const child = spawn(
1406
+ "sh",
1407
+ ["-c", `pids=$(lsof -ti tcp:${n} 2>/dev/null); [ -n "$pids" ] && kill $pids`],
1408
+ { stdio: "ignore" }
1409
+ );
1410
+ child.on("exit", done);
1411
+ child.on("error", done);
1412
+ setTimeout(done, 4000);
1413
+ });
1414
+ }
1415
+
1282
1416
  function stopCloudflare(sandboxId) {
1283
1417
  const row = cloudflareTunnels.get(sandboxId);
1284
- if (row?.child && !row.child.killed) {
1418
+ const files = row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [];
1419
+ cloudflareTunnels.delete(sandboxId);
1420
+ return Promise.all(files.map((file) => killProcessesByCommand(file)));
1421
+ }
1422
+
1423
+ function forgetProcessLaunches(sandboxId) {
1424
+ for (const key of [...launchedAt.keys()]) {
1425
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
1426
+ launchedAt.delete(key);
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ async function stopWorkspaceApps(ws) {
1432
+ const folder = path.resolve(ws.folderPath || "");
1433
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1434
+ const ports = new Set();
1435
+ if (ws.port) ports.add(Number(ws.port));
1436
+ for (const job of jobs) {
1437
+ if (job.preferredPort) ports.add(Number(job.preferredPort));
1438
+ }
1439
+ if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
1285
1440
  try {
1286
- row.child.kill();
1441
+ const port = Number(new URL(ws.appUrl).port);
1442
+ if (port) ports.add(port);
1287
1443
  } catch {
1288
1444
  /* ignore */
1289
1445
  }
1290
1446
  }
1291
- cloudflareTunnels.delete(sandboxId);
1447
+ log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
1448
+ await closeRememberedTerminals(ws.sandboxId);
1449
+ for (const port of ports) {
1450
+ await killPort(port);
1451
+ }
1452
+ forgetProcessLaunches(ws.sandboxId);
1453
+ await sleep(400);
1292
1454
  }
1293
1455
 
1294
- function stopAllCloudflare() {
1295
- for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1456
+ async function waitUntilReachable(url, timeoutMs, label) {
1457
+ const start = Date.now();
1458
+ while (Date.now() - start < timeoutMs) {
1459
+ if (await probeUrl(url)) return true;
1460
+ await sleep(600);
1461
+ }
1462
+ throw new Error(`${label} did not become reachable at ${url}`);
1296
1463
  }
1297
1464
 
1298
- function localUrlForWorkspace(ws) {
1299
- if (ws.appUrl && isLocalAppUrl(ws.appUrl)) return ws.appUrl;
1300
- return `http://127.0.0.1:${Number(ws.port) || 3100}`;
1465
+ async function waitForUrlInFile(file, timeoutMs = 90_000) {
1466
+ const start = Date.now();
1467
+ while (Date.now() - start < timeoutMs) {
1468
+ if (fs.existsSync(file)) {
1469
+ const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
1470
+ if (url) return url;
1471
+ }
1472
+ await sleep(500);
1473
+ }
1474
+ throw new Error(
1475
+ `Cloudflare did not publish a URL in time (${path.basename(file)}). Check that terminal.`
1476
+ );
1301
1477
  }
1302
1478
 
1303
- function parseTryCloudflareUrl(text) {
1304
- const match = String(text || "").match(
1305
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1306
- );
1307
- return match ? match[0].replace(/\/$/, "") : null;
1479
+ function cloudflaredCommand(localUrl, logFile) {
1480
+ const logArg = JSON.stringify(logFile);
1481
+ const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate --logfile ${logArg}`;
1482
+ if (process.platform === "win32") return run;
1483
+ return `${run} 2>&1 | tee ${logArg}`;
1308
1484
  }
1309
1485
 
1310
- function spawnCloudflared(localUrl) {
1311
- const args = ["tunnel", "--url", localUrl, "--no-autoupdate"];
1312
- const trySpawn = (file, argv, extra = {}) =>
1313
- new Promise((resolve, reject) => {
1314
- const child = spawn(file, argv, {
1315
- stdio: ["ignore", "pipe", "pipe"],
1316
- windowsHide: true,
1317
- ...extra,
1318
- });
1319
- const onError = (err) => reject(err);
1320
- child.once("error", onError);
1321
- child.once("spawn", () => {
1322
- child.off("error", onError);
1323
- resolve(child);
1324
- });
1325
- });
1486
+ async function startCloudflareTerminal(ws, role, localUrl) {
1487
+ const folder = path.resolve(ws.folderPath);
1488
+ const logDir = path.join(folder, ".maintainer-pro");
1489
+ fs.mkdirSync(logDir, { recursive: true });
1490
+ const logFile = path.join(
1491
+ logDir,
1492
+ `cf-${String(ws.sandboxId).slice(0, 8)}-${role}.log`
1493
+ );
1494
+ try {
1495
+ fs.unlinkSync(logFile);
1496
+ } catch {
1497
+ /* ignore */
1498
+ }
1499
+ const opened = await openInNewTerminal({
1500
+ title: `MP-cf-${role}`,
1501
+ folder,
1502
+ command: cloudflaredCommand(localUrl, logFile),
1503
+ launchKey: `${ws.sandboxId}:cf:${role}`,
1504
+ sandboxId: ws.sandboxId,
1505
+ force: true,
1506
+ });
1507
+ if (!opened.ok) {
1508
+ throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
1509
+ }
1510
+ const publicUrl = await waitForUrlInFile(logFile);
1511
+ return { role, localUrl, publicUrl, logFile };
1512
+ }
1326
1513
 
1327
- return trySpawn("cloudflared", args).catch(() =>
1328
- trySpawn(
1329
- process.platform === "win32" ? "npx.cmd" : "npx",
1330
- ["--yes", "cloudflared", ...args],
1331
- { shell: process.platform === "win32" }
1332
- )
1514
+ function uiPublicEnv(tunnels) {
1515
+ /** @type {Record<string, string>} */
1516
+ const env = {};
1517
+ if (tunnels.ai) {
1518
+ env.AI_SERVER_URL = tunnels.ai;
1519
+ env.NEXT_PUBLIC_AI_SERVER_URL = tunnels.ai;
1520
+ env.VITE_AI_SERVER_URL = tunnels.ai;
1521
+ }
1522
+ if (tunnels.backend) {
1523
+ env.API_URL = tunnels.backend;
1524
+ env.API_BASE_URL = tunnels.backend;
1525
+ env.VITE_API_URL = tunnels.backend;
1526
+ env.VITE_API_BASE_URL = tunnels.backend;
1527
+ env.NEXT_PUBLIC_API_URL = tunnels.backend;
1528
+ env.NEXT_PUBLIC_API_BASE_URL = tunnels.backend;
1529
+ env.BACKEND_URL = tunnels.backend;
1530
+ }
1531
+ if (tunnels.ui) {
1532
+ env.APP_URL = tunnels.ui;
1533
+ env.PUBLIC_URL = tunnels.ui;
1534
+ env.CORS_ORIGIN = tunnels.ui;
1535
+ } else if (tunnels.ai) {
1536
+ env.CORS_ORIGIN = tunnels.ai;
1537
+ }
1538
+ return env;
1539
+ }
1540
+
1541
+ function writeTunnelEnv(ws, tunnels) {
1542
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1543
+ if (!folder || !fs.existsSync(folder)) return;
1544
+ const env = uiPublicEnv(tunnels);
1545
+ const lines = Object.entries(tunnels)
1546
+ .filter(([, url]) => url)
1547
+ .map(([role, url]) => `${role}=${url}`);
1548
+ fs.writeFileSync(
1549
+ path.join(folder, ".cloudflare-tunnel-url"),
1550
+ `${lines.join("\n")}\n`,
1551
+ "utf8"
1333
1552
  );
1553
+ const envPath = path.join(folder, ".env");
1554
+ if (Object.keys(env).length) mergeEnvFile(envPath, env);
1555
+ const localEnv = {};
1556
+ if (env.NEXT_PUBLIC_AI_SERVER_URL) {
1557
+ localEnv.NEXT_PUBLIC_AI_SERVER_URL = env.NEXT_PUBLIC_AI_SERVER_URL;
1558
+ }
1559
+ if (env.NEXT_PUBLIC_API_URL) {
1560
+ localEnv.NEXT_PUBLIC_API_URL = env.NEXT_PUBLIC_API_URL;
1561
+ }
1562
+ if (Object.keys(localEnv).length) {
1563
+ mergeEnvFile(path.join(folder, ".env.local"), localEnv);
1564
+ }
1334
1565
  }
1335
1566
 
1336
- function waitForTunnelUrl(child, timeoutMs = 90_000) {
1337
- return new Promise((resolve, reject) => {
1338
- let buffer = "";
1339
- let settled = false;
1340
- const done = (err, url) => {
1341
- if (settled) return;
1342
- settled = true;
1343
- clearTimeout(timer);
1344
- if (err) reject(err);
1345
- else resolve(url);
1346
- };
1347
- const onData = (chunk) => {
1348
- buffer += String(chunk);
1349
- const url = parseTryCloudflareUrl(buffer);
1350
- if (url) done(null, url);
1351
- };
1352
- child.stdout?.on("data", onData);
1353
- child.stderr?.on("data", onData);
1354
- child.once("exit", (code) => {
1355
- done(
1356
- new Error(
1357
- `cloudflared exited ${code ?? "early"} before it published a URL`
1358
- )
1359
- );
1360
- });
1361
- const timer = setTimeout(() => {
1362
- done(
1363
- new Error(
1364
- "Cloudflare did not publish a URL in time. Is cloudflared installed and online?"
1365
- )
1366
- );
1367
- }, timeoutMs);
1368
- });
1567
+ function reservedPortsFor(cfg, sandboxId) {
1568
+ const reserved = new Set();
1569
+ for (const other of cfg.workspaces || []) {
1570
+ if (other.sandboxId !== sandboxId && other.port) {
1571
+ reserved.add(Number(other.port));
1572
+ }
1573
+ }
1574
+ return reserved;
1575
+ }
1576
+
1577
+ function appsWanted(ws) {
1578
+ return Boolean(ws?.appsRequested);
1369
1579
  }
1370
1580
 
1371
1581
  async function configureCloudflareForWorkspace(ws, cfg) {
1372
1582
  const sandboxId = ws.sandboxId;
1373
1583
  const label = ws.sandboxName || "this sandbox";
1374
- const localUrl = localUrlForWorkspace(ws);
1375
- const existing = cloudflareTunnels.get(sandboxId);
1376
- if (existing?.url && existing.localUrl === localUrl && existing.child && !existing.child.killed) {
1377
- return {
1378
- sandboxId,
1379
- folderPath: ws.folderPath,
1380
- appUrl: existing.url,
1381
- origins: [existing.url],
1382
- localUrl,
1383
- cloudflare: true,
1384
- reused: true,
1385
- };
1386
- }
1387
- if (existing) stopCloudflare(sandboxId);
1388
1584
 
1389
- log(`starting Cloudflare tunnel for ${label} ${localUrl}`);
1390
- let child;
1585
+ log(`Cloudflare queued for ${label}: stop apps and wait for Start`);
1586
+ await stopCloudflare(sandboxId);
1587
+ await stopWorkspaceApps(ws);
1588
+ await forgetLaunch(sandboxId);
1589
+ ws.cloudflarePending = true;
1590
+ ws.appsRequested = false;
1591
+ persistWorkspaceEntry(cfg, ws);
1592
+
1593
+ return {
1594
+ sandboxId,
1595
+ folderPath: ws.folderPath,
1596
+ port: ws.port,
1597
+ pending: true,
1598
+ cloudflarePending: true,
1599
+ waitingForStart: true,
1600
+ warning:
1601
+ "Cloudflare is ready in Maintainer Pro. Use Start chat server when you want to launch the apps and create the public URLs.",
1602
+ };
1603
+ }
1604
+
1605
+ async function launchCloudflareTunnels(ws, cfg) {
1606
+ const sandboxId = ws.sandboxId;
1607
+ const label = ws.sandboxName || "this sandbox";
1608
+ const folder = path.resolve(ws.folderPath || "");
1609
+ const reserved = reservedPortsFor(cfg, sandboxId);
1610
+
1611
+ log(`Cloudflare start for ${label}: tunnel chat script/backend before UI`);
1391
1612
  try {
1392
- child = await spawnCloudflared(localUrl);
1393
- } catch (err) {
1394
- const message = err instanceof Error ? err.message : String(err);
1395
- recordProcessProblem({
1396
- sandboxId,
1397
- code: "cloudflare_launch",
1398
- role: "tunnel",
1399
- title: `Could not start Cloudflare (${label})`,
1400
- message,
1401
- resolution:
1402
- "Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) or allow npx to download it, then try again.",
1403
- actionCode: "configure_cloudflare",
1404
- });
1405
- throw new Error(
1406
- `Could not start cloudflared. Install it or allow npx to download it. ${message}`
1613
+ if (!cfg.noAiServer) {
1614
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1615
+ await waitUntilReachable(
1616
+ `http://127.0.0.1:${ws.port}/embed-config.js`,
1617
+ 45_000,
1618
+ "Chat script"
1407
1619
  );
1408
1620
  }
1409
1621
 
1410
- cloudflareTunnels.set(sandboxId, { child, url: null, localUrl });
1411
- child.once("exit", () => {
1412
- const row = cloudflareTunnels.get(sandboxId);
1413
- if (row?.child === child) cloudflareTunnels.delete(sandboxId);
1414
- });
1622
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1623
+ const backendJob = jobs.find((job) => job.role === "backend");
1624
+ const uiJob = jobs.find((job) => job.role === "ui" || job.role === "app");
1625
+
1626
+ if (backendJob) {
1627
+ await ensureHostProcesses(ws, {
1628
+ reserved,
1629
+ cfg,
1630
+ onlyRoles: ["backend"],
1631
+ force: true,
1632
+ });
1633
+ const backendPort = Number(backendJob.preferredPort) || 4100;
1634
+ await waitUntilReachable(
1635
+ `http://127.0.0.1:${backendPort}`,
1636
+ 45_000,
1637
+ "Backend"
1638
+ );
1639
+ }
1415
1640
 
1416
- const publicUrl = await waitForTunnelUrl(child);
1417
- cloudflareTunnels.set(sandboxId, { child, url: publicUrl, localUrl });
1418
- child.unref();
1641
+ /** @type {Record<string, string>} */
1642
+ const tunnels = {};
1643
+ /** @type {Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }>} */
1644
+ const started = [];
1419
1645
 
1420
- ws.cloudflareUrl = publicUrl;
1421
- ws.appUrl = publicUrl;
1422
- const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1423
- if (folder && fs.existsSync(folder)) {
1424
- fs.writeFileSync(
1425
- path.join(folder, ".cloudflare-tunnel-url"),
1426
- `${publicUrl}\n`,
1427
- "utf8"
1646
+ const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
1647
+ const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal);
1648
+ tunnels.ai = aiTunnel.publicUrl;
1649
+ started.push(aiTunnel);
1650
+ log(`Cloudflare chat script: ${aiTunnel.publicUrl}`);
1651
+
1652
+ if (backendJob) {
1653
+ const backendLocal = `http://127.0.0.1:${Number(backendJob.preferredPort) || 4100}`;
1654
+ const backendTunnel = await startCloudflareTerminal(ws, "backend", backendLocal);
1655
+ tunnels.backend = backendTunnel.publicUrl;
1656
+ started.push(backendTunnel);
1657
+ log(`Cloudflare backend: ${backendTunnel.publicUrl}`);
1658
+ }
1659
+
1660
+ writeTunnelEnv(ws, tunnels);
1661
+ const uiEnv = uiPublicEnv(tunnels);
1662
+
1663
+ if (uiJob) {
1664
+ await ensureHostProcesses(ws, {
1665
+ reserved,
1666
+ cfg,
1667
+ onlyRoles: ["ui", "app"],
1668
+ extraEnv: uiEnv,
1669
+ force: true,
1670
+ });
1671
+ const uiPort = Number(uiJob.preferredPort) || 5173;
1672
+ await waitUntilReachable(`http://127.0.0.1:${uiPort}`, 60_000, "App UI");
1673
+ const uiTunnel = await startCloudflareTerminal(
1674
+ ws,
1675
+ "ui",
1676
+ `http://127.0.0.1:${uiPort}`
1428
1677
  );
1429
- const envPath = path.join(folder, ".env");
1430
- const envValues = {
1431
- APP_URL: publicUrl,
1432
- PUBLIC_URL: publicUrl,
1433
- CORS_ORIGIN: publicUrl,
1434
- };
1435
- if (isAiServerTarget(ws, localUrl)) {
1436
- envValues.AI_SERVER_URL = publicUrl;
1437
- envValues.NEXT_PUBLIC_AI_SERVER_URL = publicUrl;
1438
- }
1439
- if (fs.existsSync(envPath)) mergeEnvFile(envPath, envValues);
1678
+ tunnels.ui = uiTunnel.publicUrl;
1679
+ started.push(uiTunnel);
1680
+ log(`Cloudflare UI: ${uiTunnel.publicUrl}`);
1681
+ writeTunnelEnv(ws, tunnels);
1682
+ launchedAt.delete(`${sandboxId}:${folder}:ai`);
1683
+ await killPort(ws.port);
1684
+ await sleep(1500);
1685
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1440
1686
  }
1687
+
1688
+ const appUrl = tunnels.ui || tunnels.ai;
1689
+ ws.cloudflareUrl = appUrl;
1690
+ ws.cloudflare = tunnels;
1691
+ ws.appUrl = appUrl;
1692
+ ws.cloudflarePending = false;
1693
+ ws.appsRequested = true;
1441
1694
  persistWorkspaceEntry(cfg, ws);
1695
+ cloudflareTunnels.set(sandboxId, { tunnels: started });
1442
1696
  clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
1443
- log(`Cloudflare URL for ${label}: ${publicUrl}`);
1697
+
1444
1698
  return {
1445
1699
  sandboxId,
1446
1700
  folderPath: ws.folderPath,
1447
- appUrl: publicUrl,
1448
- origins: [publicUrl],
1449
- localUrl,
1701
+ port: ws.port,
1702
+ appUrl,
1703
+ origins: Object.values(tunnels).filter(Boolean),
1704
+ tunnels,
1450
1705
  cloudflare: true,
1451
1706
  reused: false,
1452
1707
  };
1708
+ } catch (err) {
1709
+ const message = err instanceof Error ? err.message : String(err);
1710
+ recordProcessProblem({
1711
+ sandboxId,
1712
+ code: "cloudflare_launch",
1713
+ role: "tunnel",
1714
+ title: `Could not start Cloudflare (${label})`,
1715
+ message,
1716
+ resolution:
1717
+ "Install cloudflared or allow npx to download it, then use Start chat server again.",
1718
+ actionCode: "start_ai_server",
1719
+ });
1720
+ throw err;
1721
+ }
1453
1722
  }
1454
1723
 
1455
- function isAiServerTarget(ws, localUrl) {
1456
- try {
1457
- const port = Number(new URL(localUrl).port);
1458
- return port === Number(ws.port);
1459
- } catch {
1460
- return true;
1724
+ async function startAppsForWorkspace(ws, cfg) {
1725
+ if (ws.cloudflarePending) {
1726
+ return launchCloudflareTunnels(ws, cfg);
1727
+ }
1728
+ ws.appsRequested = true;
1729
+ persistWorkspaceEntry(cfg, ws);
1730
+ const reserved = reservedPortsFor(cfg, ws.sandboxId);
1731
+ if (!cfg.noAiServer) {
1732
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1733
+ await sleep(1500);
1734
+ }
1735
+ const startedHosts = await ensureHostProcesses(ws, {
1736
+ reserved,
1737
+ cfg,
1738
+ force: true,
1739
+ });
1740
+ await sleep(800);
1741
+ await inspectHostJobs(ws);
1742
+ const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
1743
+ if (up) {
1744
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1745
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
1461
1746
  }
1747
+ const processIssues = issuesForSandbox(ws.sandboxId).map(
1748
+ ({ role: _role, ...issue }) => issue
1749
+ );
1750
+ const warning = processIssues[0]?.message || null;
1751
+ return {
1752
+ up,
1753
+ startedHosts,
1754
+ sandboxId: ws.sandboxId,
1755
+ folderPath: ws.folderPath,
1756
+ port: ws.port,
1757
+ appUrl: ws.appUrl,
1758
+ processIssues,
1759
+ warning,
1760
+ };
1462
1761
  }
1463
1762
 
1464
1763
  async function ensureHostProcesses(ws, opts = {}) {
@@ -1466,7 +1765,13 @@ async function ensureHostProcesses(ws, opts = {}) {
1466
1765
  const cfg = opts.cfg || null;
1467
1766
  const folder = path.resolve(ws.folderPath);
1468
1767
  const scripts = readPackageJson(folder)?.scripts || {};
1469
- const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1768
+ const jobs = planHostJobs(
1769
+ folder,
1770
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1771
+ ws.projectInfo
1772
+ );
1773
+ const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
1774
+ const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
1470
1775
  const started = [];
1471
1776
  let persisted = false;
1472
1777
  const label = ws.sandboxName || "this sandbox";
@@ -1484,6 +1789,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1484
1789
  }
1485
1790
 
1486
1791
  for (const job of jobs) {
1792
+ if (onlyRoles && !onlyRoles.has(job.role)) continue;
1487
1793
  const preferred = Number(job.preferredPort) || 3000;
1488
1794
  const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1489
1795
  "localhost",
@@ -1497,7 +1803,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1497
1803
  continue;
1498
1804
  }
1499
1805
  const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1500
- if (recentlyLaunched(launchKey)) {
1806
+ if (!opts.force && recentlyLaunched(launchKey)) {
1501
1807
  reserved.add(preferred);
1502
1808
  continue;
1503
1809
  }
@@ -1531,8 +1837,10 @@ async function ensureHostProcesses(ws, opts = {}) {
1531
1837
  title: `MP-${job.role}-${port}`,
1532
1838
  folder,
1533
1839
  command,
1534
- env: { PORT: String(port) },
1840
+ env: { PORT: String(port), ...extraEnv },
1535
1841
  launchKey,
1842
+ sandboxId: ws.sandboxId,
1843
+ force: Boolean(opts.force),
1536
1844
  });
1537
1845
  if (opened.skipped) continue;
1538
1846
  if (!opened.ok) {
@@ -1563,7 +1871,11 @@ async function ensureHostProcesses(ws, opts = {}) {
1563
1871
 
1564
1872
  async function inspectHostJobs(ws) {
1565
1873
  const folder = path.resolve(ws.folderPath || "");
1566
- const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1874
+ const jobs = planHostJobs(
1875
+ folder,
1876
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1877
+ ws.projectInfo
1878
+ );
1567
1879
  const label = ws.sandboxName || "this sandbox";
1568
1880
  const hosts = [];
1569
1881
  for (const job of jobs) {
@@ -1587,6 +1899,11 @@ async function inspectHostJobs(ws) {
1587
1899
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1588
1900
  continue;
1589
1901
  }
1902
+ if (!appsWanted(ws)) {
1903
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1904
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1905
+ continue;
1906
+ }
1590
1907
  if (starting) continue;
1591
1908
  const tried = launchedAt.has(launchKey);
1592
1909
  recordProcessProblem({
@@ -1687,6 +2004,8 @@ async function setupWorkspace(cfg, action) {
1687
2004
  clientKind: client.kind,
1688
2005
  appUrl,
1689
2006
  sameOrigin: Boolean(client.sameOrigin),
2007
+ appsRequested: false,
2008
+ cloudflarePending: false,
1690
2009
  };
1691
2010
  if (existing >= 0) cfg.workspaces[existing] = entry;
1692
2011
  else cfg.workspaces.push(entry);
@@ -1705,12 +2024,6 @@ async function setupWorkspace(cfg, action) {
1705
2024
  .join("\n"),
1706
2025
  });
1707
2026
 
1708
- if (!cfg.noAiServer) {
1709
- await startAiServerForWorkspace(entry, { reserved, cfg });
1710
- await sleep(1500);
1711
- }
1712
- const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
1713
- await sleep(800);
1714
2027
  await inspectHostJobs(entry);
1715
2028
 
1716
2029
  const openUrl = client.sameOrigin
@@ -1719,23 +2032,22 @@ async function setupWorkspace(cfg, action) {
1719
2032
  const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
1720
2033
  if (aiServerUp) {
1721
2034
  clearProcessProblem(sandboxId, "ai_server_launch", "ai");
2035
+ clearProcessProblem(sandboxId, "apps_not_started");
1722
2036
  }
1723
2037
 
1724
2038
  const processIssues = issuesForSandbox(sandboxId).map(
1725
2039
  ({ role: _role, ...issue }) => issue
1726
2040
  );
1727
- const warning = processIssues[0]?.message || null;
2041
+ const waitingForStart = !aiServerUp;
2042
+ const warning = waitingForStart
2043
+ ? "Folder is attached in Maintainer Pro. Use Start chat server when you want to launch the apps."
2044
+ : processIssues[0]?.message || null;
1728
2045
 
1729
2046
  for (const note of client.notes) log(note);
1730
- if (startedHosts.length) {
1731
- log(`started ${startedHosts.join(" + ")} in separate terminals`);
1732
- }
1733
- if (aiServerUp) {
1734
- log(`ai-server up — open ${openUrl}`);
1735
- } else if (warning) {
1736
- log(`process issue: ${warning}`);
2047
+ if (waitingForStart) {
2048
+ log(`folder attached waiting for Start (${openUrl})`);
1737
2049
  } else {
1738
- log("ai-server not reachable yet; it may still be starting");
2050
+ log(`ai-server already up open ${openUrl}`);
1739
2051
  }
1740
2052
 
1741
2053
  return {
@@ -1749,10 +2061,11 @@ async function setupWorkspace(cfg, action) {
1749
2061
  clientFiles: client.filesWritten,
1750
2062
  clientNotes: client.notes,
1751
2063
  aiServerUp,
1752
- startedHosts,
2064
+ startedHosts: [],
1753
2065
  openUrl,
1754
2066
  processIssues,
1755
2067
  warning,
2068
+ waitingForStart,
1756
2069
  projectInfo,
1757
2070
  };
1758
2071
  }
@@ -1794,12 +2107,6 @@ async function runActions(cfg, actions) {
1794
2107
  ok = false;
1795
2108
  result = { error: "No workspace or --no-ai-server" };
1796
2109
  } else {
1797
- const reserved = new Set();
1798
- for (const other of cfg.workspaces || []) {
1799
- if (other.sandboxId !== ws.sandboxId && other.port) {
1800
- reserved.add(Number(other.port));
1801
- }
1802
- }
1803
2110
  const problem = issuesForSandbox(ws.sandboxId)
1804
2111
  .map((issue) => issue.message)
1805
2112
  .join("\n");
@@ -1809,32 +2116,15 @@ async function runActions(cfg, actions) {
1809
2116
  problem ||
1810
2117
  "Local processes are not running or the project setup looks incomplete.",
1811
2118
  });
1812
- await startAiServerForWorkspace(ws, { reserved, cfg });
1813
- await sleep(1500);
1814
- const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
1815
- await sleep(800);
1816
- await inspectHostJobs(ws);
1817
- const up = await probeUrl(
1818
- `http://127.0.0.1:${ws.port}/embed-config.js`
1819
- );
1820
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1821
- const processIssues = issuesForSandbox(ws.sandboxId).map(
1822
- ({ role: _role, ...issue }) => issue
1823
- );
1824
- const warning = processIssues[0]?.message || null;
1825
2119
  result = {
1826
- up,
1827
- startedHosts,
1828
- sandboxId: ws.sandboxId,
1829
- folderPath: ws.folderPath,
1830
- port: ws.port,
1831
- appUrl: ws.appUrl,
1832
- processIssues,
1833
- warning,
2120
+ ...(await startAppsForWorkspace(ws, cfg)),
1834
2121
  projectInfo,
1835
2122
  };
1836
- if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
1837
- result.error = warning;
2123
+ if (
2124
+ Array.isArray(result.processIssues) &&
2125
+ result.processIssues.some((issue) => issue.code === "ai_server_launch")
2126
+ ) {
2127
+ result.error = result.warning;
1838
2128
  ok = false;
1839
2129
  }
1840
2130
  }
@@ -1875,7 +2165,7 @@ async function runActions(cfg, actions) {
1875
2165
  const sandboxId = String(
1876
2166
  action.sandboxId || action.payload?.sandboxId || ""
1877
2167
  );
1878
- forgetLaunch(sandboxId);
2168
+ await forgetLaunch(sandboxId);
1879
2169
  cfg.workspaces = (cfg.workspaces || []).filter(
1880
2170
  (w) => w.sandboxId !== sandboxId
1881
2171
  );
@@ -1924,6 +2214,7 @@ async function collectWorkspaceStates(cfg) {
1924
2214
  aiServerUp: up,
1925
2215
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
1926
2216
  appUrl: ws.appUrl || null,
2217
+ appsRequested: appsWanted(ws),
1927
2218
  });
1928
2219
  }
1929
2220
  return localStates;
@@ -1946,6 +2237,7 @@ async function sendHeartbeat(cfg, folders, localStates) {
1946
2237
  aiServerUp: st.aiServerUp,
1947
2238
  port: st.port,
1948
2239
  appUrl: st.appUrl || undefined,
2240
+ appsRequested: Boolean(st.appsRequested),
1949
2241
  })),
1950
2242
  }
1951
2243
  );
@@ -1969,6 +2261,19 @@ async function buildIssues(cfg, workspaceStates) {
1969
2261
  }
1970
2262
  for (const st of workspaceStates) {
1971
2263
  if (st.aiServerUp || st.startingAi) continue;
2264
+ if (!st.appsRequested) {
2265
+ issues.push({
2266
+ code: "apps_not_started",
2267
+ severity: "info",
2268
+ title: `Apps are not running (${st.sandboxName || "sandbox"})`,
2269
+ message:
2270
+ "This folder is attached in Maintainer Pro. Use Start chat server when you want to launch the local apps.",
2271
+ resolution: "Use Start chat server.",
2272
+ actionCode: "start_ai_server",
2273
+ sandboxId: st.sandboxId,
2274
+ });
2275
+ continue;
2276
+ }
1972
2277
  const launch = [...processProblems.values()].find(
1973
2278
  (issue) =>
1974
2279
  issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
@@ -2105,13 +2410,18 @@ async function main() {
2105
2410
  const up = await probeUrl(
2106
2411
  `http://127.0.0.1:${ws.port}/embed-config.js`
2107
2412
  );
2108
- if (!cfg.noAiServer && !up) {
2413
+ if (appsWanted(ws) && !cfg.noAiServer && !up) {
2109
2414
  await startAiServerForWorkspace(ws, { reserved, cfg });
2110
2415
  } else if (ws.port) {
2111
2416
  reserved.add(Number(ws.port));
2112
- if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2417
+ if (up) {
2418
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2419
+ clearProcessProblem(ws.sandboxId, "apps_not_started");
2420
+ }
2421
+ }
2422
+ if (appsWanted(ws)) {
2423
+ await ensureHostProcesses(ws, { reserved, cfg });
2113
2424
  }
2114
- await ensureHostProcesses(ws, { reserved, cfg });
2115
2425
  await inspectHostJobs(ws);
2116
2426
  localStates.push({
2117
2427
  sandboxId: ws.sandboxId,
@@ -2121,6 +2431,7 @@ async function main() {
2121
2431
  aiServerUp: up,
2122
2432
  startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2123
2433
  appUrl: ws.appUrl || null,
2434
+ appsRequested: appsWanted(ws),
2124
2435
  });
2125
2436
  }
2126
2437