@maintainer-pro/ai-bridge 0.1.1 → 0.1.3

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 +4 -1
  2. package/src/daemon.mjs +1391 -111
package/src/daemon.mjs CHANGED
@@ -7,18 +7,20 @@
7
7
  * npx @maintainer-pro/ai-bridge
8
8
  * npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
9
9
  */
10
- import { spawn, execFileSync } from "node:child_process";
10
+ import { spawn } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import fs from "node:fs";
13
13
  import http from "node:http";
14
+ import net from "node:net";
14
15
  import os from "node:os";
15
16
  import path from "node:path";
16
17
  import readline from "node:readline";
17
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
19
 
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
21
  const PACKAGE_VERSION = readPackageVersion();
21
22
  const HEARTBEAT_MS = 15_000;
23
+ const ACTION_POLL_MS = 2_000;
22
24
 
23
25
  const log = (msg) => console.log(`[bridge] ${msg}`);
24
26
  const warn = (msg) => console.warn(`[bridge] ${msg}`);
@@ -85,7 +87,7 @@ Flags:
85
87
  --pair <code> Claim a pair code from admin → Bridges page
86
88
  --admin-url <url> Maintainer Pro base URL (required for first pair)
87
89
  --offer-folder <path> Suggest this folder in admin (repeatable via config)
88
- --no-ai-server Do not start sidecars for workspaces
90
+ --no-ai-server Do not open ai-server terminals for workspaces
89
91
  --help
90
92
  `);
91
93
  }
@@ -161,26 +163,161 @@ async function api(baseUrl, token, method, pathname, body) {
161
163
  return data;
162
164
  }
163
165
 
164
- function commandExists(cmd) {
166
+ /** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
167
+ let aiCliModule = null;
168
+
169
+ async function loadAiCli() {
170
+ if (!aiCliModule) {
171
+ aiCliModule = (async () => {
172
+ try {
173
+ return await import("@maintainer-pro/ai-cli");
174
+ } catch {
175
+ const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
176
+ if (fs.existsSync(local)) {
177
+ return await import(pathToFileURL(local).href);
178
+ }
179
+ throw new Error("@maintainer-pro/ai-cli is not installed");
180
+ }
181
+ })();
182
+ }
183
+ return aiCliModule;
184
+ }
185
+
186
+ async function detectCliProviders() {
165
187
  try {
166
- if (process.platform === "win32") {
167
- execFileSync("where", [cmd], { stdio: "ignore" });
188
+ const { resolveProvider } = await loadAiCli();
189
+ const provider = await resolveProvider({ preference: "auto" });
190
+ return [provider.id];
191
+ } catch {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ function applyProjectInfo(ws, info, cfg) {
197
+ if (!ws || !info) return;
198
+ ws.projectInfo = {
199
+ kind: info.kind,
200
+ name: info.name,
201
+ summary: info.summary,
202
+ scripts: info.scripts || {},
203
+ ports: info.ports || {},
204
+ issues: info.issues || [],
205
+ fixes: info.fixes || [],
206
+ ready: Boolean(info.ready),
207
+ provider: info.provider,
208
+ };
209
+ if (info.kind) ws.clientKind = info.kind;
210
+ const uiPort = Number(info.ports?.ui || info.ports?.app);
211
+ if (
212
+ uiPort &&
213
+ (!ws.appUrl || isLocalAppUrl(ws.appUrl))
214
+ ) {
215
+ ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${uiPort}`, uiPort);
216
+ }
217
+ persistWorkspaceEntry(cfg, ws);
218
+ }
219
+
220
+ async function inspectProjectWithAiCli(ws, opts = {}) {
221
+ const folder = path.resolve(ws.folderPath || "");
222
+ const label = ws.sandboxName || ws.applicationName || "this sandbox";
223
+ try {
224
+ const { inspectAndRepairWorkspace } = await loadAiCli();
225
+ log(`asking ai-cli to inspect ${folder}`);
226
+ const info = await inspectAndRepairWorkspace({
227
+ workspaceDir: folder,
228
+ appName: ws.applicationName || ws.sandboxName || undefined,
229
+ problem: opts.problem,
230
+ extraContext: opts.extraContext,
231
+ });
232
+ applyProjectInfo(ws, info, opts.cfg);
233
+ if (info.issues?.length) {
234
+ recordProcessProblem({
235
+ sandboxId: ws.sandboxId,
236
+ code: "project_issue",
237
+ role: "inspect",
238
+ title: `Project needs attention (${label})`,
239
+ message: info.issues.join(" "),
240
+ resolution: info.fixes?.length
241
+ ? info.fixes.join(" ")
242
+ : "Fix the project in that folder, then use Start chat server.",
243
+ });
168
244
  } else {
169
- execFileSync("which", [cmd], { stdio: "ignore" });
245
+ clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
170
246
  }
171
- return true;
247
+ if (info.summary) log(`ai-cli: ${info.summary}`);
248
+ if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
249
+ return info;
250
+ } catch (err) {
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ warn(`ai-cli inspect failed: ${message}`);
253
+ recordProcessProblem({
254
+ sandboxId: ws.sandboxId,
255
+ code: "project_issue",
256
+ role: "inspect",
257
+ title: `Could not inspect the project (${label})`,
258
+ message,
259
+ resolution:
260
+ "Install and authenticate a coding-agent CLI (agent, claude, or agy), then Check connection.",
261
+ actionCode: "recheck",
262
+ });
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function isPortFree(port) {
268
+ return new Promise((resolve) => {
269
+ const server = net.createServer();
270
+ server.unref();
271
+ server.once("error", () => resolve(false));
272
+ server.once("listening", () => {
273
+ server.close(() => resolve(true));
274
+ });
275
+ server.listen(port);
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Prefer `preferred` if it is free and not already reserved this launch.
281
+ * Walks upward so bind failures are avoided without a central port service.
282
+ */
283
+ async function findFreePort(preferred = 3100, reserved = new Set()) {
284
+ let port = Math.max(1024, Number(preferred) || 3100);
285
+ while (port <= 49151) {
286
+ if (!reserved.has(port) && (await isPortFree(port))) {
287
+ reserved.add(port);
288
+ return port;
289
+ }
290
+ port += 1;
291
+ }
292
+ throw new Error("No free TCP port found");
293
+ }
294
+
295
+ function urlWithPort(url, port) {
296
+ try {
297
+ const parsed = new URL(url);
298
+ parsed.port = String(port);
299
+ return parsed.toString().replace(/\/$/, "");
172
300
  } catch {
173
- return false;
301
+ return `http://localhost:${port}`;
174
302
  }
175
303
  }
176
304
 
177
- function detectCliProviders() {
178
- /** @type {string[]} */
179
- const found = [];
180
- if (commandExists("agent")) found.push("agent");
181
- if (commandExists("claude")) found.push("claude");
182
- if (commandExists("agy")) found.push("agy");
183
- return found;
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;
311
+ }
312
+ }
313
+
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);
184
321
  }
185
322
 
186
323
  function probeUrl(url, timeoutMs = 2500) {
@@ -207,10 +344,46 @@ function probeUrl(url, timeoutMs = 2500) {
207
344
  });
208
345
  }
209
346
 
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 */
356
+ }
357
+ }
358
+ return roots.length ? roots : ["C:\\"];
359
+ }
360
+
210
361
  function listDirEntries(dirPath) {
211
- const resolved = path.resolve(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
+ })),
375
+ };
376
+ }
377
+
378
+ const resolved = path.resolve(raw);
212
379
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
213
- return { error: "Not a directory", path: resolved, entries: [] };
380
+ return {
381
+ error: "Not a directory",
382
+ path: resolved,
383
+ parent: path.dirname(resolved),
384
+ home,
385
+ entries: [],
386
+ };
214
387
  }
215
388
  const names = fs.readdirSync(resolved);
216
389
  const entries = [];
@@ -232,7 +405,13 @@ function listDirEntries(dirPath) {
232
405
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
233
406
  return a.name.localeCompare(b.name);
234
407
  });
235
- return { path: resolved, entries: entries.slice(0, 200) };
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
+ };
236
415
  }
237
416
 
238
417
  function mergeEnvFile(file, values) {
@@ -665,26 +844,391 @@ function collectOfferedFolders(cfg) {
665
844
  return folders;
666
845
  }
667
846
 
668
- /** @type {Map<string, import('node:child_process').ChildProcess>} */
669
- const children = new Map();
847
+ /** Prevents opening a new window on every heartbeat while a process is starting. */
848
+ const launchedAt = new Map();
849
+
850
+ /** Last process problems to send on heartbeat. Key: sandboxId::code::role */
851
+ const processProblems = new Map();
670
852
 
671
- function stopChild(sandboxId) {
672
- const child = children.get(sandboxId);
673
- if (!child || child.killed) {
674
- children.delete(sandboxId);
675
- return;
853
+ function forgetLaunch(sandboxId) {
854
+ for (const key of [...launchedAt.keys()]) {
855
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
856
+ launchedAt.delete(key);
857
+ }
858
+ }
859
+ for (const key of [...processProblems.keys()]) {
860
+ if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
676
861
  }
862
+ stopCloudflare(sandboxId);
863
+ }
864
+
865
+ function problemKey(sandboxId, code, role = "") {
866
+ return `${sandboxId || ""}::${code}::${role}`;
867
+ }
868
+
869
+ function clipIssueText(text, max) {
870
+ const value = String(text || "").trim();
871
+ if (value.length <= max) return value;
872
+ return `${value.slice(0, max - 1)}…`;
873
+ }
874
+
875
+ function processRoleLabel(role) {
876
+ if (role === "ui") return "app UI";
877
+ if (role === "backend") return "backend";
878
+ if (role === "ai") return "chat script";
879
+ return "app";
880
+ }
881
+
882
+ function recordProcessProblem(issue) {
883
+ const sandboxId = issue.sandboxId || "";
884
+ const role = issue.role || "";
885
+ processProblems.set(problemKey(sandboxId, issue.code, role), {
886
+ code: issue.code,
887
+ role,
888
+ severity: issue.severity || "error",
889
+ title: clipIssueText(issue.title, 200),
890
+ message: clipIssueText(issue.message, 2000),
891
+ resolution: clipIssueText(issue.resolution || "", 2000),
892
+ actionCode: issue.actionCode ?? "start_ai_server",
893
+ sandboxId: sandboxId || null,
894
+ });
895
+ warn(`${issue.title}: ${issue.message}`);
896
+ }
897
+
898
+ function clearProcessProblem(sandboxId, code, role = "") {
899
+ processProblems.delete(problemKey(sandboxId, code, role));
900
+ }
901
+
902
+ function issuesForSandbox(sandboxId) {
903
+ return [...processProblems.values()].filter(
904
+ (issue) => issue.sandboxId === sandboxId
905
+ );
906
+ }
907
+
908
+ function recentlyLaunched(launchKey) {
909
+ const last = launchedAt.get(launchKey) ?? 0;
910
+ return last > 0 && Date.now() - last < 60_000;
911
+ }
912
+
913
+ function readPackageJson(dir) {
677
914
  try {
678
- child.kill("SIGTERM");
915
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
679
916
  } catch {
680
- /* ignore */
917
+ return null;
681
918
  }
682
- children.delete(sandboxId);
683
919
  }
684
920
 
685
- function startAiServerForWorkspace(ws) {
686
- const key = ws.sandboxId;
687
- if (children.has(key) && !children.get(key)?.killed) return;
921
+ function isChatScript(name, command) {
922
+ const text = `${name} ${command}`;
923
+ return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(text);
924
+ }
925
+
926
+ function isUiCommand(command) {
927
+ return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
928
+ command
929
+ );
930
+ }
931
+
932
+ function portFromText(text, fallback) {
933
+ const match =
934
+ String(text || "").match(/--port(?:\s+|=)(\d{2,5})/i) ||
935
+ String(text || "").match(/\bPORT[=:]\s*(\d{2,5})/i) ||
936
+ String(text || "").match(/:(\d{2,5})\b/);
937
+ return match ? Number(match[1]) : fallback;
938
+ }
939
+
940
+ /**
941
+ * @returns {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>}
942
+ */
943
+ function planHostJobs(dir, appUrl, hints = null) {
944
+ const pkg = readPackageJson(dir);
945
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
946
+ const hinted = hints?.scripts && typeof hints.scripts === "object" ? hints.scripts : {};
947
+ const hintedPorts = hints?.ports && typeof hints.ports === "object" ? hints.ports : {};
948
+ const pickHint = (name) =>
949
+ name && scripts[name] && !isChatScript(name, scripts[name]) ? name : null;
950
+ const pick = (names) =>
951
+ names.find((name) => scripts[name] && !isChatScript(name, scripts[name]));
952
+
953
+ const uiScript =
954
+ pickHint(hinted.ui) ||
955
+ pick([
956
+ "dev:client",
957
+ "dev:ui",
958
+ "dev:web",
959
+ "dev:frontend",
960
+ "client",
961
+ "start:client",
962
+ ]);
963
+ const apiScript =
964
+ pickHint(hinted.backend) ||
965
+ pick([
966
+ "dev:server",
967
+ "dev:backend",
968
+ "dev:api",
969
+ "server",
970
+ "start:server",
971
+ ]);
972
+
973
+ /** @type {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>} */
974
+ const jobs = [];
975
+ const addJob = (role, script, fallbackPort, probe) => {
976
+ if (!script || jobs.some((job) => job.script === script)) return;
977
+ const port = portFromText(scripts[script] || probe, fallbackPort);
978
+ jobs.push({
979
+ role,
980
+ script,
981
+ command: `npm run ${script}`,
982
+ preferredPort: port,
983
+ probeUrl: probe || `http://127.0.0.1:${port}`,
984
+ });
985
+ };
986
+
987
+ addJob("ui", uiScript, Number(hintedPorts.ui) || 5173, appUrl || null);
988
+ addJob("backend", apiScript, Number(hintedPorts.backend) || 4100, null);
989
+ if (jobs.length === 0 && pickHint(hinted.app)) {
990
+ addJob(
991
+ isUiCommand(scripts[hinted.app]) ? "ui" : "app",
992
+ hinted.app,
993
+ Number(hintedPorts.app) || 3000,
994
+ appUrl || null
995
+ );
996
+ }
997
+
998
+ if (scripts.dev && !isChatScript("dev", scripts.dev)) {
999
+ const nested = [
1000
+ ...String(scripts.dev).matchAll(/npm run ([a-zA-Z0-9:_-]+)/g),
1001
+ ]
1002
+ .map((match) => match[1])
1003
+ .filter(
1004
+ (name) =>
1005
+ name !== "dev" && scripts[name] && !isChatScript(name, scripts[name])
1006
+ );
1007
+ if (nested.length >= 2) {
1008
+ for (const name of nested) {
1009
+ const role = /client|ui|web|frontend/i.test(name)
1010
+ ? "ui"
1011
+ : /server|backend|api/i.test(name)
1012
+ ? "backend"
1013
+ : name;
1014
+ addJob(role, name, 3000, role === "ui" ? appUrl || null : null);
1015
+ }
1016
+ } else if (jobs.length === 0) {
1017
+ addJob(isUiCommand(scripts.dev) ? "ui" : "app", "dev", 3000, appUrl || null);
1018
+ }
1019
+ }
1020
+ return jobs;
1021
+ }
1022
+
1023
+ function friendlyLaunchError(raw, title) {
1024
+ const text = String(raw || "").trim();
1025
+ if (/ENOENT/i.test(text)) {
1026
+ return `Could not find a program to open a terminal for "${title}". ${text}`;
1027
+ }
1028
+ return text || `Could not open a terminal for "${title}".`;
1029
+ }
1030
+
1031
+ function runLauncher(command, args, extra = {}) {
1032
+ return new Promise((resolve) => {
1033
+ let settled = false;
1034
+ const done = (result) => {
1035
+ if (settled) return;
1036
+ settled = true;
1037
+ resolve(result);
1038
+ };
1039
+ let child;
1040
+ try {
1041
+ child = spawn(command, args, {
1042
+ stdio: ["ignore", "pipe", "pipe"],
1043
+ windowsHide: extra.windowsHide,
1044
+ });
1045
+ } catch (err) {
1046
+ done({
1047
+ ok: false,
1048
+ error: err instanceof Error ? err.message : String(err),
1049
+ });
1050
+ return;
1051
+ }
1052
+ let stderr = "";
1053
+ child.stderr.on("data", (chunk) => {
1054
+ stderr += String(chunk);
1055
+ });
1056
+ child.once("error", (err) => {
1057
+ done({ ok: false, error: err.message });
1058
+ });
1059
+ child.once("exit", (code) => {
1060
+ if (code === 0 || code === null) done({ ok: true });
1061
+ else {
1062
+ const detail = stderr.trim().replace(/\s+/g, " ");
1063
+ done({
1064
+ ok: false,
1065
+ error: detail || `${command} exited with code ${code}`,
1066
+ });
1067
+ }
1068
+ });
1069
+ child.once("spawn", () => {
1070
+ setTimeout(() => done({ ok: true }), 400);
1071
+ });
1072
+ });
1073
+ }
1074
+
1075
+ async function openInNewTerminal(opts) {
1076
+ const { title, folder, command, env = {}, launchKey } = opts;
1077
+ if (launchKey) {
1078
+ if (!opts.force && recentlyLaunched(launchKey)) {
1079
+ return { ok: true, skipped: true };
1080
+ }
1081
+ launchedAt.set(launchKey, Date.now());
1082
+ }
1083
+ if (!fs.existsSync(folder)) {
1084
+ const error = `Folder is missing: ${folder}`;
1085
+ warn(`cannot open terminal: ${error}`);
1086
+ return { ok: false, error };
1087
+ }
1088
+
1089
+ const envWin = Object.entries(env)
1090
+ .map(([key, value]) => `set ${key}=${value}`)
1091
+ .join("&& ");
1092
+ const envUnix = Object.entries(env)
1093
+ .map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
1094
+ .join(" && ");
1095
+
1096
+ log(`opening separate terminal [${title}] in ${folder}: ${command}`);
1097
+
1098
+ try {
1099
+ if (process.platform === "win32") {
1100
+ const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
1101
+ const escaped = inner.replace(/'/g, "''");
1102
+ const opened = await runLauncher(
1103
+ "powershell.exe",
1104
+ [
1105
+ "-NoProfile",
1106
+ "-WindowStyle",
1107
+ "Hidden",
1108
+ "-Command",
1109
+ `Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
1110
+ ],
1111
+ { windowsHide: true }
1112
+ );
1113
+ if (!opened.ok) {
1114
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1115
+ }
1116
+ return { ok: true };
1117
+ }
1118
+
1119
+ const script = `cd ${JSON.stringify(folder)} && ${envUnix ? `${envUnix} && ` : ""}${command}`;
1120
+ if (process.platform === "darwin") {
1121
+ const opened = await runLauncher("osascript", [
1122
+ "-e",
1123
+ `tell application "Terminal" to do script ${JSON.stringify(script)}`,
1124
+ ]);
1125
+ if (!opened.ok) {
1126
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1127
+ }
1128
+ return { ok: true };
1129
+ }
1130
+
1131
+ const opened = await runLauncher(process.env.TERMINAL || "x-terminal-emulator", [
1132
+ "-e",
1133
+ "bash",
1134
+ "-lc",
1135
+ `${script}; exec bash`,
1136
+ ]);
1137
+ if (!opened.ok) {
1138
+ return { ok: false, error: friendlyLaunchError(opened.error, title) };
1139
+ }
1140
+ return { ok: true };
1141
+ } catch (err) {
1142
+ return {
1143
+ ok: false,
1144
+ error: friendlyLaunchError(
1145
+ err instanceof Error ? err.message : String(err),
1146
+ title
1147
+ ),
1148
+ };
1149
+ }
1150
+ }
1151
+
1152
+ function commandWithPort(job, scripts, port) {
1153
+ const raw = String(scripts?.[job.script] || "");
1154
+ if (
1155
+ job.role === "ui" ||
1156
+ job.role === "app" ||
1157
+ isUiCommand(raw) ||
1158
+ /--port\b/i.test(raw)
1159
+ ) {
1160
+ return `${job.command} -- --port ${port}`;
1161
+ }
1162
+ return job.command;
1163
+ }
1164
+
1165
+ async function startAiServerForWorkspace(ws, opts = {}) {
1166
+ const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
1167
+ const cfg = opts.cfg || null;
1168
+ const folder = path.resolve(ws.folderPath);
1169
+ const preferred = Number(ws.port) || 3100;
1170
+ const launchKey = `${ws.sandboxId}:${folder}:ai`;
1171
+ const label = ws.sandboxName || "this sandbox";
1172
+
1173
+ if (!fs.existsSync(folder)) {
1174
+ recordProcessProblem({
1175
+ sandboxId: ws.sandboxId,
1176
+ code: "ai_server_launch",
1177
+ role: "ai",
1178
+ title: `Could not start the chat script (${label})`,
1179
+ message: `The project folder is missing: ${folder}`,
1180
+ resolution: "Attach the folder again from Local setup.",
1181
+ });
1182
+ return { port: preferred, up: false, launched: false };
1183
+ }
1184
+
1185
+ if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
1186
+ reserved.add(preferred);
1187
+ clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1188
+ return { port: preferred, up: true, launched: false };
1189
+ }
1190
+
1191
+ if (recentlyLaunched(launchKey)) {
1192
+ reserved.add(preferred);
1193
+ return { port: preferred, up: false, launched: false, starting: true };
1194
+ }
1195
+
1196
+ let port = preferred;
1197
+ try {
1198
+ if (reserved.has(preferred)) {
1199
+ if (!(await isPortFree(preferred))) {
1200
+ reserved.delete(preferred);
1201
+ port = await findFreePort(preferred, reserved);
1202
+ }
1203
+ } else {
1204
+ port = await findFreePort(preferred, reserved);
1205
+ }
1206
+ } catch (err) {
1207
+ recordProcessProblem({
1208
+ sandboxId: ws.sandboxId,
1209
+ code: "ai_server_launch",
1210
+ role: "ai",
1211
+ title: `Could not start the chat script (${label})`,
1212
+ message: `No free port found (tried from ${preferred}). ${
1213
+ err instanceof Error ? err.message : String(err)
1214
+ }`,
1215
+ resolution: "Close other local servers, then use Start chat server.",
1216
+ });
1217
+ return { port: preferred, up: false, launched: false };
1218
+ }
1219
+ if (port !== preferred) {
1220
+ log(`port ${preferred} busy; using ${port} for ai-server`);
1221
+ ws.port = port;
1222
+ const envPath = path.join(folder, ".env");
1223
+ if (fs.existsSync(envPath)) {
1224
+ mergeEnvFile(envPath, {
1225
+ PORT: String(port),
1226
+ AI_SERVER_URL: `http://localhost:${port}`,
1227
+ NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1228
+ });
1229
+ }
1230
+ persistWorkspaceEntry(cfg, ws);
1231
+ }
688
1232
 
689
1233
  const localCli = path.resolve(
690
1234
  __dirname,
@@ -695,42 +1239,545 @@ function startAiServerForWorkspace(ws) {
695
1239
  "cli.js"
696
1240
  );
697
1241
  const useLocal = fs.existsSync(localCli);
698
- const env = {
699
- ...process.env,
700
- PORT: String(ws.port),
701
- AI_SERVER_URL: `http://localhost:${ws.port}`,
702
- NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${ws.port}`,
703
- };
1242
+ const run = useLocal
1243
+ ? process.platform === "win32"
1244
+ ? `"${process.execPath}" "${localCli}" --port ${port}`
1245
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(localCli)} --port ${port}`
1246
+ : `npx --yes @maintainer-pro/ai-server --port ${port}`;
1247
+
1248
+ const opened = await openInNewTerminal({
1249
+ title: `MP-ai-${port}`,
1250
+ folder,
1251
+ command: run,
1252
+ env: {
1253
+ PORT: String(port),
1254
+ AI_SERVER_URL: `http://localhost:${port}`,
1255
+ NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
1256
+ },
1257
+ launchKey,
1258
+ });
1259
+ if (opened.skipped) {
1260
+ return { port, up: false, launched: false, starting: true };
1261
+ }
1262
+ if (!opened.ok) {
1263
+ recordProcessProblem({
1264
+ sandboxId: ws.sandboxId,
1265
+ code: "ai_server_launch",
1266
+ role: "ai",
1267
+ title: `Could not start the chat script (${label})`,
1268
+ message: opened.error,
1269
+ resolution:
1270
+ "Allow the bridge to open terminal windows, or start the script manually in that folder.",
1271
+ });
1272
+ return { port, up: false, launched: false };
1273
+ }
1274
+ return { port, up: false, launched: true, starting: true };
1275
+ }
704
1276
 
705
- log(`starting ai-server for ${ws.sandboxName || ws.sandboxId} on :${ws.port}`);
706
- const child = spawn(
707
- useLocal
708
- ? process.execPath
709
- : process.platform === "win32"
710
- ? "npx.cmd"
711
- : "npx",
712
- useLocal ? [localCli] : ["--yes", "@maintainer-pro/ai-server"],
713
- {
714
- cwd: ws.folderPath,
715
- env,
716
- stdio: ["ignore", "pipe", "pipe"],
717
- shell: process.platform === "win32" && !useLocal,
718
- }
1277
+ function sleep(ms) {
1278
+ return new Promise((resolve) => setTimeout(resolve, ms));
1279
+ }
1280
+
1281
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
1282
+ const cloudflareTunnels = new Map();
1283
+
1284
+ function stopAllCloudflare() {
1285
+ for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1286
+ }
1287
+
1288
+ function parseTryCloudflareUrl(text) {
1289
+ const match = String(text || "").match(
1290
+ /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
719
1291
  );
720
- const tag = ws.sandboxName || ws.sandboxId.slice(0, 8);
721
- child.stdout?.on("data", (buf) => {
722
- const line = String(buf).trimEnd();
723
- if (line) console.log(`[ai-server:${tag}] ${line}`);
1292
+ return match ? match[0].replace(/\/$/, "") : null;
1293
+ }
1294
+
1295
+ function killProcessesByCommand(fragment) {
1296
+ if (!fragment) return Promise.resolve();
1297
+ return new Promise((resolve) => {
1298
+ const done = () => resolve();
1299
+ if (process.platform === "win32") {
1300
+ const escaped = String(fragment).replace(/'/g, "''");
1301
+ const child = spawn(
1302
+ "powershell.exe",
1303
+ [
1304
+ "-NoProfile",
1305
+ "-Command",
1306
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escaped}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
1307
+ ],
1308
+ { windowsHide: true, stdio: "ignore" }
1309
+ );
1310
+ child.on("exit", done);
1311
+ child.on("error", done);
1312
+ setTimeout(done, 8000);
1313
+ return;
1314
+ }
1315
+ const child = spawn("pkill", ["-f", String(fragment)], { stdio: "ignore" });
1316
+ child.on("exit", done);
1317
+ child.on("error", done);
1318
+ setTimeout(done, 4000);
724
1319
  });
725
- child.stderr?.on("data", (buf) => {
726
- const line = String(buf).trimEnd();
727
- if (line) console.error(`[ai-server:${tag}] ${line}`);
1320
+ }
1321
+
1322
+ function killPort(port) {
1323
+ const n = Number(port);
1324
+ if (!n) return Promise.resolve();
1325
+ return new Promise((resolve) => {
1326
+ const done = () => resolve();
1327
+ if (process.platform === "win32") {
1328
+ const child = spawn(
1329
+ "powershell.exe",
1330
+ [
1331
+ "-NoProfile",
1332
+ "-Command",
1333
+ `$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 }`,
1334
+ ],
1335
+ { windowsHide: true, stdio: "ignore" }
1336
+ );
1337
+ child.on("exit", done);
1338
+ child.on("error", done);
1339
+ setTimeout(done, 8000);
1340
+ return;
1341
+ }
1342
+ const child = spawn(
1343
+ "sh",
1344
+ ["-c", `pids=$(lsof -ti tcp:${n} 2>/dev/null); [ -n "$pids" ] && kill $pids`],
1345
+ { stdio: "ignore" }
1346
+ );
1347
+ child.on("exit", done);
1348
+ child.on("error", done);
1349
+ setTimeout(done, 4000);
728
1350
  });
729
- child.on("exit", (code, signal) => {
730
- log(`ai-server ${tag} exited (code=${code}, signal=${signal})`);
731
- children.delete(key);
1351
+ }
1352
+
1353
+ function stopCloudflare(sandboxId) {
1354
+ const row = cloudflareTunnels.get(sandboxId);
1355
+ const files = row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [];
1356
+ cloudflareTunnels.delete(sandboxId);
1357
+ return Promise.all(files.map((file) => killProcessesByCommand(file)));
1358
+ }
1359
+
1360
+ function forgetProcessLaunches(sandboxId) {
1361
+ for (const key of [...launchedAt.keys()]) {
1362
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
1363
+ launchedAt.delete(key);
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ async function stopWorkspaceApps(ws) {
1369
+ const folder = path.resolve(ws.folderPath || "");
1370
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1371
+ const ports = new Set();
1372
+ if (ws.port) ports.add(Number(ws.port));
1373
+ for (const job of jobs) {
1374
+ if (job.preferredPort) ports.add(Number(job.preferredPort));
1375
+ }
1376
+ if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
1377
+ try {
1378
+ const port = Number(new URL(ws.appUrl).port);
1379
+ if (port) ports.add(port);
1380
+ } catch {
1381
+ /* ignore */
1382
+ }
1383
+ }
1384
+ log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
1385
+ for (const port of ports) {
1386
+ await killPort(port);
1387
+ }
1388
+ forgetProcessLaunches(ws.sandboxId);
1389
+ await sleep(400);
1390
+ }
1391
+
1392
+ async function waitUntilReachable(url, timeoutMs, label) {
1393
+ const start = Date.now();
1394
+ while (Date.now() - start < timeoutMs) {
1395
+ if (await probeUrl(url)) return true;
1396
+ await sleep(600);
1397
+ }
1398
+ throw new Error(`${label} did not become reachable at ${url}`);
1399
+ }
1400
+
1401
+ async function waitForUrlInFile(file, timeoutMs = 90_000) {
1402
+ const start = Date.now();
1403
+ while (Date.now() - start < timeoutMs) {
1404
+ if (fs.existsSync(file)) {
1405
+ const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
1406
+ if (url) return url;
1407
+ }
1408
+ await sleep(500);
1409
+ }
1410
+ throw new Error(
1411
+ `Cloudflare did not publish a URL in time (${path.basename(file)}). Check that terminal.`
1412
+ );
1413
+ }
1414
+
1415
+ function cloudflaredCommand(localUrl, logFile) {
1416
+ const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate`;
1417
+ if (process.platform === "win32") {
1418
+ const dest = String(logFile).replace(/'/g, "''");
1419
+ return `powershell -NoProfile -Command "${run} 2>&1 | Tee-Object -FilePath '${dest}'"`;
1420
+ }
1421
+ return `${run} 2>&1 | tee ${JSON.stringify(logFile)}`;
1422
+ }
1423
+
1424
+ async function startCloudflareTerminal(ws, role, localUrl) {
1425
+ const folder = path.resolve(ws.folderPath);
1426
+ const logDir = path.join(folder, ".maintainer-pro");
1427
+ fs.mkdirSync(logDir, { recursive: true });
1428
+ const logFile = path.join(
1429
+ logDir,
1430
+ `cf-${String(ws.sandboxId).slice(0, 8)}-${role}.log`
1431
+ );
1432
+ try {
1433
+ fs.unlinkSync(logFile);
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
1437
+ const opened = await openInNewTerminal({
1438
+ title: `MP-cf-${role}`,
1439
+ folder,
1440
+ command: cloudflaredCommand(localUrl, logFile),
1441
+ launchKey: `${ws.sandboxId}:cf:${role}`,
1442
+ force: true,
732
1443
  });
733
- children.set(key, child);
1444
+ if (!opened.ok) {
1445
+ throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
1446
+ }
1447
+ const publicUrl = await waitForUrlInFile(logFile);
1448
+ return { role, localUrl, publicUrl, logFile };
1449
+ }
1450
+
1451
+ function uiPublicEnv(tunnels) {
1452
+ /** @type {Record<string, string>} */
1453
+ const env = {};
1454
+ if (tunnels.ai) {
1455
+ env.AI_SERVER_URL = tunnels.ai;
1456
+ env.NEXT_PUBLIC_AI_SERVER_URL = tunnels.ai;
1457
+ env.VITE_AI_SERVER_URL = tunnels.ai;
1458
+ }
1459
+ if (tunnels.backend) {
1460
+ env.API_URL = tunnels.backend;
1461
+ env.API_BASE_URL = tunnels.backend;
1462
+ env.VITE_API_URL = tunnels.backend;
1463
+ env.VITE_API_BASE_URL = tunnels.backend;
1464
+ env.NEXT_PUBLIC_API_URL = tunnels.backend;
1465
+ env.NEXT_PUBLIC_API_BASE_URL = tunnels.backend;
1466
+ env.BACKEND_URL = tunnels.backend;
1467
+ }
1468
+ if (tunnels.ui) {
1469
+ env.APP_URL = tunnels.ui;
1470
+ env.PUBLIC_URL = tunnels.ui;
1471
+ env.CORS_ORIGIN = tunnels.ui;
1472
+ } else if (tunnels.ai) {
1473
+ env.CORS_ORIGIN = tunnels.ai;
1474
+ }
1475
+ return env;
1476
+ }
1477
+
1478
+ function writeTunnelEnv(ws, tunnels) {
1479
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1480
+ if (!folder || !fs.existsSync(folder)) return;
1481
+ const env = uiPublicEnv(tunnels);
1482
+ const lines = Object.entries(tunnels)
1483
+ .filter(([, url]) => url)
1484
+ .map(([role, url]) => `${role}=${url}`);
1485
+ fs.writeFileSync(
1486
+ path.join(folder, ".cloudflare-tunnel-url"),
1487
+ `${lines.join("\n")}\n`,
1488
+ "utf8"
1489
+ );
1490
+ const envPath = path.join(folder, ".env");
1491
+ if (Object.keys(env).length) mergeEnvFile(envPath, env);
1492
+ const localEnv = {};
1493
+ if (env.NEXT_PUBLIC_AI_SERVER_URL) {
1494
+ localEnv.NEXT_PUBLIC_AI_SERVER_URL = env.NEXT_PUBLIC_AI_SERVER_URL;
1495
+ }
1496
+ if (env.NEXT_PUBLIC_API_URL) {
1497
+ localEnv.NEXT_PUBLIC_API_URL = env.NEXT_PUBLIC_API_URL;
1498
+ }
1499
+ if (Object.keys(localEnv).length) {
1500
+ mergeEnvFile(path.join(folder, ".env.local"), localEnv);
1501
+ }
1502
+ }
1503
+
1504
+ async function configureCloudflareForWorkspace(ws, cfg) {
1505
+ const sandboxId = ws.sandboxId;
1506
+ const label = ws.sandboxName || "this sandbox";
1507
+ const folder = path.resolve(ws.folderPath || "");
1508
+ const reserved = new Set();
1509
+ for (const other of cfg.workspaces || []) {
1510
+ if (other.sandboxId !== sandboxId && other.port) {
1511
+ reserved.add(Number(other.port));
1512
+ }
1513
+ }
1514
+
1515
+ log(`Cloudflare setup for ${label}: stop apps, then tunnel ai-server/backend before UI`);
1516
+ try {
1517
+ await stopCloudflare(sandboxId);
1518
+ await stopWorkspaceApps(ws);
1519
+
1520
+ if (!cfg.noAiServer) {
1521
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1522
+ await waitUntilReachable(
1523
+ `http://127.0.0.1:${ws.port}/embed-config.js`,
1524
+ 45_000,
1525
+ "Chat script"
1526
+ );
1527
+ }
1528
+
1529
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1530
+ const backendJob = jobs.find((job) => job.role === "backend");
1531
+ const uiJob = jobs.find((job) => job.role === "ui" || job.role === "app");
1532
+
1533
+ if (backendJob) {
1534
+ await ensureHostProcesses(ws, {
1535
+ reserved,
1536
+ cfg,
1537
+ onlyRoles: ["backend"],
1538
+ force: true,
1539
+ });
1540
+ const backendPort = Number(backendJob.preferredPort) || 4100;
1541
+ await waitUntilReachable(
1542
+ `http://127.0.0.1:${backendPort}`,
1543
+ 45_000,
1544
+ "Backend"
1545
+ );
1546
+ }
1547
+
1548
+ /** @type {Record<string, string>} */
1549
+ const tunnels = {};
1550
+ /** @type {Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }>} */
1551
+ const started = [];
1552
+
1553
+ const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
1554
+ const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal);
1555
+ tunnels.ai = aiTunnel.publicUrl;
1556
+ started.push(aiTunnel);
1557
+ log(`Cloudflare chat script: ${aiTunnel.publicUrl}`);
1558
+
1559
+ if (backendJob) {
1560
+ const backendLocal = `http://127.0.0.1:${Number(backendJob.preferredPort) || 4100}`;
1561
+ const backendTunnel = await startCloudflareTerminal(ws, "backend", backendLocal);
1562
+ tunnels.backend = backendTunnel.publicUrl;
1563
+ started.push(backendTunnel);
1564
+ log(`Cloudflare backend: ${backendTunnel.publicUrl}`);
1565
+ }
1566
+
1567
+ writeTunnelEnv(ws, tunnels);
1568
+ const uiEnv = uiPublicEnv(tunnels);
1569
+
1570
+ if (uiJob) {
1571
+ await ensureHostProcesses(ws, {
1572
+ reserved,
1573
+ cfg,
1574
+ onlyRoles: ["ui", "app"],
1575
+ extraEnv: uiEnv,
1576
+ force: true,
1577
+ });
1578
+ const uiPort = Number(uiJob.preferredPort) || 5173;
1579
+ await waitUntilReachable(`http://127.0.0.1:${uiPort}`, 60_000, "App UI");
1580
+ const uiTunnel = await startCloudflareTerminal(
1581
+ ws,
1582
+ "ui",
1583
+ `http://127.0.0.1:${uiPort}`
1584
+ );
1585
+ tunnels.ui = uiTunnel.publicUrl;
1586
+ started.push(uiTunnel);
1587
+ log(`Cloudflare UI: ${uiTunnel.publicUrl}`);
1588
+ writeTunnelEnv(ws, tunnels);
1589
+ launchedAt.delete(`${sandboxId}:${folder}:ai`);
1590
+ await killPort(ws.port);
1591
+ await sleep(1500);
1592
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1593
+ }
1594
+
1595
+ const appUrl = tunnels.ui || tunnels.ai;
1596
+ ws.cloudflareUrl = appUrl;
1597
+ ws.cloudflare = tunnels;
1598
+ ws.appUrl = appUrl;
1599
+ persistWorkspaceEntry(cfg, ws);
1600
+ cloudflareTunnels.set(sandboxId, { tunnels: started });
1601
+ clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
1602
+
1603
+ return {
1604
+ sandboxId,
1605
+ folderPath: ws.folderPath,
1606
+ appUrl,
1607
+ origins: Object.values(tunnels).filter(Boolean),
1608
+ tunnels,
1609
+ cloudflare: true,
1610
+ reused: false,
1611
+ };
1612
+ } catch (err) {
1613
+ const message = err instanceof Error ? err.message : String(err);
1614
+ recordProcessProblem({
1615
+ sandboxId,
1616
+ code: "cloudflare_launch",
1617
+ role: "tunnel",
1618
+ title: `Could not start Cloudflare (${label})`,
1619
+ message,
1620
+ resolution:
1621
+ "Install cloudflared or allow npx to download it, then try Share with Cloudflare again.",
1622
+ actionCode: "configure_cloudflare",
1623
+ });
1624
+ throw err;
1625
+ }
1626
+ }
1627
+
1628
+ async function ensureHostProcesses(ws, opts = {}) {
1629
+ const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
1630
+ const cfg = opts.cfg || null;
1631
+ const folder = path.resolve(ws.folderPath);
1632
+ const scripts = readPackageJson(folder)?.scripts || {};
1633
+ const jobs = planHostJobs(
1634
+ folder,
1635
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1636
+ ws.projectInfo
1637
+ );
1638
+ const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
1639
+ const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
1640
+ const started = [];
1641
+ let persisted = false;
1642
+ const label = ws.sandboxName || "this sandbox";
1643
+
1644
+ if (jobs.length > 0 && !fs.existsSync(folder)) {
1645
+ recordProcessProblem({
1646
+ sandboxId: ws.sandboxId,
1647
+ code: "host_process_launch",
1648
+ role: "app",
1649
+ title: `Could not start the app (${label})`,
1650
+ message: `The project folder is missing: ${folder}`,
1651
+ resolution: "Attach the folder again from Local setup.",
1652
+ });
1653
+ return started;
1654
+ }
1655
+
1656
+ for (const job of jobs) {
1657
+ if (onlyRoles && !onlyRoles.has(job.role)) continue;
1658
+ const preferred = Number(job.preferredPort) || 3000;
1659
+ const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1660
+ "localhost",
1661
+ "127.0.0.1"
1662
+ );
1663
+ if (await probeUrl(probe)) {
1664
+ reserved.add(portFromText(probe, preferred));
1665
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1666
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1667
+ log(`${job.role} already running at ${job.probeUrl || probe}`);
1668
+ continue;
1669
+ }
1670
+ const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1671
+ if (!opts.force && recentlyLaunched(launchKey)) {
1672
+ reserved.add(preferred);
1673
+ continue;
1674
+ }
1675
+
1676
+ let port = preferred;
1677
+ try {
1678
+ port = await findFreePort(preferred, reserved);
1679
+ } catch (err) {
1680
+ recordProcessProblem({
1681
+ sandboxId: ws.sandboxId,
1682
+ code: "host_process_launch",
1683
+ role: job.role,
1684
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
1685
+ message: `No free port found for "${job.script}" (tried from ${preferred}). ${
1686
+ err instanceof Error ? err.message : String(err)
1687
+ }`,
1688
+ resolution: "Close other local servers, then use Start chat server.",
1689
+ });
1690
+ continue;
1691
+ }
1692
+ if (port !== preferred) {
1693
+ log(`port ${preferred} busy; using ${port} for ${job.role}`);
1694
+ }
1695
+
1696
+ const needsInstall =
1697
+ fs.existsSync(path.join(folder, "package.json")) &&
1698
+ !fs.existsSync(path.join(folder, "node_modules"));
1699
+ const run = commandWithPort(job, scripts, port);
1700
+ const command = needsInstall ? `npm install && ${run}` : run;
1701
+ const opened = await openInNewTerminal({
1702
+ title: `MP-${job.role}-${port}`,
1703
+ folder,
1704
+ command,
1705
+ env: { PORT: String(port), ...extraEnv },
1706
+ launchKey,
1707
+ force: Boolean(opts.force),
1708
+ });
1709
+ if (opened.skipped) continue;
1710
+ if (!opened.ok) {
1711
+ recordProcessProblem({
1712
+ sandboxId: ws.sandboxId,
1713
+ code: "host_process_launch",
1714
+ role: job.role,
1715
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
1716
+ message: `${opened.error} Command: ${command}`,
1717
+ resolution:
1718
+ "Allow the bridge to open terminal windows, or run that command manually in the folder.",
1719
+ });
1720
+ continue;
1721
+ }
1722
+ started.push(job.role);
1723
+ if (
1724
+ (job.role === "ui" || job.role === "app") &&
1725
+ (!ws.appUrl || isLocalAppUrl(ws.appUrl))
1726
+ ) {
1727
+ ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
1728
+ persisted = true;
1729
+ }
1730
+ await sleep(400);
1731
+ }
1732
+ if (persisted) persistWorkspaceEntry(cfg, ws);
1733
+ return started;
1734
+ }
1735
+
1736
+ async function inspectHostJobs(ws) {
1737
+ const folder = path.resolve(ws.folderPath || "");
1738
+ const jobs = planHostJobs(
1739
+ folder,
1740
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1741
+ ws.projectInfo
1742
+ );
1743
+ const label = ws.sandboxName || "this sandbox";
1744
+ const hosts = [];
1745
+ for (const job of jobs) {
1746
+ const preferred = Number(job.preferredPort) || 3000;
1747
+ const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1748
+ "localhost",
1749
+ "127.0.0.1"
1750
+ );
1751
+ const up = await probeUrl(probe);
1752
+ const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1753
+ const starting = recentlyLaunched(launchKey);
1754
+ hosts.push({
1755
+ role: job.role,
1756
+ script: job.script,
1757
+ probeUrl: job.probeUrl || probe,
1758
+ up,
1759
+ starting,
1760
+ });
1761
+ if (up) {
1762
+ clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
1763
+ clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
1764
+ continue;
1765
+ }
1766
+ if (starting) continue;
1767
+ const tried = launchedAt.has(launchKey);
1768
+ recordProcessProblem({
1769
+ sandboxId: ws.sandboxId,
1770
+ code: "host_process_down",
1771
+ role: job.role,
1772
+ title: `The ${processRoleLabel(job.role)} is not running (${label})`,
1773
+ message: tried
1774
+ ? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
1775
+ : `Nothing is running at ${probe} for "${job.script}".`,
1776
+ resolution:
1777
+ "Fix the error in that terminal, then use Start chat server to try again.",
1778
+ });
1779
+ }
1780
+ return hosts;
734
1781
  }
735
1782
 
736
1783
  async function setupWorkspace(cfg, action) {
@@ -738,7 +1785,22 @@ async function setupWorkspace(cfg, action) {
738
1785
  const sandboxId = String(
739
1786
  action.sandboxId || action.payload?.sandboxId || ""
740
1787
  );
741
- const port = Number(action.payload?.port) || 3100;
1788
+ const requestedPort = Number(action.payload?.port) || 3100;
1789
+ const reserved = new Set();
1790
+ for (const other of cfg.workspaces || []) {
1791
+ if (other.sandboxId !== sandboxId && other.port) {
1792
+ reserved.add(Number(other.port));
1793
+ }
1794
+ }
1795
+ let port = requestedPort;
1796
+ if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
1797
+ reserved.add(requestedPort);
1798
+ } else {
1799
+ port = await findFreePort(requestedPort, reserved);
1800
+ if (port !== requestedPort) {
1801
+ log(`port ${requestedPort} busy; using ${port} for ai-server`);
1802
+ }
1803
+ }
742
1804
  const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
743
1805
  const hostAppUrl =
744
1806
  typeof action.payload?.hostAppUrl === "string" &&
@@ -799,6 +1861,8 @@ async function setupWorkspace(cfg, action) {
799
1861
  sandboxName: config.sandbox?.name,
800
1862
  applicationName: config.sandbox?.applicationName,
801
1863
  clientKind: client.kind,
1864
+ appUrl,
1865
+ sameOrigin: Boolean(client.sameOrigin),
802
1866
  };
803
1867
  if (existing >= 0) cfg.workspaces[existing] = entry;
804
1868
  else cfg.workspaces.push(entry);
@@ -806,27 +1870,66 @@ async function setupWorkspace(cfg, action) {
806
1870
  if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
807
1871
  saveConfig(cfg);
808
1872
 
1873
+ const projectInfo = await inspectProjectWithAiCli(entry, {
1874
+ cfg,
1875
+ extraContext: [
1876
+ `Scaffold kind: ${client.kind}`,
1877
+ `Chat script port: ${port}`,
1878
+ client.notes.join(" "),
1879
+ ]
1880
+ .filter(Boolean)
1881
+ .join("\n"),
1882
+ });
1883
+
809
1884
  if (!cfg.noAiServer) {
810
- startAiServerForWorkspace(entry);
811
- await new Promise((r) => setTimeout(r, 1500));
1885
+ await startAiServerForWorkspace(entry, { reserved, cfg });
1886
+ await sleep(1500);
1887
+ }
1888
+ const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
1889
+ await sleep(800);
1890
+ await inspectHostJobs(entry);
1891
+
1892
+ const openUrl = client.sameOrigin
1893
+ ? `http://localhost:${entry.port}`
1894
+ : entry.appUrl || appUrl;
1895
+ const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
1896
+ if (aiServerUp) {
1897
+ clearProcessProblem(sandboxId, "ai_server_launch", "ai");
812
1898
  }
813
1899
 
814
- const aiServerUp = await probeUrl(`${aiOrigin.replace("localhost", "127.0.0.1")}/embed-config.js`);
1900
+ const processIssues = issuesForSandbox(sandboxId).map(
1901
+ ({ role: _role, ...issue }) => issue
1902
+ );
1903
+ const warning = processIssues[0]?.message || null;
815
1904
 
816
1905
  for (const note of client.notes) log(note);
1906
+ if (startedHosts.length) {
1907
+ log(`started ${startedHosts.join(" + ")} in separate terminals`);
1908
+ }
1909
+ if (aiServerUp) {
1910
+ log(`ai-server up — open ${openUrl}`);
1911
+ } else if (warning) {
1912
+ log(`process issue: ${warning}`);
1913
+ } else {
1914
+ log("ai-server not reachable yet; it may still be starting");
1915
+ }
817
1916
 
818
1917
  return {
819
1918
  sandboxId,
820
1919
  folderPath: resolved,
821
- port,
822
- appUrl,
823
- origins: [corsOrigin, aiOrigin].filter(Boolean),
1920
+ port: entry.port,
1921
+ appUrl: entry.appUrl || appUrl,
1922
+ origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
824
1923
  wroteEnv: true,
825
1924
  clientKind: client.kind,
826
1925
  clientFiles: client.filesWritten,
827
1926
  clientNotes: client.notes,
828
1927
  aiServerUp,
829
- openUrl: client.sameOrigin ? aiOrigin : appUrl,
1928
+ startedHosts,
1929
+ openUrl,
1930
+ processIssues,
1931
+ warning,
1932
+ projectInfo,
830
1933
  };
831
1934
  }
832
1935
 
@@ -843,7 +1946,15 @@ async function runActions(cfg, actions) {
843
1946
  } else if (action.code === "setup_workspace") {
844
1947
  result = await setupWorkspace(cfg, action);
845
1948
  } else if (action.code === "recheck") {
846
- result = { recheckedAt: new Date().toISOString() };
1949
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
1950
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
1951
+ const projectInfo = ws
1952
+ ? await inspectProjectWithAiCli(ws, { cfg })
1953
+ : null;
1954
+ result = {
1955
+ recheckedAt: new Date().toISOString(),
1956
+ projectInfo,
1957
+ };
847
1958
  } else if (action.code === "start_ai_server") {
848
1959
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
849
1960
  const ws =
@@ -859,23 +1970,88 @@ async function runActions(cfg, actions) {
859
1970
  ok = false;
860
1971
  result = { error: "No workspace or --no-ai-server" };
861
1972
  } else {
862
- startAiServerForWorkspace(ws);
863
- await new Promise((r) => setTimeout(r, 1200));
1973
+ const reserved = new Set();
1974
+ for (const other of cfg.workspaces || []) {
1975
+ if (other.sandboxId !== ws.sandboxId && other.port) {
1976
+ reserved.add(Number(other.port));
1977
+ }
1978
+ }
1979
+ const problem = issuesForSandbox(ws.sandboxId)
1980
+ .map((issue) => issue.message)
1981
+ .join("\n");
1982
+ const projectInfo = await inspectProjectWithAiCli(ws, {
1983
+ cfg,
1984
+ problem:
1985
+ problem ||
1986
+ "Local processes are not running or the project setup looks incomplete.",
1987
+ });
1988
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1989
+ await sleep(1500);
1990
+ const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
1991
+ await sleep(800);
1992
+ await inspectHostJobs(ws);
1993
+ const up = await probeUrl(
1994
+ `http://127.0.0.1:${ws.port}/embed-config.js`
1995
+ );
1996
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
1997
+ const processIssues = issuesForSandbox(ws.sandboxId).map(
1998
+ ({ role: _role, ...issue }) => issue
1999
+ );
2000
+ const warning = processIssues[0]?.message || null;
864
2001
  result = {
865
- up: await probeUrl(
866
- `http://127.0.0.1:${ws.port}/embed-config.js`
867
- ),
2002
+ up,
2003
+ startedHosts,
2004
+ sandboxId: ws.sandboxId,
2005
+ folderPath: ws.folderPath,
2006
+ port: ws.port,
2007
+ appUrl: ws.appUrl,
2008
+ processIssues,
2009
+ warning,
2010
+ projectInfo,
868
2011
  };
2012
+ if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
2013
+ result.error = warning;
2014
+ ok = false;
2015
+ }
869
2016
  }
870
2017
  } else if (action.code === "refresh_public_url") {
2018
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
2019
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
871
2020
  result = {
872
- appUrl: process.env.APP_URL || process.env.PUBLIC_URL || null,
2021
+ appUrl:
2022
+ ws?.cloudflareUrl ||
2023
+ ws?.appUrl ||
2024
+ process.env.APP_URL ||
2025
+ process.env.PUBLIC_URL ||
2026
+ null,
873
2027
  };
2028
+ } else if (action.code === "configure_cloudflare") {
2029
+ const sandboxId = action.sandboxId || action.payload?.sandboxId;
2030
+ const ws =
2031
+ (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
2032
+ (action.payload?.folderPath
2033
+ ? {
2034
+ sandboxId,
2035
+ folderPath: String(action.payload.folderPath),
2036
+ port: Number(action.payload.port) || 3100,
2037
+ appUrl:
2038
+ typeof action.payload.appUrl === "string"
2039
+ ? action.payload.appUrl
2040
+ : null,
2041
+ sandboxName: action.payload.sandboxName,
2042
+ }
2043
+ : null);
2044
+ if (!ws) {
2045
+ ok = false;
2046
+ result = { error: "No folder is attached for this sandbox" };
2047
+ } else {
2048
+ result = await configureCloudflareForWorkspace(ws, cfg);
2049
+ }
874
2050
  } else if (action.code === "remove_workspace") {
875
2051
  const sandboxId = String(
876
2052
  action.sandboxId || action.payload?.sandboxId || ""
877
2053
  );
878
- stopChild(sandboxId);
2054
+ forgetLaunch(sandboxId);
879
2055
  cfg.workspaces = (cfg.workspaces || []).filter(
880
2056
  (w) => w.sandboxId !== sandboxId
881
2057
  );
@@ -908,10 +2084,53 @@ async function runActions(cfg, actions) {
908
2084
  }
909
2085
  }
910
2086
 
911
- function buildIssues(cfg, workspaceStates) {
2087
+ async function collectWorkspaceStates(cfg) {
2088
+ /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
2089
+ const localStates = [];
2090
+ for (const ws of cfg.workspaces || []) {
2091
+ const folder = path.resolve(ws.folderPath || "");
2092
+ const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
2093
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2094
+ await inspectHostJobs(ws);
2095
+ localStates.push({
2096
+ sandboxId: ws.sandboxId,
2097
+ sandboxName: ws.sandboxName,
2098
+ port: ws.port,
2099
+ folderPath: ws.folderPath,
2100
+ aiServerUp: up,
2101
+ startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2102
+ appUrl: ws.appUrl || null,
2103
+ });
2104
+ }
2105
+ return localStates;
2106
+ }
2107
+
2108
+ async function sendHeartbeat(cfg, folders, localStates) {
2109
+ return api(
2110
+ cfg.adminUrl,
2111
+ cfg.token,
2112
+ "POST",
2113
+ "/api/v1/bridge/machine/heartbeat",
2114
+ {
2115
+ hostname: os.hostname(),
2116
+ platform: `${os.platform()}-${os.arch()}`,
2117
+ bridgeVersion: PACKAGE_VERSION,
2118
+ folders,
2119
+ issues: await buildIssues(cfg, localStates),
2120
+ workspaces: localStates.map((st) => ({
2121
+ sandboxId: st.sandboxId,
2122
+ aiServerUp: st.aiServerUp,
2123
+ port: st.port,
2124
+ appUrl: st.appUrl || undefined,
2125
+ })),
2126
+ }
2127
+ );
2128
+ }
2129
+
2130
+ async function buildIssues(cfg, workspaceStates) {
912
2131
  /** @type {Array<Record<string, unknown>>} */
913
2132
  const issues = [];
914
- const cli = detectCliProviders();
2133
+ const cli = await detectCliProviders();
915
2134
  if (!cli.length) {
916
2135
  issues.push({
917
2136
  code: "missing_cli",
@@ -925,17 +2144,54 @@ function buildIssues(cfg, workspaceStates) {
925
2144
  });
926
2145
  }
927
2146
  for (const st of workspaceStates) {
928
- if (!st.aiServerUp) {
929
- issues.push({
930
- code: "ai_server_down",
931
- severity: "error",
932
- title: `AI server down (${st.sandboxName || st.sandboxId})`,
933
- message: `Cannot reach http://localhost:${st.port}`,
934
- resolution: "Start ai-server from the Bridges page or restart the bridge.",
935
- actionCode: "start_ai_server",
936
- sandboxId: st.sandboxId,
937
- });
2147
+ if (st.aiServerUp || st.startingAi) continue;
2148
+ const launch = [...processProblems.values()].find(
2149
+ (issue) =>
2150
+ issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
2151
+ );
2152
+ issues.push({
2153
+ code: "ai_server_down",
2154
+ severity: "error",
2155
+ title:
2156
+ launch?.title ||
2157
+ `Local script is not running (${st.sandboxName || "sandbox"})`,
2158
+ message:
2159
+ launch?.message ||
2160
+ `Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
2161
+ resolution:
2162
+ launch?.resolution ||
2163
+ "Read the error in that terminal, then use Start chat server.",
2164
+ actionCode: "start_ai_server",
2165
+ sandboxId: st.sandboxId,
2166
+ });
2167
+ }
2168
+ for (const problem of processProblems.values()) {
2169
+ if (
2170
+ problem.code === "ai_server_launch" ||
2171
+ problem.code === "ai_server_down"
2172
+ ) {
2173
+ continue;
938
2174
  }
2175
+ if (
2176
+ problem.code === "host_process_down" &&
2177
+ [...processProblems.values()].some(
2178
+ (other) =>
2179
+ other.sandboxId === problem.sandboxId &&
2180
+ other.role === problem.role &&
2181
+ other.code === "host_process_launch"
2182
+ )
2183
+ ) {
2184
+ continue;
2185
+ }
2186
+ issues.push({
2187
+ code: problem.code,
2188
+ severity: problem.severity,
2189
+ title: problem.title,
2190
+ message: problem.message,
2191
+ resolution: problem.resolution,
2192
+ actionCode: problem.actionCode,
2193
+ sandboxId: problem.sandboxId,
2194
+ });
939
2195
  }
940
2196
  return issues;
941
2197
  }
@@ -975,6 +2231,8 @@ async function pairFlow(args) {
975
2231
  saveConfig(cfg);
976
2232
  log(`paired as ${result.machine?.name || cfg.machineId}`);
977
2233
  log(`config ${configPath()}`);
2234
+ log("Keep this process running.");
2235
+ log("Next: Admin → Bridges → pick a folder → Setup sandbox.");
978
2236
  return cfg;
979
2237
  }
980
2238
 
@@ -1017,35 +2275,32 @@ async function main() {
1017
2275
  /** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
1018
2276
  const localStates = [];
1019
2277
 
2278
+ const reserved = new Set();
1020
2279
  for (const ws of cfg.workspaces || []) {
2280
+ const folder = path.resolve(ws.folderPath || "");
1021
2281
  const up = await probeUrl(
1022
2282
  `http://127.0.0.1:${ws.port}/embed-config.js`
1023
2283
  );
2284
+ if (!cfg.noAiServer && !up) {
2285
+ await startAiServerForWorkspace(ws, { reserved, cfg });
2286
+ } else if (ws.port) {
2287
+ reserved.add(Number(ws.port));
2288
+ if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
2289
+ }
2290
+ await ensureHostProcesses(ws, { reserved, cfg });
2291
+ await inspectHostJobs(ws);
1024
2292
  localStates.push({
1025
2293
  sandboxId: ws.sandboxId,
1026
2294
  sandboxName: ws.sandboxName,
1027
2295
  port: ws.port,
1028
2296
  folderPath: ws.folderPath,
1029
2297
  aiServerUp: up,
2298
+ startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
2299
+ appUrl: ws.appUrl || null,
1030
2300
  });
1031
- if (!cfg.noAiServer && !up) {
1032
- startAiServerForWorkspace(ws);
1033
- }
1034
2301
  }
1035
2302
 
1036
- const hb = await api(
1037
- cfg.adminUrl,
1038
- cfg.token,
1039
- "POST",
1040
- "/api/v1/bridge/machine/heartbeat",
1041
- {
1042
- hostname: os.hostname(),
1043
- platform: `${os.platform()}-${os.arch()}`,
1044
- bridgeVersion: PACKAGE_VERSION,
1045
- folders,
1046
- issues: buildIssues(cfg, localStates),
1047
- }
1048
- );
2303
+ const hb = await sendHeartbeat(cfg, folders, localStates);
1049
2304
 
1050
2305
  // Sync local workspace list from server assignments
1051
2306
  if (Array.isArray(hb.workspaces)) {
@@ -1074,20 +2329,45 @@ async function main() {
1074
2329
 
1075
2330
  if (Array.isArray(hb.actions) && hb.actions.length > 0) {
1076
2331
  await runActions(cfg, hb.actions);
2332
+ await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
1077
2333
  }
1078
2334
  } catch (err) {
1079
2335
  warn(err instanceof Error ? err.message : String(err));
1080
2336
  }
1081
2337
  };
1082
2338
 
1083
- await tick();
2339
+ let cycleBusy = false;
2340
+ const runLocked = async (fn) => {
2341
+ if (cycleBusy) return;
2342
+ cycleBusy = true;
2343
+ try {
2344
+ await fn();
2345
+ } finally {
2346
+ cycleBusy = false;
2347
+ }
2348
+ };
2349
+
2350
+ await runLocked(tick);
1084
2351
  setInterval(() => {
1085
- void tick();
2352
+ void runLocked(tick);
1086
2353
  }, HEARTBEAT_MS);
2354
+ setInterval(() => {
2355
+ void runLocked(async () => {
2356
+ const pending = await api(
2357
+ cfg.adminUrl,
2358
+ cfg.token,
2359
+ "GET",
2360
+ "/api/v1/bridge/machine/actions"
2361
+ );
2362
+ if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
2363
+ await runActions(cfg, pending.actions);
2364
+ }
2365
+ });
2366
+ }, ACTION_POLL_MS);
1087
2367
 
1088
2368
  const shutdown = () => {
1089
- log("shutting down");
1090
- for (const id of [...children.keys()]) stopChild(id);
2369
+ stopAllCloudflare();
2370
+ log("shutting down (other terminals stay open)");
1091
2371
  process.exit(0);
1092
2372
  };
1093
2373
  process.on("SIGINT", shutdown);