@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.
- package/package.json +4 -1
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
245
|
+
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
170
246
|
}
|
|
171
|
-
|
|
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
|
|
301
|
+
return `http://localhost:${port}`;
|
|
174
302
|
}
|
|
175
303
|
}
|
|
176
304
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
669
|
-
const
|
|
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
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
915
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
679
916
|
} catch {
|
|
680
|
-
|
|
917
|
+
return null;
|
|
681
918
|
}
|
|
682
|
-
children.delete(sandboxId);
|
|
683
919
|
}
|
|
684
920
|
|
|
685
|
-
function
|
|
686
|
-
const
|
|
687
|
-
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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
|
|
866
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
2369
|
+
stopAllCloudflare();
|
|
2370
|
+
log("shutting down (other terminals stay open)");
|
|
1091
2371
|
process.exit(0);
|
|
1092
2372
|
};
|
|
1093
2373
|
process.on("SIGINT", shutdown);
|