@maintainer-pro/ai-bridge 0.1.7 → 0.1.10
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/README.md +15 -11
- package/bin/cli.js +2 -1
- package/package.json +3 -2
- package/src/daemon.mjs +2258 -1542
package/src/daemon.mjs
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* Maintainer Pro bridge — partner machine daemon.
|
|
4
4
|
*
|
|
5
5
|
* Pair once with a code from admin, then manage many sandboxes/folders:
|
|
6
|
-
* npx @maintainer-pro/ai-bridge --pair ABCD-EF01
|
|
7
|
-
*
|
|
8
|
-
* npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
|
|
6
|
+
* npx --yes --prefer-online @maintainer-pro/ai-bridge@latest --pair ABCD-EF01
|
|
7
|
+
* npm run bridge -- --pair ABCD-EF01 --admin-url http://localhost:4100
|
|
9
8
|
*/
|
|
10
9
|
import { spawn } from "node:child_process";
|
|
11
10
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -17,14 +16,17 @@ import os from "node:os";
|
|
|
17
16
|
import path from "node:path";
|
|
18
17
|
import readline from "node:readline";
|
|
19
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
-
import { createLogger } from "@maintainer-pro/ai-cli";
|
|
19
|
+
import { createLogger, ensureProjectDataDir } from "@maintainer-pro/ai-cli";
|
|
20
|
+
import { findIife, startAiServer } from "@maintainer-pro/ai-server";
|
|
21
21
|
|
|
22
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const PACKAGE_VERSION = readPackageVersion();
|
|
24
24
|
const HEARTBEAT_MS = 15_000;
|
|
25
25
|
const WS_PING_MS = 10_000;
|
|
26
|
-
const WS_RECONNECT_MIN_MS =
|
|
27
|
-
const WS_RECONNECT_MAX_MS =
|
|
26
|
+
const WS_RECONNECT_MIN_MS = 1_000;
|
|
27
|
+
const WS_RECONNECT_MAX_MS = 30_000;
|
|
28
|
+
const WS_CONNECT_TIMEOUT_MS = 20_000;
|
|
29
|
+
const WS_WATCHDOG_MS = 5_000;
|
|
28
30
|
|
|
29
31
|
/** @type {import("@maintainer-pro/ai-cli").Logger} */
|
|
30
32
|
let logger = createLogger("ai-bridge");
|
|
@@ -35,6 +37,31 @@ const fail = (msg) => {
|
|
|
35
37
|
process.exit(1);
|
|
36
38
|
};
|
|
37
39
|
|
|
40
|
+
/** @type {Map<string, Array<{ at: string, level: string, message: string }>>} */
|
|
41
|
+
const activityBySandbox = new Map();
|
|
42
|
+
|
|
43
|
+
/** @type {(payload: Record<string, unknown>) => boolean} */
|
|
44
|
+
let bridgeSend = () => false;
|
|
45
|
+
|
|
46
|
+
function activity(sandboxId, level, message) {
|
|
47
|
+
const text = String(message || "").trim();
|
|
48
|
+
if (!text) return;
|
|
49
|
+
if (level === "error" || level === "warn") warn(text);
|
|
50
|
+
else log(text);
|
|
51
|
+
if (!sandboxId) return;
|
|
52
|
+
const list = activityBySandbox.get(sandboxId) || [];
|
|
53
|
+
list.push({
|
|
54
|
+
at: new Date().toISOString(),
|
|
55
|
+
level: level === "error" || level === "warn" ? level : "info",
|
|
56
|
+
message: text.slice(0, 500),
|
|
57
|
+
});
|
|
58
|
+
activityBySandbox.set(sandboxId, list.slice(-40));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function activityLogFor(sandboxId) {
|
|
62
|
+
return activityBySandbox.get(sandboxId) || [];
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
function shortId(value) {
|
|
39
66
|
const text = String(value || "");
|
|
40
67
|
return text.length > 12 ? `${text.slice(0, 8)}…` : text;
|
|
@@ -83,6 +110,28 @@ function readPackageVersion() {
|
|
|
83
110
|
}
|
|
84
111
|
}
|
|
85
112
|
|
|
113
|
+
async function warnIfBridgeOutdated() {
|
|
114
|
+
const fromWorkspace = path
|
|
115
|
+
.normalize(__dirname)
|
|
116
|
+
.includes(`${path.sep}packages${path.sep}ai-bridge${path.sep}`);
|
|
117
|
+
if (fromWorkspace) return;
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(
|
|
120
|
+
"https://registry.npmjs.org/@maintainer-pro/ai-bridge/latest",
|
|
121
|
+
{ signal: AbortSignal.timeout(2500) }
|
|
122
|
+
);
|
|
123
|
+
if (!res.ok) return;
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
const latest = typeof body?.version === "string" ? body.version : "";
|
|
126
|
+
if (!latest || latest === PACKAGE_VERSION) return;
|
|
127
|
+
warn(
|
|
128
|
+
`this process is v${PACKAGE_VERSION}; npm latest is v${latest}. Re-run: npx --yes --prefer-online @maintainer-pro/ai-bridge@${latest}`
|
|
129
|
+
);
|
|
130
|
+
} catch {
|
|
131
|
+
// Registry unreachable — keep running the local copy.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
86
135
|
function parseArgs(argv) {
|
|
87
136
|
/** @type {Record<string, string | boolean>} */
|
|
88
137
|
const out = {};
|
|
@@ -120,17 +169,19 @@ Pair this machine to your partner account, then configure folders for any
|
|
|
120
169
|
client sandbox from the Maintainer Pro admin UI.
|
|
121
170
|
|
|
122
171
|
Usage:
|
|
123
|
-
npx @maintainer-pro/ai-bridge --pair ABCD-EF01 --admin-url https://admin.example.com
|
|
124
|
-
npx @maintainer-pro/ai-bridge
|
|
125
|
-
|
|
172
|
+
npx --yes --prefer-online @maintainer-pro/ai-bridge@latest --pair ABCD-EF01 --admin-url https://admin.example.com
|
|
173
|
+
npx --yes --prefer-online @maintainer-pro/ai-bridge@latest
|
|
174
|
+
|
|
175
|
+
From the SDK repo (local development):
|
|
176
|
+
npm run bridge -- --pair ABCD-EF01 --admin-url http://localhost:4100
|
|
177
|
+
npm run bridge
|
|
126
178
|
|
|
127
179
|
Config is stored in ~/.maintainer-pro/bridge.json (not the cwd).
|
|
128
180
|
|
|
129
181
|
Flags:
|
|
130
182
|
--pair <code> Claim a pair code from admin → Bridges page
|
|
131
183
|
--admin-url <url> Maintainer Pro base URL (required for first pair)
|
|
132
|
-
--
|
|
133
|
-
--no-ai-server Do not open ai-server terminals for workspaces
|
|
184
|
+
--no-ai-server Do not start in-process chat for workspaces
|
|
134
185
|
--help
|
|
135
186
|
|
|
136
187
|
Logging (env):
|
|
@@ -143,6 +194,14 @@ function configDir() {
|
|
|
143
194
|
return path.join(os.homedir(), ".maintainer-pro");
|
|
144
195
|
}
|
|
145
196
|
|
|
197
|
+
/** Per-sandbox data (apps cache, uploads, logs) — never the host app folder. */
|
|
198
|
+
function dataDirFor(folder, sandboxId) {
|
|
199
|
+
return ensureProjectDataDir({
|
|
200
|
+
workspaceDir: folder || "",
|
|
201
|
+
sandboxId: sandboxId || undefined,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
146
205
|
function configPath() {
|
|
147
206
|
return path.join(configDir(), "bridge.json");
|
|
148
207
|
}
|
|
@@ -263,15 +322,11 @@ let aiCliModule = null;
|
|
|
263
322
|
async function loadAiCli() {
|
|
264
323
|
if (!aiCliModule) {
|
|
265
324
|
aiCliModule = (async () => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
|
|
270
|
-
if (fs.existsSync(local)) {
|
|
271
|
-
return await import(pathToFileURL(local).href);
|
|
272
|
-
}
|
|
273
|
-
throw new Error("@maintainer-pro/ai-cli is not installed");
|
|
325
|
+
const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
|
|
326
|
+
if (fs.existsSync(local)) {
|
|
327
|
+
return await import(pathToFileURL(local).href);
|
|
274
328
|
}
|
|
329
|
+
return await import("@maintainer-pro/ai-cli");
|
|
275
330
|
})();
|
|
276
331
|
}
|
|
277
332
|
return aiCliModule;
|
|
@@ -295,6 +350,85 @@ async function detectCliProviders() {
|
|
|
295
350
|
}
|
|
296
351
|
}
|
|
297
352
|
|
|
353
|
+
async function resolveWorkspaceHostApps(ws, opts = {}) {
|
|
354
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
355
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
356
|
+
const desired = Array.isArray(opts.desired)
|
|
357
|
+
? opts.desired
|
|
358
|
+
: Array.isArray(ws.hostApps)
|
|
359
|
+
? ws.hostApps
|
|
360
|
+
: [];
|
|
361
|
+
try {
|
|
362
|
+
const cli = await loadAiCli();
|
|
363
|
+
if (typeof cli.resolveHostApps !== "function") {
|
|
364
|
+
throw new Error("ai-cli is missing resolveHostApps — update @maintainer-pro/ai-cli");
|
|
365
|
+
}
|
|
366
|
+
activity(
|
|
367
|
+
ws.sandboxId,
|
|
368
|
+
"info",
|
|
369
|
+
`detecting ports for ${label} in ${folder} (env first${opts.force ? ", redetect" : ""})`
|
|
370
|
+
);
|
|
371
|
+
const result = await cli.resolveHostApps({
|
|
372
|
+
workspaceDir: folder,
|
|
373
|
+
appName: ws.applicationName || ws.sandboxName,
|
|
374
|
+
preferredAiPort: Number(ws.port) || 3100,
|
|
375
|
+
desired: opts.ignoreDesired ? [] : desired,
|
|
376
|
+
force: Boolean(opts.force),
|
|
377
|
+
allowAi: Boolean(opts.allowAi),
|
|
378
|
+
sandboxId: ws.sandboxId,
|
|
379
|
+
});
|
|
380
|
+
const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
381
|
+
result.apps = result.apps.map((app) => {
|
|
382
|
+
const match = previous.find((row) => row && row.id === app.id);
|
|
383
|
+
if (!match || (app.envMaps && app.envMaps.length)) return app;
|
|
384
|
+
return { ...app, envMaps: match.envMaps || [] };
|
|
385
|
+
});
|
|
386
|
+
ws.hostApps = result.apps;
|
|
387
|
+
if (opts.cfg) persistWorkspaceEntry(opts.cfg, ws);
|
|
388
|
+
const summary = result.apps
|
|
389
|
+
.map((app) => `${app.name}:${app.port}`)
|
|
390
|
+
.join(", ");
|
|
391
|
+
activity(
|
|
392
|
+
ws.sandboxId,
|
|
393
|
+
result.confused ? "warn" : "info",
|
|
394
|
+
`ports ${result.source}${result.cached ? " cache" : ""}${
|
|
395
|
+
result.usedAi ? " + ai-cli" : ""
|
|
396
|
+
}: ${summary || "none"}`
|
|
397
|
+
);
|
|
398
|
+
for (const reason of result.reasons || []) {
|
|
399
|
+
activity(ws.sandboxId, "warn", reason);
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
404
|
+
activity(
|
|
405
|
+
ws.sandboxId,
|
|
406
|
+
"warn",
|
|
407
|
+
`port detect failed (${message}) — using AI server ${Number(ws.port) || 3100} so setup can continue`
|
|
408
|
+
);
|
|
409
|
+
if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
|
|
410
|
+
ws.hostApps = [
|
|
411
|
+
{
|
|
412
|
+
id: "ai-server",
|
|
413
|
+
name: "AI server",
|
|
414
|
+
role: "ai-server",
|
|
415
|
+
port: Number(ws.port) || 3100,
|
|
416
|
+
source: "default",
|
|
417
|
+
locked: true,
|
|
418
|
+
},
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
apps: ws.hostApps,
|
|
423
|
+
source: "default",
|
|
424
|
+
cached: false,
|
|
425
|
+
usedAi: false,
|
|
426
|
+
confused: true,
|
|
427
|
+
reasons: [message],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
298
432
|
function applyProjectInfo(ws, info, cfg) {
|
|
299
433
|
if (!ws || !info) return;
|
|
300
434
|
const folder = path.resolve(ws.folderPath || "");
|
|
@@ -328,8 +462,8 @@ function applyProjectInfo(ws, info, cfg) {
|
|
|
328
462
|
|
|
329
463
|
const PROJECT_INSPECT_CACHE = "project-inspect.json";
|
|
330
464
|
|
|
331
|
-
function projectInspectCachePath(folder) {
|
|
332
|
-
return path.join(folder,
|
|
465
|
+
function projectInspectCachePath(folder, sandboxId) {
|
|
466
|
+
return path.join(dataDirFor(folder, sandboxId), PROJECT_INSPECT_CACHE);
|
|
333
467
|
}
|
|
334
468
|
|
|
335
469
|
/** Invalidate cache when package.json / common config files change. */
|
|
@@ -378,10 +512,10 @@ function usableCachedProjectInfo(info) {
|
|
|
378
512
|
return hasKind || hasScript || Boolean(info.summary);
|
|
379
513
|
}
|
|
380
514
|
|
|
381
|
-
function readProjectInspectCache(folder) {
|
|
515
|
+
function readProjectInspectCache(folder, sandboxId) {
|
|
382
516
|
const resolved = path.resolve(folder || "");
|
|
383
517
|
try {
|
|
384
|
-
const file = projectInspectCachePath(resolved);
|
|
518
|
+
const file = projectInspectCachePath(resolved, sandboxId);
|
|
385
519
|
if (!fs.existsSync(file)) return null;
|
|
386
520
|
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
387
521
|
if (!data || typeof data !== "object") return null;
|
|
@@ -399,11 +533,11 @@ function readProjectInspectCache(folder) {
|
|
|
399
533
|
}
|
|
400
534
|
}
|
|
401
535
|
|
|
402
|
-
function writeProjectInspectCache(folder, info) {
|
|
536
|
+
function writeProjectInspectCache(folder, info, sandboxId) {
|
|
403
537
|
const resolved = path.resolve(folder || "");
|
|
404
538
|
if (!info || !usableCachedProjectInfo(info)) return;
|
|
405
539
|
try {
|
|
406
|
-
const dir =
|
|
540
|
+
const dir = dataDirFor(resolved, sandboxId);
|
|
407
541
|
fs.mkdirSync(dir, { recursive: true });
|
|
408
542
|
const fingerprint = projectFingerprint(resolved);
|
|
409
543
|
const payload = {
|
|
@@ -423,7 +557,7 @@ function writeProjectInspectCache(folder, info) {
|
|
|
423
557
|
},
|
|
424
558
|
};
|
|
425
559
|
fs.writeFileSync(
|
|
426
|
-
projectInspectCachePath(resolved),
|
|
560
|
+
projectInspectCachePath(resolved, sandboxId),
|
|
427
561
|
`${JSON.stringify(payload, null, 2)}\n`,
|
|
428
562
|
"utf8"
|
|
429
563
|
);
|
|
@@ -447,7 +581,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
447
581
|
|
|
448
582
|
if (!force) {
|
|
449
583
|
const fingerprint = projectFingerprint(folder);
|
|
450
|
-
const fromDisk = readProjectInspectCache(folder);
|
|
584
|
+
const fromDisk = readProjectInspectCache(folder, ws.sandboxId);
|
|
451
585
|
const fromWs =
|
|
452
586
|
usableCachedProjectInfo(ws.projectInfo) &&
|
|
453
587
|
(!ws.projectInfo.fingerprint ||
|
|
@@ -458,7 +592,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
458
592
|
if (cached) {
|
|
459
593
|
log(`using cached project inspect for ${folder}`);
|
|
460
594
|
applyProjectInfo(ws, { ...cached, fingerprint }, opts.cfg);
|
|
461
|
-
if (!fromDisk) writeProjectInspectCache(folder, cached);
|
|
595
|
+
if (!fromDisk) writeProjectInspectCache(folder, cached, ws.sandboxId);
|
|
462
596
|
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
463
597
|
return { ...cached, fingerprint, cached: true };
|
|
464
598
|
}
|
|
@@ -475,7 +609,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
475
609
|
});
|
|
476
610
|
const fingerprint = projectFingerprint(folder);
|
|
477
611
|
applyProjectInfo(ws, { ...info, fingerprint }, opts.cfg);
|
|
478
|
-
writeProjectInspectCache(folder, info);
|
|
612
|
+
writeProjectInspectCache(folder, info, ws.sandboxId);
|
|
479
613
|
if (info.issues?.length) {
|
|
480
614
|
recordProcessProblem({
|
|
481
615
|
sandboxId: ws.sandboxId,
|
|
@@ -498,7 +632,7 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
498
632
|
warn(`ai-cli inspect failed: ${message}`);
|
|
499
633
|
// Fall back to any stale cache so Start Apps can still try.
|
|
500
634
|
const fallback =
|
|
501
|
-
readProjectInspectCache(folder) ||
|
|
635
|
+
readProjectInspectCache(folder, ws.sandboxId) ||
|
|
502
636
|
(usableCachedProjectInfo(ws.projectInfo) ? ws.projectInfo : null);
|
|
503
637
|
if (fallback) {
|
|
504
638
|
warn(`falling back to cached project inspect after ai-cli error`);
|
|
@@ -559,10 +693,14 @@ function urlWithPort(url, port) {
|
|
|
559
693
|
|
|
560
694
|
function isLocalAppUrl(url) {
|
|
561
695
|
try {
|
|
562
|
-
const
|
|
563
|
-
|
|
696
|
+
const parsed = new URL(String(url || "").trim());
|
|
697
|
+
// Share proxy paths live on the admin host; they are not the listen URL.
|
|
698
|
+
if (parsed.pathname === "/p" || parsed.pathname.startsWith("/p/")) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
564
702
|
} catch {
|
|
565
|
-
return
|
|
703
|
+
return false;
|
|
566
704
|
}
|
|
567
705
|
}
|
|
568
706
|
|
|
@@ -575,6 +713,71 @@ function persistWorkspaceEntry(cfg, ws) {
|
|
|
575
713
|
saveConfig(cfg);
|
|
576
714
|
}
|
|
577
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Sandbox store keys live on the workspace (and ~/.maintainer-pro/bridge.json),
|
|
718
|
+
* not in the app .env. Never use the bridge machine token as MAINTAINER_PRO_API_KEY.
|
|
719
|
+
*/
|
|
720
|
+
async function loadSandboxStoreEnv(ws, cfg) {
|
|
721
|
+
const cached = ws?.store && typeof ws.store === "object" ? ws.store : {};
|
|
722
|
+
const cachedServer = String(cached.serverKey || "").trim();
|
|
723
|
+
const cachedClient = String(cached.clientKey || "").trim();
|
|
724
|
+
if (cachedServer) {
|
|
725
|
+
return {
|
|
726
|
+
MAINTAINER_PRO_URL: cfg?.adminUrl || "",
|
|
727
|
+
MAINTAINER_PRO_API_KEY: cachedServer,
|
|
728
|
+
MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
|
|
729
|
+
NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
|
|
730
|
+
NEXT_PUBLIC_MAINTAINER_PRO_URL: cfg?.adminUrl || "",
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
if (!cfg?.adminUrl || !cfg.token || !ws?.sandboxId) {
|
|
734
|
+
return {
|
|
735
|
+
MAINTAINER_PRO_URL: cfg?.adminUrl || "",
|
|
736
|
+
MAINTAINER_PRO_API_KEY: "",
|
|
737
|
+
MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const config = await api(
|
|
742
|
+
cfg.adminUrl,
|
|
743
|
+
cfg.token,
|
|
744
|
+
"GET",
|
|
745
|
+
`/api/v1/bridge/machine/sandboxes/${ws.sandboxId}/setup-config?port=${
|
|
746
|
+
Number(ws.port) || 3100
|
|
747
|
+
}`
|
|
748
|
+
);
|
|
749
|
+
const env = config?.env && typeof config.env === "object" ? config.env : {};
|
|
750
|
+
const serverKey = String(env.MAINTAINER_PRO_API_KEY || "").trim();
|
|
751
|
+
const clientKey = String(
|
|
752
|
+
env.MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
753
|
+
env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
754
|
+
""
|
|
755
|
+
).trim();
|
|
756
|
+
ws.store = { serverKey, clientKey };
|
|
757
|
+
persistWorkspaceEntry(cfg, ws);
|
|
758
|
+
return {
|
|
759
|
+
MAINTAINER_PRO_URL: String(env.MAINTAINER_PRO_URL || cfg.adminUrl || ""),
|
|
760
|
+
MAINTAINER_PRO_API_KEY: serverKey,
|
|
761
|
+
MAINTAINER_PRO_CLIENT_API_KEY: clientKey,
|
|
762
|
+
NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY: clientKey,
|
|
763
|
+
NEXT_PUBLIC_MAINTAINER_PRO_URL: String(
|
|
764
|
+
env.NEXT_PUBLIC_MAINTAINER_PRO_URL || cfg.adminUrl || ""
|
|
765
|
+
),
|
|
766
|
+
};
|
|
767
|
+
} catch (err) {
|
|
768
|
+
warn(
|
|
769
|
+
`setup-config ${shortId(ws.sandboxId)}: ${
|
|
770
|
+
err instanceof Error ? err.message : String(err)
|
|
771
|
+
}`
|
|
772
|
+
);
|
|
773
|
+
return {
|
|
774
|
+
MAINTAINER_PRO_URL: cfg.adminUrl || "",
|
|
775
|
+
MAINTAINER_PRO_API_KEY: "",
|
|
776
|
+
MAINTAINER_PRO_CLIENT_API_KEY: cachedClient,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
578
781
|
function probeUrl(url, timeoutMs = 2500) {
|
|
579
782
|
return new Promise((resolve) => {
|
|
580
783
|
let settled = false;
|
|
@@ -609,6 +812,88 @@ function probeUrl(url, timeoutMs = 2500) {
|
|
|
609
812
|
});
|
|
610
813
|
}
|
|
611
814
|
|
|
815
|
+
function adminListenPort(cfg = bridgeCfg) {
|
|
816
|
+
try {
|
|
817
|
+
const u = new URL(String(cfg?.adminUrl || "http://localhost:4100"));
|
|
818
|
+
if (u.port) return Number(u.port);
|
|
819
|
+
return u.protocol === "https:" ? 443 : 80;
|
|
820
|
+
} catch {
|
|
821
|
+
return 4100;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function isAdminListenPort(port, cfg = bridgeCfg) {
|
|
826
|
+
return Number(port) === adminListenPort(cfg);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function looksLikeEmbedConfig(body, contentType) {
|
|
830
|
+
const text = String(body || "").trim();
|
|
831
|
+
if (!text || text.startsWith("<")) return false;
|
|
832
|
+
const ct = String(contentType || "").toLowerCase();
|
|
833
|
+
if (ct.includes("html")) return false;
|
|
834
|
+
return (
|
|
835
|
+
text.includes("__MAINTAINER_PRO__") || /javascript|ecmascript/.test(ct)
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function probeEmbedConfig(url, timeoutMs = 2500) {
|
|
840
|
+
return new Promise((resolve) => {
|
|
841
|
+
let settled = false;
|
|
842
|
+
const done = (ok) => {
|
|
843
|
+
if (settled) return;
|
|
844
|
+
settled = true;
|
|
845
|
+
resolve(ok);
|
|
846
|
+
};
|
|
847
|
+
try {
|
|
848
|
+
const parsed = new URL(String(url));
|
|
849
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
850
|
+
const req = lib.get(
|
|
851
|
+
parsed,
|
|
852
|
+
{
|
|
853
|
+
timeout: timeoutMs,
|
|
854
|
+
rejectUnauthorized: true,
|
|
855
|
+
headers: { Accept: "application/javascript,*/*" },
|
|
856
|
+
},
|
|
857
|
+
(res) => {
|
|
858
|
+
const chunks = [];
|
|
859
|
+
res.on("data", (d) => {
|
|
860
|
+
if (chunks.reduce((n, c) => n + c.length, 0) < 8192) chunks.push(d);
|
|
861
|
+
});
|
|
862
|
+
res.on("end", () => {
|
|
863
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
864
|
+
done(
|
|
865
|
+
Boolean(res.statusCode && res.statusCode < 400) &&
|
|
866
|
+
looksLikeEmbedConfig(body, res.headers["content-type"])
|
|
867
|
+
);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
req.on("error", () => done(false));
|
|
872
|
+
req.on("timeout", () => {
|
|
873
|
+
req.destroy();
|
|
874
|
+
done(false);
|
|
875
|
+
});
|
|
876
|
+
} catch {
|
|
877
|
+
done(false);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function sanitizeAiServerPort(ws) {
|
|
883
|
+
if (!ws || typeof ws !== "object") return;
|
|
884
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
885
|
+
for (const app of apps) {
|
|
886
|
+
if (
|
|
887
|
+
app &&
|
|
888
|
+
(app.role === "ai-server" || app.id === "ai-server") &&
|
|
889
|
+
isAdminListenPort(app.port)
|
|
890
|
+
) {
|
|
891
|
+
app.port = 3100;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (isAdminListenPort(ws.port)) ws.port = 3100;
|
|
895
|
+
}
|
|
896
|
+
|
|
612
897
|
function fetchText(url, timeoutMs = 8000) {
|
|
613
898
|
return new Promise((resolve) => {
|
|
614
899
|
try {
|
|
@@ -645,43 +930,33 @@ function fetchText(url, timeoutMs = 8000) {
|
|
|
645
930
|
});
|
|
646
931
|
}
|
|
647
932
|
|
|
648
|
-
function
|
|
649
|
-
if (process.platform !== "win32") return ["/"];
|
|
650
|
-
const roots = [];
|
|
651
|
-
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
|
|
652
|
-
const root = `${letter}:\\`;
|
|
653
|
-
try {
|
|
654
|
-
if (fs.existsSync(root)) roots.push(root);
|
|
655
|
-
} catch {
|
|
656
|
-
/* skip */
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
return roots.length ? roots : ["C:\\"];
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function listDirEntries(dirPath) {
|
|
933
|
+
function listDirEntries(dirPath, allowed) {
|
|
663
934
|
const raw = String(dirPath || "").trim();
|
|
664
935
|
const home = os.homedir();
|
|
936
|
+
const roots =
|
|
937
|
+
Array.isArray(allowed) && allowed.length > 0
|
|
938
|
+
? allowed.map((p) => path.resolve(p))
|
|
939
|
+
: [process.cwd()];
|
|
940
|
+
|
|
665
941
|
if (!raw || raw === "roots") {
|
|
666
|
-
|
|
942
|
+
return listLaunchRoots(roots);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const resolved = path.resolve(raw);
|
|
946
|
+
if (!roots.some((root) => pathInside(resolved, root))) {
|
|
667
947
|
return {
|
|
668
|
-
|
|
948
|
+
error: "That folder is outside the directory where the bridge is running.",
|
|
949
|
+
path: resolved,
|
|
669
950
|
parent: null,
|
|
670
951
|
home,
|
|
671
|
-
entries:
|
|
672
|
-
name: root,
|
|
673
|
-
path: root,
|
|
674
|
-
isDir: true,
|
|
675
|
-
})),
|
|
952
|
+
entries: [],
|
|
676
953
|
};
|
|
677
954
|
}
|
|
678
|
-
|
|
679
|
-
const resolved = path.resolve(raw);
|
|
680
955
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
681
956
|
return {
|
|
682
957
|
error: "Not a directory",
|
|
683
958
|
path: resolved,
|
|
684
|
-
parent: path.dirname(resolved),
|
|
959
|
+
parent: isLaunchRoot(resolved, roots) ? null : path.dirname(resolved),
|
|
685
960
|
home,
|
|
686
961
|
entries: [],
|
|
687
962
|
};
|
|
@@ -706,10 +981,11 @@ function listDirEntries(dirPath) {
|
|
|
706
981
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
707
982
|
return a.name.localeCompare(b.name);
|
|
708
983
|
});
|
|
984
|
+
const atRoot = isLaunchRoot(resolved, roots);
|
|
709
985
|
const parent = path.dirname(resolved);
|
|
710
986
|
return {
|
|
711
987
|
path: resolved,
|
|
712
|
-
parent: parent === resolved ? null : parent,
|
|
988
|
+
parent: atRoot ? (roots.length > 1 ? "" : null) : parent === resolved ? null : parent,
|
|
713
989
|
home,
|
|
714
990
|
entries: entries.slice(0, 400),
|
|
715
991
|
};
|
|
@@ -743,54 +1019,20 @@ function mergeEnvFile(file, values, opts = {}) {
|
|
|
743
1019
|
fs.writeFileSync(file, body + "\n", "utf8");
|
|
744
1020
|
}
|
|
745
1021
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
key === "AI_SERVER_URL" ||
|
|
759
|
-
key.startsWith("NEXT_PUBLIC_") ||
|
|
760
|
-
key.startsWith("VITE_") ||
|
|
761
|
-
key.startsWith("REACT_APP_")
|
|
762
|
-
) {
|
|
763
|
-
localValues[key] = value;
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
const localRemove = remove.filter(
|
|
767
|
-
(key) =>
|
|
768
|
-
key === "PORT" ||
|
|
769
|
-
key === "APP_URL" ||
|
|
770
|
-
key === "PUBLIC_URL" ||
|
|
771
|
-
key === "CORS_ORIGIN" ||
|
|
772
|
-
key === "AI_SERVER_URL" ||
|
|
773
|
-
key.startsWith("NEXT_PUBLIC_") ||
|
|
774
|
-
key.startsWith("VITE_") ||
|
|
775
|
-
key.startsWith("REACT_APP_")
|
|
776
|
-
);
|
|
777
|
-
if (Object.keys(localValues).length || localRemove.length) {
|
|
778
|
-
mergeEnvFile(path.join(folder, ".env.local"), localValues, {
|
|
779
|
-
remove: localRemove,
|
|
780
|
-
});
|
|
1022
|
+
/**
|
|
1023
|
+
* Do not write Maintainer Pro keys into the app. The bridge injects env into
|
|
1024
|
+
* the process it launches (terminal script / in-process chat).
|
|
1025
|
+
* Only strip leftover trycloudflare values we previously wrote.
|
|
1026
|
+
*/
|
|
1027
|
+
function writeProjectEnv(folder, _values, opts = {}) {
|
|
1028
|
+
const remove = (opts.remove || []).filter(Boolean);
|
|
1029
|
+
if (!folder || !remove.length) return;
|
|
1030
|
+
for (const name of [".env", ".env.local"]) {
|
|
1031
|
+
const file = path.join(folder, name);
|
|
1032
|
+
if (!fs.existsSync(file)) continue;
|
|
1033
|
+
mergeEnvFile(file, {}, { remove });
|
|
781
1034
|
}
|
|
782
|
-
|
|
783
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
784
|
-
.join(" ");
|
|
785
|
-
log(
|
|
786
|
-
`env write ${folder} .env${
|
|
787
|
-
Object.keys(localValues).length || localRemove.length
|
|
788
|
-
? " +.env.local"
|
|
789
|
-
: ""
|
|
790
|
-
}${remove.length ? ` remove=${remove.join(",")}` : ""}${
|
|
791
|
-
keys ? ` ${keys}` : ""
|
|
792
|
-
}`
|
|
793
|
-
);
|
|
1035
|
+
log(`env strip ${folder} remove=${remove.join(",")}`);
|
|
794
1036
|
}
|
|
795
1037
|
|
|
796
1038
|
function originFromUrl(value) {
|
|
@@ -822,11 +1064,6 @@ function isTryCloudflareUrl(value) {
|
|
|
822
1064
|
}
|
|
823
1065
|
}
|
|
824
1066
|
|
|
825
|
-
function normalizePublicOrigin(value) {
|
|
826
|
-
const origin = originFromUrl(value);
|
|
827
|
-
return origin || null;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
1067
|
function readCloudflareTunnelFile(folder) {
|
|
831
1068
|
if (!folder) return null;
|
|
832
1069
|
const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
|
|
@@ -856,9 +1093,9 @@ function clearCloudflareTunnelFile(folder) {
|
|
|
856
1093
|
}
|
|
857
1094
|
|
|
858
1095
|
/** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
|
|
859
|
-
function archiveStaleCloudflareLogs(folder) {
|
|
1096
|
+
function archiveStaleCloudflareLogs(folder, sandboxId) {
|
|
860
1097
|
if (!folder) return;
|
|
861
|
-
const logDir =
|
|
1098
|
+
const logDir = dataDirFor(folder, sandboxId);
|
|
862
1099
|
if (!fs.existsSync(logDir)) return;
|
|
863
1100
|
let names = [];
|
|
864
1101
|
try {
|
|
@@ -883,6 +1120,7 @@ const CLOUDFLARE_ENV_KEYS = [
|
|
|
883
1120
|
"APP_URL",
|
|
884
1121
|
"PUBLIC_URL",
|
|
885
1122
|
"CORS_ORIGIN",
|
|
1123
|
+
"CORS_ORIGINS",
|
|
886
1124
|
"NEXT_PUBLIC_APP_URL",
|
|
887
1125
|
"VITE_APP_URL",
|
|
888
1126
|
"REACT_APP_APP_URL",
|
|
@@ -938,7 +1176,7 @@ function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
|
|
|
938
1176
|
}
|
|
939
1177
|
|
|
940
1178
|
// Always archive cf-*.log so dead trycloudflare hosts are not rediscovered.
|
|
941
|
-
archiveStaleCloudflareLogs(folder);
|
|
1179
|
+
archiveStaleCloudflareLogs(folder, ws.sandboxId);
|
|
942
1180
|
if (keep && Object.keys(keep).length) {
|
|
943
1181
|
writeTunnelEnv(ws, keep);
|
|
944
1182
|
} else {
|
|
@@ -958,12 +1196,7 @@ function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
|
|
|
958
1196
|
remove.push(key);
|
|
959
1197
|
}
|
|
960
1198
|
if (remove.length) {
|
|
961
|
-
|
|
962
|
-
const localFallback = {};
|
|
963
|
-
if (opts.localEnv && typeof opts.localEnv === "object") {
|
|
964
|
-
Object.assign(localFallback, opts.localEnv);
|
|
965
|
-
}
|
|
966
|
-
writeProjectEnv(folder, localFallback, { remove });
|
|
1199
|
+
writeProjectEnv(folder, {}, { remove });
|
|
967
1200
|
}
|
|
968
1201
|
|
|
969
1202
|
persistWorkspaceEntry(cfg, ws);
|
|
@@ -1026,7 +1259,7 @@ function discoverCloudflareTunnels(ws) {
|
|
|
1026
1259
|
];
|
|
1027
1260
|
for (const [role, value] of envRoles) setRole(role, value);
|
|
1028
1261
|
|
|
1029
|
-
const logDir =
|
|
1262
|
+
const logDir = dataDirFor(folder, ws.sandboxId);
|
|
1030
1263
|
if (fs.existsSync(logDir)) {
|
|
1031
1264
|
const sandboxPrefix = `cf-${String(ws.sandboxId || "").slice(0, 8)}-`;
|
|
1032
1265
|
let names = [];
|
|
@@ -1064,31 +1297,6 @@ function discoverCloudflareTunnels(ws) {
|
|
|
1064
1297
|
return Object.keys(tunnels).length ? tunnels : null;
|
|
1065
1298
|
}
|
|
1066
1299
|
|
|
1067
|
-
function rememberCloudflareTunnels(sandboxId, tunnels) {
|
|
1068
|
-
if (!sandboxId || !tunnels) return;
|
|
1069
|
-
cloudflareTunnels.set(sandboxId, {
|
|
1070
|
-
tunnels: Object.entries(tunnels)
|
|
1071
|
-
.filter(([, url]) => Boolean(url))
|
|
1072
|
-
.map(([role, publicUrl]) => ({
|
|
1073
|
-
role,
|
|
1074
|
-
localUrl: "",
|
|
1075
|
-
publicUrl: String(publicUrl),
|
|
1076
|
-
logFile: "",
|
|
1077
|
-
})),
|
|
1078
|
-
});
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function cloudflareLive(ws) {
|
|
1082
|
-
if (!ws?.sandboxId) return false;
|
|
1083
|
-
if (ws.cloudflarePending) return false;
|
|
1084
|
-
// Only tunnels managed in this process count as live. Persisted
|
|
1085
|
-
// cloudflareUrl / discovered log URLs go stale when apps stop.
|
|
1086
|
-
const row = cloudflareTunnels.get(ws.sandboxId);
|
|
1087
|
-
return Boolean(
|
|
1088
|
-
row?.tunnels?.some((t) => isTryCloudflareUrl(t.publicUrl))
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1300
|
function workspaceHostReport(ws) {
|
|
1093
1301
|
/** @type {string[]} */
|
|
1094
1302
|
const origins = [];
|
|
@@ -1097,41 +1305,22 @@ function workspaceHostReport(ws) {
|
|
|
1097
1305
|
if (origin && !origins.includes(origin)) origins.push(origin);
|
|
1098
1306
|
};
|
|
1099
1307
|
const env = readProjectEnvValues(ws.folderPath);
|
|
1100
|
-
const discovered = discoverCloudflareTunnels(ws);
|
|
1101
|
-
const liveCf = cloudflareLive(ws);
|
|
1102
1308
|
const chatPort = Number(env.AI_SERVER_PORT || ws.port);
|
|
1103
1309
|
const uiPort = Number(
|
|
1104
1310
|
env.PORT || ws.projectInfo?.ports?.ui || ws.projectInfo?.ports?.app
|
|
1105
1311
|
);
|
|
1106
|
-
const cf =
|
|
1107
|
-
liveCf || discovered
|
|
1108
|
-
? {
|
|
1109
|
-
...(typeof ws.cloudflare === "object" && ws.cloudflare
|
|
1110
|
-
? ws.cloudflare
|
|
1111
|
-
: {}),
|
|
1112
|
-
...(discovered || {}),
|
|
1113
|
-
}
|
|
1114
|
-
: null;
|
|
1115
1312
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
env.NEXT_PUBLIC_APP_URL,
|
|
1128
|
-
env.PUBLIC_URL,
|
|
1129
|
-
ws.appUrl,
|
|
1130
|
-
]) {
|
|
1131
|
-
if (value && isLocalAppUrl(value)) add(value);
|
|
1132
|
-
}
|
|
1133
|
-
add(env.AI_SERVER_URL);
|
|
1134
|
-
add(env.NEXT_PUBLIC_AI_SERVER_URL);
|
|
1313
|
+
for (const value of [
|
|
1314
|
+
env.CORS_ORIGIN,
|
|
1315
|
+
env.APP_URL,
|
|
1316
|
+
env.NEXT_PUBLIC_APP_URL,
|
|
1317
|
+
env.PUBLIC_URL,
|
|
1318
|
+
env.AI_SERVER_URL,
|
|
1319
|
+
env.NEXT_PUBLIC_AI_SERVER_URL,
|
|
1320
|
+
ws.appUrl,
|
|
1321
|
+
]) {
|
|
1322
|
+
if (!value || isTryCloudflareUrl(value)) continue;
|
|
1323
|
+
add(value);
|
|
1135
1324
|
}
|
|
1136
1325
|
if (chatPort) {
|
|
1137
1326
|
add(`http://localhost:${chatPort}`);
|
|
@@ -1142,13 +1331,13 @@ function workspaceHostReport(ws) {
|
|
|
1142
1331
|
add(`http://127.0.0.1:${uiPort}`);
|
|
1143
1332
|
}
|
|
1144
1333
|
|
|
1334
|
+
const uiShare = proxyUrlForApp(
|
|
1335
|
+
ws,
|
|
1336
|
+
hostAppOf(ws) || { id: "ui", role: "ui", host: true }
|
|
1337
|
+
);
|
|
1145
1338
|
let appUrl = null;
|
|
1146
|
-
if (
|
|
1147
|
-
appUrl =
|
|
1148
|
-
} else if (ws.cloudflareUrl && isTryCloudflareUrl(ws.cloudflareUrl)) {
|
|
1149
|
-
appUrl = normalizePublicOrigin(ws.cloudflareUrl);
|
|
1150
|
-
} else if (cf?.ai && isTryCloudflareUrl(cf.ai)) {
|
|
1151
|
-
appUrl = normalizePublicOrigin(cf.ai);
|
|
1339
|
+
if (uiShare) {
|
|
1340
|
+
appUrl = uiShare;
|
|
1152
1341
|
} else if (env.APP_URL && isLocalAppUrl(env.APP_URL)) {
|
|
1153
1342
|
appUrl = originFromUrl(env.APP_URL);
|
|
1154
1343
|
} else if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
|
|
@@ -1161,59 +1350,17 @@ function workspaceHostReport(ws) {
|
|
|
1161
1350
|
return { appUrl, origins };
|
|
1162
1351
|
}
|
|
1163
1352
|
|
|
1164
|
-
/**
|
|
1165
|
-
* Probe discovered public URLs and keep only ones that respond.
|
|
1166
|
-
* @returns {Promise<{ live: Record<string, string> | null, dead: Record<string, string> }>}
|
|
1167
|
-
*/
|
|
1168
|
-
async function filterReachableCloudflareTunnels(tunnels) {
|
|
1169
|
-
/** @type {Record<string, string>} */
|
|
1170
|
-
const live = {};
|
|
1171
|
-
/** @type {Record<string, string>} */
|
|
1172
|
-
const dead = {};
|
|
1173
|
-
if (!tunnels || !Object.keys(tunnels).length) {
|
|
1174
|
-
return { live: null, dead };
|
|
1175
|
-
}
|
|
1176
|
-
for (const [role, url] of Object.entries(tunnels)) {
|
|
1177
|
-
if (!isTryCloudflareUrl(url)) continue;
|
|
1178
|
-
const normalized = String(url).replace(/\/$/, "");
|
|
1179
|
-
const target =
|
|
1180
|
-
role === "ai"
|
|
1181
|
-
? `${normalized}/embed-config.js`
|
|
1182
|
-
: normalized;
|
|
1183
|
-
if (await probeUrl(target, 8_000)) {
|
|
1184
|
-
live[role] = normalized;
|
|
1185
|
-
} else {
|
|
1186
|
-
dead[role] = normalized;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
return {
|
|
1190
|
-
live: Object.keys(live).length ? live : null,
|
|
1191
|
-
dead,
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
function envNeedsUpdate(folder, desired) {
|
|
1196
|
-
if (!desired || !Object.keys(desired).length) return false;
|
|
1197
|
-
const current = readProjectEnvValues(folder);
|
|
1198
|
-
return Object.entries(desired).some(
|
|
1199
|
-
([key, value]) => String(current[key] || "") !== String(value)
|
|
1200
|
-
);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
1353
|
/**
|
|
1204
1354
|
* Status-only reconcile for a workspace:
|
|
1205
1355
|
* 1) probe chat / ui / backend
|
|
1206
|
-
* 2)
|
|
1207
|
-
* 3)
|
|
1208
|
-
* 4) compute host appUrl + CORS origins for Maintainer Pro
|
|
1356
|
+
* 2) drop leftover trycloudflare URLs (never start or reuse tunnels)
|
|
1357
|
+
* 3) compute host appUrl + CORS origins for Maintainer Pro
|
|
1209
1358
|
*
|
|
1210
|
-
* Does not start apps
|
|
1359
|
+
* Does not start apps. Does not write the app's .env files.
|
|
1211
1360
|
*/
|
|
1212
1361
|
async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
1213
|
-
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1214
|
-
const writeEnv = opts.writeEnv !== false;
|
|
1215
1362
|
const timeoutMs = opts.timeoutMs || 800;
|
|
1216
|
-
|
|
1363
|
+
sanitizeAiServerPort(ws);
|
|
1217
1364
|
|
|
1218
1365
|
// 1. Local process probes
|
|
1219
1366
|
const probe = await probeRunningApps(ws, timeoutMs);
|
|
@@ -1221,133 +1368,29 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
|
1221
1368
|
ws.port = probe.chatPort;
|
|
1222
1369
|
}
|
|
1223
1370
|
|
|
1224
|
-
// 2. Cloudflare
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const cfCheck = discovered
|
|
1228
|
-
? await filterReachableCloudflareTunnels(discovered)
|
|
1229
|
-
: { live: null, dead: {} };
|
|
1230
|
-
const reachableCf = cfCheck.live;
|
|
1231
|
-
const deadCf = cfCheck.dead;
|
|
1232
|
-
const managedLive = cloudflareLive(ws);
|
|
1233
|
-
|
|
1234
|
-
if (Object.keys(deadCf).length > 0) {
|
|
1235
|
-
/** @type {Record<string, string>} */
|
|
1236
|
-
let localEnv = {};
|
|
1237
|
-
if (probe.running) {
|
|
1238
|
-
const jobs = probe.hosts
|
|
1239
|
-
.filter((h) => h.up)
|
|
1240
|
-
.map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
|
|
1241
|
-
localEnv = envForWorkspacePorts(ws, jobs);
|
|
1242
|
-
}
|
|
1243
|
-
/** @type {Record<string, string> | null} */
|
|
1244
|
-
let keep = reachableCf;
|
|
1245
|
-
if (!keep && managedLive) {
|
|
1246
|
-
const managed = {};
|
|
1247
|
-
for (const t of cloudflareTunnels.get(ws.sandboxId)?.tunnels || []) {
|
|
1248
|
-
if (t?.role && isTryCloudflareUrl(t.publicUrl)) {
|
|
1249
|
-
managed[t.role] = String(t.publicUrl).replace(/\/$/, "");
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
keep = Object.keys(managed).length ? managed : null;
|
|
1253
|
-
}
|
|
1371
|
+
// 2. Remove leftover Cloudflare tunnels / env — share URLs replace them.
|
|
1372
|
+
const leftover = discoverCloudflareTunnels(ws);
|
|
1373
|
+
if (leftover || ws.cloudflareUrl || ws.cloudflare) {
|
|
1254
1374
|
purgeUnreachableCloudflare(ws, cfg, {
|
|
1255
|
-
deadUrls: Object.values(
|
|
1256
|
-
keep,
|
|
1257
|
-
localEnv,
|
|
1375
|
+
deadUrls: leftover ? Object.values(leftover) : [],
|
|
1376
|
+
keep: null,
|
|
1258
1377
|
});
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
);
|
|
1264
|
-
|
|
1265
|
-
if (reachableCf) {
|
|
1266
|
-
ws.cloudflare = { ...(ws.cloudflare || {}), ...reachableCf };
|
|
1267
|
-
ws.cloudflareUrl = reachableCf.ui || reachableCf.ai || ws.cloudflareUrl || null;
|
|
1268
|
-
if (reachableCf.ui || reachableCf.ai) {
|
|
1269
|
-
ws.appUrl = reachableCf.ui || reachableCf.ai;
|
|
1270
|
-
}
|
|
1271
|
-
} else if (!managedLive) {
|
|
1272
|
-
ws.cloudflareUrl = null;
|
|
1273
|
-
ws.cloudflare = null;
|
|
1274
|
-
if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) ws.appUrl = null;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const cfTunnels =
|
|
1278
|
-
usingCloudflare
|
|
1279
|
-
? {
|
|
1280
|
-
...(typeof ws.cloudflare === "object" && ws.cloudflare
|
|
1281
|
-
? ws.cloudflare
|
|
1282
|
-
: {}),
|
|
1283
|
-
...(reachableCf || {}),
|
|
1284
|
-
}
|
|
1285
|
-
: null;
|
|
1286
|
-
|
|
1287
|
-
// 3. Sync env base URLs when apps are up or Cloudflare is live
|
|
1288
|
-
if (writeEnv && folder && fs.existsSync(folder)) {
|
|
1289
|
-
/** @type {Record<string, string>} */
|
|
1290
|
-
let desired = {};
|
|
1291
|
-
if (cfTunnels && Object.keys(cfTunnels).length) {
|
|
1292
|
-
desired = {
|
|
1293
|
-
...uiPublicEnv(cfTunnels),
|
|
1294
|
-
AI_SERVER_PORT: String(probe.chatPort || ws.port || 3100),
|
|
1295
|
-
};
|
|
1296
|
-
if (probe.hosts.some((h) => h.role === "ui" || h.role === "app")) {
|
|
1297
|
-
const ui = probe.hosts.find(
|
|
1298
|
-
(h) => (h.role === "ui" || h.role === "app") && h.up
|
|
1299
|
-
);
|
|
1300
|
-
if (ui?.port) desired.PORT = String(ui.port);
|
|
1301
|
-
}
|
|
1302
|
-
if (envNeedsUpdate(folder, desired)) {
|
|
1303
|
-
log(
|
|
1304
|
-
`env sync ${label}: cloudflare urls (${Object.keys(cfTunnels).join(",")})`
|
|
1305
|
-
);
|
|
1306
|
-
writeTunnelEnv(ws, cfTunnels);
|
|
1307
|
-
writeProjectEnv(folder, desired);
|
|
1308
|
-
}
|
|
1309
|
-
} else if (probe.running) {
|
|
1310
|
-
const jobs = probe.hosts
|
|
1311
|
-
.filter((h) => h.up)
|
|
1312
|
-
.map((h) => ({ role: h.role, port: h.port, preferredPort: h.port }));
|
|
1313
|
-
desired = envForWorkspacePorts(ws, jobs);
|
|
1314
|
-
// Drop stale trycloudflare values when running local-only.
|
|
1315
|
-
const current = readProjectEnvValues(folder);
|
|
1316
|
-
/** @type {string[]} */
|
|
1317
|
-
const remove = [];
|
|
1318
|
-
for (const key of [
|
|
1319
|
-
"APP_URL",
|
|
1320
|
-
"PUBLIC_URL",
|
|
1321
|
-
"CORS_ORIGIN",
|
|
1322
|
-
"NEXT_PUBLIC_APP_URL",
|
|
1323
|
-
"VITE_APP_URL",
|
|
1324
|
-
"AI_SERVER_URL",
|
|
1325
|
-
"NEXT_PUBLIC_AI_SERVER_URL",
|
|
1326
|
-
"VITE_AI_SERVER_URL",
|
|
1327
|
-
"REACT_APP_AI_SERVER_URL",
|
|
1328
|
-
"API_URL",
|
|
1329
|
-
"VITE_API_URL",
|
|
1330
|
-
"NEXT_PUBLIC_API_URL",
|
|
1331
|
-
]) {
|
|
1332
|
-
if (isTryCloudflareUrl(current[key]) && desired[key]) {
|
|
1333
|
-
// overwritten by desired
|
|
1334
|
-
} else if (isTryCloudflareUrl(current[key]) && !desired[key]) {
|
|
1335
|
-
remove.push(key);
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
if (envNeedsUpdate(folder, desired) || remove.length) {
|
|
1339
|
-
log(`env sync ${label}: local app urls`);
|
|
1340
|
-
writeProjectEnv(folder, desired, { remove });
|
|
1341
|
-
}
|
|
1378
|
+
try {
|
|
1379
|
+
await stopCloudflare(ws.sandboxId, ws.folderPath);
|
|
1380
|
+
} catch {
|
|
1381
|
+
/* leftover cloudflared */
|
|
1342
1382
|
}
|
|
1343
1383
|
}
|
|
1344
1384
|
|
|
1345
|
-
//
|
|
1385
|
+
// 3. Host + CORS origins for Maintainer Pro
|
|
1346
1386
|
const host = workspaceHostReport(ws);
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1387
|
+
const uiShare = proxyUrlForApp(ws, hostAppOf(ws) || { id: "ui", role: "ui", host: true });
|
|
1388
|
+
const aiShare = proxyUrlForApp(ws, {
|
|
1389
|
+
id: "ai-server",
|
|
1390
|
+
role: "ai-server",
|
|
1391
|
+
});
|
|
1392
|
+
if (uiShare) {
|
|
1393
|
+
host.appUrl = uiShare;
|
|
1351
1394
|
} else if (probe.running) {
|
|
1352
1395
|
const ui = probe.hosts.find(
|
|
1353
1396
|
(h) => (h.role === "ui" || h.role === "app") && h.up
|
|
@@ -1358,6 +1401,12 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
|
1358
1401
|
host.appUrl = `http://localhost:${probe.chatPort}`;
|
|
1359
1402
|
}
|
|
1360
1403
|
}
|
|
1404
|
+
if (uiShare && !host.origins.includes(uiShare)) host.origins.push(uiShare);
|
|
1405
|
+
if (aiShare && !host.origins.includes(aiShare)) host.origins.push(aiShare);
|
|
1406
|
+
const proxyOrigin = String(ws?.proxy?.origin || "").replace(/\/$/, "");
|
|
1407
|
+
if (proxyOrigin && !host.origins.includes(proxyOrigin)) {
|
|
1408
|
+
host.origins.push(proxyOrigin);
|
|
1409
|
+
}
|
|
1361
1410
|
|
|
1362
1411
|
// Ensure probed local origins are always included when processes are up.
|
|
1363
1412
|
for (const h of probe.hosts) {
|
|
@@ -1371,12 +1420,6 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
|
1371
1420
|
const chatOrigin = `http://localhost:${probe.chatPort}`;
|
|
1372
1421
|
if (!host.origins.includes(chatOrigin)) host.origins.push(chatOrigin);
|
|
1373
1422
|
}
|
|
1374
|
-
if (cfTunnels) {
|
|
1375
|
-
for (const value of Object.values(cfTunnels)) {
|
|
1376
|
-
const origin = normalizePublicOrigin(value);
|
|
1377
|
-
if (origin && !host.origins.includes(origin)) host.origins.push(origin);
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
1423
|
|
|
1381
1424
|
if (host.appUrl) ws.appUrl = host.appUrl;
|
|
1382
1425
|
|
|
@@ -1395,146 +1438,77 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
|
1395
1438
|
|
|
1396
1439
|
return {
|
|
1397
1440
|
probe,
|
|
1398
|
-
usingCloudflare,
|
|
1399
|
-
cloudflare:
|
|
1441
|
+
usingCloudflare: false,
|
|
1442
|
+
cloudflare: null,
|
|
1400
1443
|
host,
|
|
1401
1444
|
appsRunning: probe.running,
|
|
1402
1445
|
aiServerUp: probe.chatUp,
|
|
1403
1446
|
};
|
|
1404
1447
|
}
|
|
1405
1448
|
|
|
1406
|
-
async function
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1449
|
+
async function probeHttpPaths(port, paths, timeoutMs = 2500) {
|
|
1450
|
+
const n = Number(port);
|
|
1451
|
+
if (!n) return false;
|
|
1452
|
+
for (const host of ["127.0.0.1", "localhost"]) {
|
|
1453
|
+
for (const suffix of paths) {
|
|
1454
|
+
if (await probeUrl(`http://${host}:${n}${suffix}`, timeoutMs)) return true;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function probePortOpen(port, timeoutMs = 800) {
|
|
1461
|
+
const n = Number(port);
|
|
1462
|
+
if (!n) return Promise.resolve(false);
|
|
1463
|
+
return new Promise((resolve) => {
|
|
1464
|
+
const socket = net.connect({ host: "127.0.0.1", port: n });
|
|
1465
|
+
const done = (ok) => {
|
|
1466
|
+
socket.removeAllListeners();
|
|
1467
|
+
socket.destroy();
|
|
1468
|
+
resolve(ok);
|
|
1469
|
+
};
|
|
1470
|
+
socket.setTimeout(timeoutMs);
|
|
1471
|
+
socket.once("connect", () => done(true));
|
|
1472
|
+
socket.once("timeout", () => done(false));
|
|
1473
|
+
socket.once("error", () => done(false));
|
|
1410
1474
|
});
|
|
1411
|
-
return status.usingCloudflare;
|
|
1412
1475
|
}
|
|
1413
1476
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
const discovered = discoverCloudflareTunnels(ws);
|
|
1426
|
-
if (!discovered) {
|
|
1427
|
-
await progress("No existing Cloudflare tunnel URLs found yet.");
|
|
1428
|
-
return null;
|
|
1429
|
-
}
|
|
1430
|
-
await progress(
|
|
1431
|
-
`Found candidate tunnels: ${Object.entries(discovered)
|
|
1432
|
-
.map(([role, url]) => `${role}=${url}`)
|
|
1433
|
-
.join(", ")}`
|
|
1434
|
-
);
|
|
1435
|
-
const { live, dead } = await filterReachableCloudflareTunnels(discovered);
|
|
1436
|
-
if (!live || (!live.ai && !live.ui)) {
|
|
1437
|
-
if (Object.keys(dead).length) {
|
|
1438
|
-
purgeUnreachableCloudflare(ws, cfg, {
|
|
1439
|
-
deadUrls: Object.values(dead),
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
await progress(
|
|
1443
|
-
"Those Cloudflare URLs did not respond — will create fresh tunnels."
|
|
1444
|
-
);
|
|
1445
|
-
return null;
|
|
1477
|
+
async function portIsLive(port, timeoutMs = 1200) {
|
|
1478
|
+
const n = Number(port);
|
|
1479
|
+
if (!n) return false;
|
|
1480
|
+
if (
|
|
1481
|
+
await probeHttpPaths(
|
|
1482
|
+
n,
|
|
1483
|
+
["/", "/embed-config.js", "/health", "/index.html"],
|
|
1484
|
+
timeoutMs
|
|
1485
|
+
)
|
|
1486
|
+
) {
|
|
1487
|
+
return true;
|
|
1446
1488
|
}
|
|
1489
|
+
return probePortOpen(n, Math.min(timeoutMs, 800));
|
|
1490
|
+
}
|
|
1447
1491
|
|
|
1448
|
-
|
|
1449
|
-
// Make sure the chat script advertises the public AI URL when we have one.
|
|
1450
|
-
if (live.ai) {
|
|
1451
|
-
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
1452
|
-
const up = await probeUrl(
|
|
1453
|
-
`http://127.0.0.1:${Number(ws.port) || 3100}/embed-config.js`
|
|
1454
|
-
);
|
|
1455
|
-
if (up) {
|
|
1456
|
-
launchedAt.delete(`${ws.sandboxId}:${path.resolve(ws.folderPath)}:ai`);
|
|
1457
|
-
await killPort(ws.port);
|
|
1458
|
-
await sleep(1200);
|
|
1459
|
-
}
|
|
1460
|
-
await startAiServerForWorkspace(ws, {
|
|
1461
|
-
reserved,
|
|
1462
|
-
cfg,
|
|
1463
|
-
port: ws.port,
|
|
1464
|
-
env: uiPublicEnv(live),
|
|
1465
|
-
});
|
|
1466
|
-
await waitUntilReachable(
|
|
1467
|
-
`http://127.0.0.1:${ws.port}/embed-config.js`,
|
|
1468
|
-
45_000,
|
|
1469
|
-
"the chat script",
|
|
1470
|
-
progress
|
|
1471
|
-
);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
const validation = await validateCloudflareGoLive(ws, live, {
|
|
1475
|
-
onProgress: progress,
|
|
1476
|
-
});
|
|
1477
|
-
if (!validation.ok) {
|
|
1478
|
-
await progress(
|
|
1479
|
-
"Existing tunnels failed go-live checks — will recreate if needed."
|
|
1480
|
-
);
|
|
1481
|
-
return null;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const appUrl = live.ui || live.ai;
|
|
1485
|
-
ws.cloudflareUrl = appUrl;
|
|
1486
|
-
ws.cloudflare = live;
|
|
1487
|
-
ws.appUrl = appUrl;
|
|
1488
|
-
ws.cloudflarePending = false;
|
|
1489
|
-
ws.appsRequested = true;
|
|
1490
|
-
persistWorkspaceEntry(cfg, ws);
|
|
1491
|
-
rememberCloudflareTunnels(ws.sandboxId, live);
|
|
1492
|
-
clearProcessProblem(ws.sandboxId, "cloudflare_launch", "tunnel");
|
|
1493
|
-
const host = workspaceHostReport(ws);
|
|
1494
|
-
await progress(`Attached to existing Cloudflare: ${host.appUrl || appUrl}`);
|
|
1495
|
-
log(`cloudflare attached (reuse) ${label}: ${appUrl}`);
|
|
1496
|
-
|
|
1497
|
-
return {
|
|
1498
|
-
sandboxId: ws.sandboxId,
|
|
1499
|
-
folderPath: ws.folderPath,
|
|
1500
|
-
port: ws.port,
|
|
1501
|
-
appUrl: host.appUrl || appUrl,
|
|
1502
|
-
origins: host.origins.length
|
|
1503
|
-
? host.origins
|
|
1504
|
-
: Object.values(live).filter(Boolean),
|
|
1505
|
-
tunnels: live,
|
|
1506
|
-
validation,
|
|
1507
|
-
cloudflare: true,
|
|
1508
|
-
reused: true,
|
|
1509
|
-
attached: true,
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
function clearStaleCloudflare(ws) {
|
|
1514
|
-
if (cloudflareLive(ws)) return;
|
|
1515
|
-
if (!ws.cloudflareUrl && !ws.cloudflare) return;
|
|
1516
|
-
if (discoverCloudflareTunnels(ws)) return;
|
|
1517
|
-
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1518
|
-
log(`clearing stale Cloudflare URL for ${label}`);
|
|
1519
|
-
ws.cloudflareUrl = null;
|
|
1520
|
-
ws.cloudflare = null;
|
|
1521
|
-
if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
|
|
1522
|
-
ws.appUrl = null;
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
async function probeHttpPaths(port, paths, timeoutMs = 2500) {
|
|
1492
|
+
async function isChatServerOnPort(port, timeoutMs = 2500) {
|
|
1527
1493
|
const n = Number(port);
|
|
1528
|
-
if (!n) return false;
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1494
|
+
if (!n || isAdminListenPort(n)) return false;
|
|
1495
|
+
return (
|
|
1496
|
+
(await probeEmbedConfig(
|
|
1497
|
+
`http://127.0.0.1:${n}/embed-config.js`,
|
|
1498
|
+
timeoutMs
|
|
1499
|
+
)) ||
|
|
1500
|
+
(await probeEmbedConfig(
|
|
1501
|
+
`http://localhost:${n}/embed-config.js`,
|
|
1502
|
+
timeoutMs
|
|
1503
|
+
))
|
|
1504
|
+
);
|
|
1535
1505
|
}
|
|
1536
1506
|
|
|
1537
1507
|
async function discoverChatPort(ws, timeoutMs = 2500) {
|
|
1508
|
+
const embedded = ws?.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
|
|
1509
|
+
if (embedded?.port) {
|
|
1510
|
+
return { port: embedded.port, up: true };
|
|
1511
|
+
}
|
|
1538
1512
|
const env = readProjectEnvValues(ws.folderPath);
|
|
1539
1513
|
const candidates = [
|
|
1540
1514
|
Number(ws.port),
|
|
@@ -1542,72 +1516,182 @@ async function discoverChatPort(ws, timeoutMs = 2500) {
|
|
|
1542
1516
|
portFromText(env.AI_SERVER_URL, 0),
|
|
1543
1517
|
portFromText(env.NEXT_PUBLIC_AI_SERVER_URL, 0),
|
|
1544
1518
|
3100,
|
|
1545
|
-
].filter((port) => port >= 1024);
|
|
1519
|
+
].filter((port) => port >= 1024 && !isAdminListenPort(port));
|
|
1546
1520
|
const unique = [...new Set(candidates)];
|
|
1547
|
-
const chatPaths = ["/embed-config.js", "/", "/health"];
|
|
1548
1521
|
for (const port of unique) {
|
|
1549
|
-
if (await
|
|
1522
|
+
if (await isChatServerOnPort(port, timeoutMs)) {
|
|
1550
1523
|
return { port, up: true };
|
|
1551
1524
|
}
|
|
1552
1525
|
}
|
|
1553
|
-
return { port: unique[0] ||
|
|
1526
|
+
return { port: unique[0] || 3100, up: false };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function hostAppOf(ws) {
|
|
1530
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
1531
|
+
return (
|
|
1532
|
+
apps.find((app) => app && app.host === true && app.role !== "ai-server") ||
|
|
1533
|
+
apps.find((app) => app && (app.role === "ui" || app.role === "app")) ||
|
|
1534
|
+
null
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function isBackendApp(app) {
|
|
1539
|
+
if (!app) return false;
|
|
1540
|
+
if (app.role === "backend") return true;
|
|
1541
|
+
if (app.role === "ai-server" || app.id === "ai-server") return false;
|
|
1542
|
+
if (app.host || app.role === "ui") return false;
|
|
1543
|
+
return /backend|api|server/i.test(`${app.id || ""} ${app.name || ""}`);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function usesBridgeProxy(app) {
|
|
1547
|
+
return Boolean(app && proxySlugForApp(app));
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function proxySlugForApp(app) {
|
|
1551
|
+
if (!app) return "";
|
|
1552
|
+
if (app.role === "ai-server" || app.id === "ai-server") return "ai";
|
|
1553
|
+
if (isBackendApp(app)) return "backend";
|
|
1554
|
+
if (app.host || app.role === "ui" || app.role === "app") return "ui";
|
|
1555
|
+
const id = String(app.id || "")
|
|
1556
|
+
.trim()
|
|
1557
|
+
.toLowerCase()
|
|
1558
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
1559
|
+
.replace(/^-+|-+$/g, "");
|
|
1560
|
+
return id || "app";
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function applyAssignedProxy(ws, remote, cfg) {
|
|
1564
|
+
const proxy =
|
|
1565
|
+
remote?.proxy && typeof remote.proxy === "object" ? remote.proxy : null;
|
|
1566
|
+
if (!proxy?.token) return;
|
|
1567
|
+
const origin = String(proxy.origin || cfg?.adminUrl || "").replace(/\/$/, "");
|
|
1568
|
+
const slugs =
|
|
1569
|
+
proxy.slugs && typeof proxy.slugs === "object" ? proxy.slugs : {};
|
|
1570
|
+
/** @type {Record<string, string>} */
|
|
1571
|
+
const urls = {};
|
|
1572
|
+
const apps = Array.isArray(ws.hostApps)
|
|
1573
|
+
? ws.hostApps
|
|
1574
|
+
: Array.isArray(remote.hostApps)
|
|
1575
|
+
? remote.hostApps
|
|
1576
|
+
: [];
|
|
1577
|
+
for (const app of apps) {
|
|
1578
|
+
const slug = slugs[app.id] || proxySlugForApp(app);
|
|
1579
|
+
if (!slug || !origin) continue;
|
|
1580
|
+
urls[app.id] = `${origin}/p/${proxy.token}/${slug}`;
|
|
1581
|
+
}
|
|
1582
|
+
ws.proxy = { origin, token: String(proxy.token), slugs, urls };
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function proxyUrlForApp(ws, app) {
|
|
1586
|
+
if (!usesBridgeProxy(app)) return "";
|
|
1587
|
+
const urls =
|
|
1588
|
+
ws?.proxy?.urls && typeof ws.proxy.urls === "object" ? ws.proxy.urls : {};
|
|
1589
|
+
if (typeof urls[app?.id] === "string" && urls[app.id]) {
|
|
1590
|
+
return String(urls[app.id]).replace(/\/$/, "");
|
|
1591
|
+
}
|
|
1592
|
+
const origin = String(ws?.proxy?.origin || "").replace(/\/$/, "");
|
|
1593
|
+
const token = String(ws?.proxy?.token || "");
|
|
1594
|
+
const slug =
|
|
1595
|
+
(ws?.proxy?.slugs && app?.id && ws.proxy.slugs[app.id]) ||
|
|
1596
|
+
proxySlugForApp(app);
|
|
1597
|
+
if (!origin || !token || !slug) return "";
|
|
1598
|
+
return `${origin}/p/${token}/${slug}`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function tunnelRoleForApp(app) {
|
|
1602
|
+
if (!app) return "app";
|
|
1603
|
+
if (app.role === "ai-server") return "ai";
|
|
1604
|
+
if (app.host === true || app.role === "ui" || app.role === "app") return "ui";
|
|
1605
|
+
if (isBackendApp(app)) return "backend";
|
|
1606
|
+
if (app.role === "custom") return "app";
|
|
1607
|
+
return app.role;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function jobsFromHostApps(ws) {
|
|
1611
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
1612
|
+
const hostId = hostAppOf(ws)?.id;
|
|
1613
|
+
return apps
|
|
1614
|
+
.filter((app) => app && app.role !== "ai-server")
|
|
1615
|
+
.map((app) => {
|
|
1616
|
+
const port = Number(app.port) || 3000;
|
|
1617
|
+
const command = String(app.startCommand || "").trim();
|
|
1618
|
+
const script = command.replace(/^npm\s+run\s+/, "") || "dev";
|
|
1619
|
+
return {
|
|
1620
|
+
role: isBackendApp(app)
|
|
1621
|
+
? "backend"
|
|
1622
|
+
: app.role === "custom"
|
|
1623
|
+
? "app"
|
|
1624
|
+
: app.role || "app",
|
|
1625
|
+
host: app.id === hostId || app.host === true,
|
|
1626
|
+
script,
|
|
1627
|
+
command: command || `npm run ${script}`,
|
|
1628
|
+
preferredPort: port,
|
|
1629
|
+
port,
|
|
1630
|
+
probeUrl: `http://127.0.0.1:${port}`,
|
|
1631
|
+
appId: app.id,
|
|
1632
|
+
};
|
|
1633
|
+
});
|
|
1554
1634
|
}
|
|
1555
1635
|
|
|
1556
1636
|
async function probeRunningApps(ws, timeoutMs = 2500) {
|
|
1557
1637
|
const folder = path.resolve(ws.folderPath || "");
|
|
1558
|
-
const env = readProjectEnvValues(ws.folderPath);
|
|
1559
1638
|
const chat = await discoverChatPort(ws, timeoutMs);
|
|
1560
1639
|
if (chat.up && Number(ws.port) !== chat.port) {
|
|
1561
1640
|
ws.port = chat.port;
|
|
1562
1641
|
}
|
|
1563
|
-
const jobs =
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1642
|
+
const jobs = jobsFromHostApps(ws).length
|
|
1643
|
+
? jobsFromHostApps(ws)
|
|
1644
|
+
: planHostJobs(
|
|
1645
|
+
folder,
|
|
1646
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
1647
|
+
ws.projectInfo
|
|
1648
|
+
);
|
|
1649
|
+
/** @type {Array<{ role: string, port: number, up: boolean, appId?: string }>} */
|
|
1569
1650
|
const hosts = [];
|
|
1570
1651
|
for (const job of jobs) {
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
let up = false;
|
|
1580
|
-
let port = unique[0];
|
|
1581
|
-
for (const candidate of unique) {
|
|
1582
|
-
const probe = (
|
|
1583
|
-
job.probeUrl || `http://127.0.0.1:${candidate}`
|
|
1584
|
-
).replace("localhost", "127.0.0.1");
|
|
1585
|
-
const ok =
|
|
1586
|
-
(await probeUrl(probe, timeoutMs)) ||
|
|
1587
|
-
(await probeHttpPaths(candidate, ["/"], timeoutMs));
|
|
1588
|
-
if (ok) {
|
|
1589
|
-
up = true;
|
|
1590
|
-
port = candidate;
|
|
1591
|
-
break;
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
hosts.push({ role: job.role, port, up });
|
|
1652
|
+
const port = Number(job.port || job.preferredPort) || 0;
|
|
1653
|
+
const up = port ? await portIsLive(port, timeoutMs) : false;
|
|
1654
|
+
hosts.push({
|
|
1655
|
+
role: job.role,
|
|
1656
|
+
port,
|
|
1657
|
+
up,
|
|
1658
|
+
appId: job.appId || job.role,
|
|
1659
|
+
});
|
|
1595
1660
|
}
|
|
1661
|
+
const listed = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
1662
|
+
const hostApps = await Promise.all(
|
|
1663
|
+
listed.map(async (app) => {
|
|
1664
|
+
const port = Number(app.port) || 0;
|
|
1665
|
+
const up =
|
|
1666
|
+
(app.role === "ai-server" &&
|
|
1667
|
+
chat.up &&
|
|
1668
|
+
(!port || Number(chat.port) === port)) ||
|
|
1669
|
+
(port ? await portIsLive(port, timeoutMs) : false);
|
|
1670
|
+
return {
|
|
1671
|
+
...app,
|
|
1672
|
+
port: port || app.port,
|
|
1673
|
+
running: Boolean(up),
|
|
1674
|
+
cloudflareUrl: null,
|
|
1675
|
+
publicUrl: proxyUrlForApp(ws, app) || null,
|
|
1676
|
+
lastCheckedAt: new Date().toISOString(),
|
|
1677
|
+
};
|
|
1678
|
+
})
|
|
1679
|
+
);
|
|
1596
1680
|
return {
|
|
1597
1681
|
chatUp: chat.up,
|
|
1598
1682
|
chatPort: chat.port,
|
|
1599
1683
|
hosts,
|
|
1600
|
-
|
|
1684
|
+
hostApps,
|
|
1685
|
+
running: chat.up || hosts.some((host) => host.up) || hostApps.some((app) => app.running),
|
|
1601
1686
|
};
|
|
1602
1687
|
}
|
|
1603
1688
|
|
|
1604
1689
|
async function restoreHostsAfterReconnect(cfg) {
|
|
1605
1690
|
for (const ws of cfg.workspaces || []) {
|
|
1606
1691
|
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1607
|
-
// Status-only: probe apps
|
|
1608
|
-
// Never start apps
|
|
1692
|
+
// Status-only: probe apps, drop leftover tunnels, report origins.
|
|
1693
|
+
// Never start apps on reconnect. Never write the app's .env.
|
|
1609
1694
|
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
1610
|
-
writeEnv: true,
|
|
1611
1695
|
timeoutMs: 2500,
|
|
1612
1696
|
});
|
|
1613
1697
|
const hostSummary = status.probe.hosts
|
|
@@ -1617,17 +1701,15 @@ async function restoreHostsAfterReconnect(cfg) {
|
|
|
1617
1701
|
log(
|
|
1618
1702
|
`found running apps for ${label}: chat=${status.probe.chatPort}${
|
|
1619
1703
|
status.probe.chatUp ? "(up)" : "(down)"
|
|
1620
|
-
}${hostSummary ? ` ${hostSummary}` : ""}
|
|
1621
|
-
status.
|
|
1622
|
-
}
|
|
1704
|
+
}${hostSummary ? ` ${hostSummary}` : ""} share=${
|
|
1705
|
+
status.host.appUrl || "(none)"
|
|
1706
|
+
} origins=${
|
|
1623
1707
|
status.host.origins.join(",") || "none"
|
|
1624
1708
|
}`
|
|
1625
1709
|
);
|
|
1626
1710
|
} else {
|
|
1627
1711
|
log(
|
|
1628
|
-
`no apps running for ${label}
|
|
1629
|
-
status.usingCloudflare ? " (cloudflare urls present)" : ""
|
|
1630
|
-
} — waiting for Start Apps from Maintainer Pro`
|
|
1712
|
+
`no apps running for ${label} — waiting for Start Apps from Maintainer Pro`
|
|
1631
1713
|
);
|
|
1632
1714
|
}
|
|
1633
1715
|
}
|
|
@@ -1635,11 +1717,10 @@ async function restoreHostsAfterReconnect(cfg) {
|
|
|
1635
1717
|
|
|
1636
1718
|
function envForWorkspacePorts(ws, jobs) {
|
|
1637
1719
|
const aiPort = Number(ws.port) || 3100;
|
|
1638
|
-
const
|
|
1639
|
-
(
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
"";
|
|
1720
|
+
const aiApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
|
|
1721
|
+
(app) => app.role === "ai-server" || app.id === "ai-server"
|
|
1722
|
+
) || { id: "ai-server", role: "ai-server" };
|
|
1723
|
+
const publicAi = proxyUrlForApp(ws, aiApp) || "";
|
|
1643
1724
|
const ai = publicAi || `http://localhost:${aiPort}`;
|
|
1644
1725
|
/** @type {Record<string, string>} */
|
|
1645
1726
|
const env = {
|
|
@@ -1648,13 +1729,19 @@ function envForWorkspacePorts(ws, jobs) {
|
|
|
1648
1729
|
NEXT_PUBLIC_AI_SERVER_URL: ai,
|
|
1649
1730
|
VITE_AI_SERVER_URL: ai,
|
|
1650
1731
|
REACT_APP_AI_SERVER_URL: ai,
|
|
1732
|
+
MAINTAINER_PRO_DATA_DIR: dataDirFor(ws.folderPath, ws.sandboxId),
|
|
1733
|
+
MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
|
|
1651
1734
|
};
|
|
1652
|
-
const ui =
|
|
1653
|
-
|
|
1654
|
-
(
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1735
|
+
const ui =
|
|
1736
|
+
jobs.find((job) => job.host) ||
|
|
1737
|
+
jobs.find((job) => job.role === "ui" || job.role === "app");
|
|
1738
|
+
const uiApp =
|
|
1739
|
+
hostAppOf(ws) ||
|
|
1740
|
+
(Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
|
|
1741
|
+
(app) => app && (app.host || app.role === "ui" || app.role === "app")
|
|
1742
|
+
) ||
|
|
1743
|
+
{ id: "ui", role: "ui", host: true };
|
|
1744
|
+
const publicUi = proxyUrlForApp(ws, uiApp) || "";
|
|
1658
1745
|
if (publicUi) {
|
|
1659
1746
|
env.PORT = ui?.port ? String(ui.port) : env.PORT;
|
|
1660
1747
|
env.APP_URL = publicUi;
|
|
@@ -1672,16 +1759,20 @@ function envForWorkspacePorts(ws, jobs) {
|
|
|
1672
1759
|
}
|
|
1673
1760
|
const backend = jobs.find((job) => job.role === "backend");
|
|
1674
1761
|
if (backend?.port) {
|
|
1675
|
-
const
|
|
1676
|
-
(
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
"";
|
|
1762
|
+
const backendApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
|
|
1763
|
+
(app) => isBackendApp(app)
|
|
1764
|
+
) || { id: "backend", role: "backend", port: backend.port };
|
|
1765
|
+
const publicBackend = proxyUrlForApp(ws, backendApp) || "";
|
|
1680
1766
|
const api = publicBackend || `http://localhost:${backend.port}`;
|
|
1681
1767
|
env.API_URL = api;
|
|
1768
|
+
env.API_BASE_URL = api;
|
|
1682
1769
|
env.API_PORT = String(backend.port);
|
|
1770
|
+
env.BACKEND_URL = api;
|
|
1683
1771
|
env.VITE_API_URL = api;
|
|
1772
|
+
env.VITE_API_BASE_URL = api;
|
|
1684
1773
|
env.NEXT_PUBLIC_API_URL = api;
|
|
1774
|
+
env.NEXT_PUBLIC_API_BASE_URL = api;
|
|
1775
|
+
env.REACT_APP_API_URL = api;
|
|
1685
1776
|
}
|
|
1686
1777
|
return env;
|
|
1687
1778
|
}
|
|
@@ -1695,10 +1786,12 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
|
|
|
1695
1786
|
return { aiPort, jobs: [], env: {} };
|
|
1696
1787
|
}
|
|
1697
1788
|
log(`ports pick ${label} in ${folder}`);
|
|
1698
|
-
const
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
);
|
|
1789
|
+
const listedAi = Array.isArray(ws.hostApps)
|
|
1790
|
+
? ws.hostApps.find((app) => app.role === "ai-server" || app.id === "ai-server")
|
|
1791
|
+
: null;
|
|
1792
|
+
const preferredRaw = Number(listedAi?.port || ws.port) || 3100;
|
|
1793
|
+
const preferredAi = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
|
|
1794
|
+
const aiUp = await isChatServerOnPort(preferredAi);
|
|
1702
1795
|
const aiPort = aiUp
|
|
1703
1796
|
? (reserved.add(preferredAi), preferredAi)
|
|
1704
1797
|
: await findFreePort(preferredAi, reserved);
|
|
@@ -1711,11 +1804,13 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
|
|
|
1711
1804
|
);
|
|
1712
1805
|
ws.port = aiPort;
|
|
1713
1806
|
|
|
1714
|
-
const jobs =
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1807
|
+
const jobs = jobsFromHostApps(ws).length
|
|
1808
|
+
? jobsFromHostApps(ws)
|
|
1809
|
+
: planHostJobs(
|
|
1810
|
+
folder,
|
|
1811
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
1812
|
+
ws.projectInfo
|
|
1813
|
+
);
|
|
1719
1814
|
log(
|
|
1720
1815
|
jobs.length
|
|
1721
1816
|
? `ports jobs ${jobs.map((job) => `${job.role}:${job.script}:${job.preferredPort}`).join(" ")}`
|
|
@@ -1749,7 +1844,9 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
|
|
|
1749
1844
|
}
|
|
1750
1845
|
|
|
1751
1846
|
const env = envForWorkspacePorts(ws, planned);
|
|
1752
|
-
|
|
1847
|
+
log(
|
|
1848
|
+
`env inject ${label} (process only): ${Object.keys(env).join(" ") || "(none)"}`
|
|
1849
|
+
);
|
|
1753
1850
|
if (planned.length) {
|
|
1754
1851
|
ws.projectInfo = {
|
|
1755
1852
|
...(ws.projectInfo || {}),
|
|
@@ -1768,14 +1865,11 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
|
|
|
1768
1865
|
return { aiPort, jobs: planned, env };
|
|
1769
1866
|
}
|
|
1770
1867
|
|
|
1771
|
-
const ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
|
|
1772
|
-
const ACCESS_IGNORE_END = "# maintainer-pro:access-end";
|
|
1773
1868
|
const DEFAULT_AI_IGNORE_PATHS = [
|
|
1774
1869
|
".env",
|
|
1775
1870
|
".env.*",
|
|
1776
1871
|
"**/.env",
|
|
1777
1872
|
"**/.env.*",
|
|
1778
|
-
".maintainer-pro/",
|
|
1779
1873
|
];
|
|
1780
1874
|
|
|
1781
1875
|
function normalizeIgnorePaths(paths) {
|
|
@@ -1798,51 +1892,20 @@ function resolveIgnorePaths(partnerPaths) {
|
|
|
1798
1892
|
return normalizeIgnorePaths([...DEFAULT_AI_IGNORE_PATHS, ...(partnerPaths || [])]);
|
|
1799
1893
|
}
|
|
1800
1894
|
|
|
1801
|
-
function upsertManagedIgnoreFile(existing, ignorePaths) {
|
|
1802
|
-
const block = [
|
|
1803
|
-
ACCESS_IGNORE_BEGIN,
|
|
1804
|
-
"# Managed by Maintainer Pro — do not edit this block by hand.",
|
|
1805
|
-
...ignorePaths,
|
|
1806
|
-
ACCESS_IGNORE_END,
|
|
1807
|
-
"",
|
|
1808
|
-
].join("\n");
|
|
1809
|
-
const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
|
|
1810
|
-
const end = existing.indexOf(ACCESS_IGNORE_END);
|
|
1811
|
-
if (begin >= 0 && end > begin) {
|
|
1812
|
-
const afterEnd = end + ACCESS_IGNORE_END.length;
|
|
1813
|
-
const before = existing.slice(0, begin).replace(/\s+$/, "");
|
|
1814
|
-
const after = existing.slice(afterEnd).replace(/^\r?\n/, "");
|
|
1815
|
-
const parts = [before, block.trimEnd(), after.trimStart()].filter(Boolean);
|
|
1816
|
-
return `${parts.join("\n\n")}\n`;
|
|
1817
|
-
}
|
|
1818
|
-
const trimmed = existing.replace(/\s+$/, "");
|
|
1819
|
-
return trimmed ? `${trimmed}\n\n${block}` : block;
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
1895
|
/**
|
|
1823
|
-
*
|
|
1824
|
-
* @param {string} folder
|
|
1825
|
-
* @param {string[]} partnerIgnorePaths
|
|
1896
|
+
* Store partner ignore paths next to the bridge. Does not write into the host app.
|
|
1826
1897
|
*/
|
|
1827
|
-
function applyAccessPolicy(folder, partnerIgnorePaths) {
|
|
1898
|
+
function applyAccessPolicy(folder, partnerIgnorePaths, sandboxId) {
|
|
1828
1899
|
const resolved = path.resolve(folder);
|
|
1829
1900
|
fs.mkdirSync(resolved, { recursive: true });
|
|
1830
1901
|
const ignorePaths = resolveIgnorePaths(partnerIgnorePaths);
|
|
1831
|
-
const envPath = path.join(resolved, ".env");
|
|
1832
|
-
mergeEnvFile(envPath, {
|
|
1833
|
-
AI_CLI_WORKSPACE: ".",
|
|
1834
|
-
AI_CLI_IGNORE_PATHS: JSON.stringify(
|
|
1835
|
-
normalizeIgnorePaths(partnerIgnorePaths || [])
|
|
1836
|
-
),
|
|
1837
|
-
});
|
|
1838
1902
|
|
|
1839
|
-
const mpDir =
|
|
1840
|
-
fs.mkdirSync(mpDir, { recursive: true });
|
|
1903
|
+
const mpDir = dataDirFor(resolved, sandboxId);
|
|
1841
1904
|
fs.writeFileSync(
|
|
1842
1905
|
path.join(mpDir, "access.json"),
|
|
1843
1906
|
JSON.stringify(
|
|
1844
1907
|
{
|
|
1845
|
-
workspace:
|
|
1908
|
+
workspace: resolved,
|
|
1846
1909
|
ignorePaths,
|
|
1847
1910
|
partnerIgnorePaths: normalizeIgnorePaths(partnerIgnorePaths || []),
|
|
1848
1911
|
updatedAt: new Date().toISOString(),
|
|
@@ -1853,15 +1916,7 @@ function applyAccessPolicy(folder, partnerIgnorePaths) {
|
|
|
1853
1916
|
"utf8"
|
|
1854
1917
|
);
|
|
1855
1918
|
|
|
1856
|
-
|
|
1857
|
-
const file = path.join(resolved, name);
|
|
1858
|
-
const existing = fs.existsSync(file)
|
|
1859
|
-
? fs.readFileSync(file, "utf8")
|
|
1860
|
-
: "";
|
|
1861
|
-
fs.writeFileSync(file, upsertManagedIgnoreFile(existing, ignorePaths), "utf8");
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
return { ignorePaths };
|
|
1919
|
+
return { ignorePaths, dataDir: mpDir };
|
|
1865
1920
|
}
|
|
1866
1921
|
|
|
1867
1922
|
const IGNORE_NAMES = new Set([
|
|
@@ -1870,6 +1925,7 @@ const IGNORE_NAMES = new Set([
|
|
|
1870
1925
|
"Thumbs.db",
|
|
1871
1926
|
"node_modules",
|
|
1872
1927
|
".maintainer-pro",
|
|
1928
|
+
".collaborater",
|
|
1873
1929
|
".maintainer-pro-bridge.json",
|
|
1874
1930
|
".cloudflare-tunnel-url",
|
|
1875
1931
|
]);
|
|
@@ -1968,161 +2024,9 @@ function emptyProjectIndexHtml(appName) {
|
|
|
1968
2024
|
`;
|
|
1969
2025
|
}
|
|
1970
2026
|
|
|
1971
|
-
const WIDGET_MARKER = "AiUi.init";
|
|
1972
|
-
|
|
1973
|
-
function injectHtmlWidget(html, aiServerUrl) {
|
|
1974
|
-
if (html.includes(WIDGET_MARKER) || html.includes("ai-ui.iife.js")) {
|
|
1975
|
-
return { html, injected: false };
|
|
1976
|
-
}
|
|
1977
|
-
const snippet = `
|
|
1978
|
-
<script src="${aiServerUrl}/embed-config.js"></script>
|
|
1979
|
-
<script src="${aiServerUrl}/ai-ui.iife.js"></script>
|
|
1980
|
-
<script>
|
|
1981
|
-
(function () {
|
|
1982
|
-
var cfg = window.__MAINTAINER_PRO__ || {};
|
|
1983
|
-
if (!cfg.apiUrl) {
|
|
1984
|
-
console.error("Maintainer Pro: embed-config.js missing apiUrl (is ai-server running?)");
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
AiUi.init({
|
|
1988
|
-
apiUrl: cfg.apiUrl,
|
|
1989
|
-
title: "AI Assistant",
|
|
1990
|
-
maintainerProUrl: cfg.maintainerProUrl || undefined,
|
|
1991
|
-
maintainerProApiKey: cfg.maintainerProApiKey || undefined,
|
|
1992
|
-
});
|
|
1993
|
-
})();
|
|
1994
|
-
</script>
|
|
1995
|
-
`;
|
|
1996
|
-
if (/<\/body>/i.test(html)) {
|
|
1997
|
-
return {
|
|
1998
|
-
html: html.replace(/<\/body>/i, `${snippet}</body>`),
|
|
1999
|
-
injected: true,
|
|
2000
|
-
};
|
|
2001
|
-
}
|
|
2002
|
-
return { html: html + snippet, injected: true };
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
function nextWidgetComponentSource() {
|
|
2006
|
-
return `"use client";
|
|
2007
|
-
|
|
2008
|
-
import { useEffect } from "react";
|
|
2009
|
-
|
|
2010
|
-
declare global {
|
|
2011
|
-
interface Window {
|
|
2012
|
-
AiUi?: { init: (opts: Record<string, unknown>) => void; destroy?: () => void };
|
|
2013
|
-
__MAINTAINER_PRO__?: {
|
|
2014
|
-
aiServerUrl?: string;
|
|
2015
|
-
apiUrl?: string;
|
|
2016
|
-
maintainerProUrl?: string;
|
|
2017
|
-
maintainerProApiKey?: string;
|
|
2018
|
-
};
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
const AI_SERVER_URL = (process.env.NEXT_PUBLIC_AI_SERVER_URL || "").replace(/\\/$/, "");
|
|
2023
|
-
|
|
2024
|
-
export function MaintainerProWidget() {
|
|
2025
|
-
useEffect(() => {
|
|
2026
|
-
if (!AI_SERVER_URL) {
|
|
2027
|
-
console.error("Set NEXT_PUBLIC_AI_SERVER_URL to the ai-server origin");
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
let cancelled = false;
|
|
2031
|
-
|
|
2032
|
-
const start = (cfg: {
|
|
2033
|
-
apiUrl?: string;
|
|
2034
|
-
maintainerProUrl?: string;
|
|
2035
|
-
maintainerProApiKey?: string;
|
|
2036
|
-
}) => {
|
|
2037
|
-
if (cancelled) return;
|
|
2038
|
-
const AiUi = window.AiUi;
|
|
2039
|
-
if (!AiUi?.init) {
|
|
2040
|
-
setTimeout(() => start(cfg), 40);
|
|
2041
|
-
return;
|
|
2042
|
-
}
|
|
2043
|
-
AiUi.init({
|
|
2044
|
-
apiUrl: cfg.apiUrl || \`\${AI_SERVER_URL}/api/chat\`,
|
|
2045
|
-
title: "AI Assistant",
|
|
2046
|
-
maintainerProUrl: cfg.maintainerProUrl,
|
|
2047
|
-
maintainerProApiKey: cfg.maintainerProApiKey,
|
|
2048
|
-
});
|
|
2049
|
-
};
|
|
2050
|
-
|
|
2051
|
-
const ensureScript = (src: string) =>
|
|
2052
|
-
new Promise<void>((resolve, reject) => {
|
|
2053
|
-
const existing = document.querySelector<HTMLScriptElement>(\`script[src="\${src}"]\`);
|
|
2054
|
-
if (existing) {
|
|
2055
|
-
if (existing.dataset.loaded === "1") resolve();
|
|
2056
|
-
else existing.addEventListener("load", () => resolve(), { once: true });
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
const script = document.createElement("script");
|
|
2060
|
-
script.src = src;
|
|
2061
|
-
script.async = true;
|
|
2062
|
-
script.onload = () => {
|
|
2063
|
-
script.dataset.loaded = "1";
|
|
2064
|
-
resolve();
|
|
2065
|
-
};
|
|
2066
|
-
script.onerror = () => reject(new Error(\`Failed to load \${src}\`));
|
|
2067
|
-
document.body.appendChild(script);
|
|
2068
|
-
});
|
|
2069
|
-
|
|
2070
|
-
void (async () => {
|
|
2071
|
-
try {
|
|
2072
|
-
await ensureScript(\`\${AI_SERVER_URL}/embed-config.js\`);
|
|
2073
|
-
await ensureScript(\`\${AI_SERVER_URL}/ai-ui.iife.js\`);
|
|
2074
|
-
start(window.__MAINTAINER_PRO__ || {});
|
|
2075
|
-
} catch (err) {
|
|
2076
|
-
console.error(err);
|
|
2077
|
-
}
|
|
2078
|
-
})();
|
|
2079
|
-
|
|
2080
|
-
return () => {
|
|
2081
|
-
cancelled = true;
|
|
2082
|
-
window.AiUi?.destroy?.();
|
|
2083
|
-
};
|
|
2084
|
-
}, []);
|
|
2085
|
-
|
|
2086
|
-
return null;
|
|
2087
|
-
}
|
|
2088
|
-
`;
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
function tryMountNextWidget(dir) {
|
|
2092
|
-
const candidates = [
|
|
2093
|
-
path.join(dir, "app", "layout.tsx"),
|
|
2094
|
-
path.join(dir, "app", "layout.jsx"),
|
|
2095
|
-
path.join(dir, "src", "app", "layout.tsx"),
|
|
2096
|
-
path.join(dir, "src", "app", "layout.jsx"),
|
|
2097
|
-
];
|
|
2098
|
-
for (const layout of candidates) {
|
|
2099
|
-
if (!fs.existsSync(layout)) continue;
|
|
2100
|
-
let text = fs.readFileSync(layout, "utf8");
|
|
2101
|
-
if (text.includes("MaintainerProWidget")) {
|
|
2102
|
-
return { mounted: false, reason: "already mounted" };
|
|
2103
|
-
}
|
|
2104
|
-
const fromAppRoot =
|
|
2105
|
-
/[/\\]app[/\\]layout\.(t|j)sx$/.test(layout) &&
|
|
2106
|
-
!/[/\\]src[/\\]app[/\\]/.test(layout);
|
|
2107
|
-
const imp = fromAppRoot
|
|
2108
|
-
? "../components/MaintainerProWidget"
|
|
2109
|
-
: "@/components/MaintainerProWidget";
|
|
2110
|
-
|
|
2111
|
-
text = `import { MaintainerProWidget } from "${imp}";\n` + text;
|
|
2112
|
-
if (/\{children\}/.test(text)) {
|
|
2113
|
-
text = text.replace(
|
|
2114
|
-
/\{children\}/,
|
|
2115
|
-
"{children}\n <MaintainerProWidget />"
|
|
2116
|
-
);
|
|
2117
|
-
fs.writeFileSync(layout, text, "utf8");
|
|
2118
|
-
return { mounted: true, layout };
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
return { mounted: false, reason: "no layout found" };
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
2027
|
/**
|
|
2125
|
-
*
|
|
2028
|
+
* Detect project kind only. Existing host files are never rewritten —
|
|
2029
|
+
* the share proxy injects chat, and Start Apps injects process env.
|
|
2126
2030
|
*/
|
|
2127
2031
|
function configureClient(opts) {
|
|
2128
2032
|
const {
|
|
@@ -2159,7 +2063,7 @@ function configureClient(opts) {
|
|
|
2159
2063
|
if (!fs.existsSync(indexPath)) {
|
|
2160
2064
|
fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
|
|
2161
2065
|
filesWritten.push("index.html");
|
|
2162
|
-
notes.push("Created index.html (served
|
|
2066
|
+
notes.push("Created index.html (chat served in-process).");
|
|
2163
2067
|
} else {
|
|
2164
2068
|
notes.push("index.html already present.");
|
|
2165
2069
|
}
|
|
@@ -2174,27 +2078,13 @@ function configureClient(opts) {
|
|
|
2174
2078
|
}
|
|
2175
2079
|
|
|
2176
2080
|
if (kind === "html") {
|
|
2177
|
-
const candidates = [
|
|
2178
|
-
path.join(dir, "index.html"),
|
|
2179
|
-
path.join(dir, "public", "index.html"),
|
|
2180
|
-
];
|
|
2181
|
-
for (const htmlPath of candidates) {
|
|
2182
|
-
if (!fs.existsSync(htmlPath)) continue;
|
|
2183
|
-
const raw = fs.readFileSync(htmlPath, "utf8");
|
|
2184
|
-
const { html, injected } = injectHtmlWidget(raw, aiOrigin);
|
|
2185
|
-
if (injected) {
|
|
2186
|
-
fs.writeFileSync(htmlPath, html, "utf8");
|
|
2187
|
-
filesWritten.push(path.relative(dir, htmlPath));
|
|
2188
|
-
notes.push(`Injected widget into ${path.relative(dir, htmlPath)}.`);
|
|
2189
|
-
} else {
|
|
2190
|
-
notes.push(`Widget already present in ${path.relative(dir, htmlPath)}.`);
|
|
2191
|
-
}
|
|
2192
|
-
break;
|
|
2193
|
-
}
|
|
2194
2081
|
const origin = hostAppUrl || "http://localhost:3000";
|
|
2195
2082
|
return {
|
|
2196
2083
|
kind: "html",
|
|
2197
|
-
notes
|
|
2084
|
+
notes: [
|
|
2085
|
+
...notes,
|
|
2086
|
+
"Existing HTML app — chat is injected on the share URL, the host files are not changed.",
|
|
2087
|
+
],
|
|
2198
2088
|
filesWritten,
|
|
2199
2089
|
corsOrigin: origin,
|
|
2200
2090
|
appUrl: origin,
|
|
@@ -2203,36 +2093,12 @@ function configureClient(opts) {
|
|
|
2203
2093
|
}
|
|
2204
2094
|
|
|
2205
2095
|
if (kind === "next") {
|
|
2206
|
-
const envLocal = path.join(dir, ".env.local");
|
|
2207
|
-
mergeEnvFile(envLocal, {
|
|
2208
|
-
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
2209
|
-
});
|
|
2210
|
-
filesWritten.push(".env.local");
|
|
2211
|
-
notes.push("Set NEXT_PUBLIC_AI_SERVER_URL in .env.local.");
|
|
2212
|
-
|
|
2213
|
-
const useSrc = fs.existsSync(path.join(dir, "src", "app"));
|
|
2214
|
-
const compDir = useSrc
|
|
2215
|
-
? path.join(dir, "src", "components")
|
|
2216
|
-
: path.join(dir, "components");
|
|
2217
|
-
fs.mkdirSync(compDir, { recursive: true });
|
|
2218
|
-
const widgetPath = path.join(compDir, "MaintainerProWidget.tsx");
|
|
2219
|
-
if (!fs.existsSync(widgetPath)) {
|
|
2220
|
-
fs.writeFileSync(widgetPath, nextWidgetComponentSource(), "utf8");
|
|
2221
|
-
filesWritten.push(path.relative(dir, widgetPath));
|
|
2222
|
-
notes.push("Added MaintainerProWidget.tsx.");
|
|
2223
|
-
}
|
|
2224
|
-
const mount = tryMountNextWidget(dir);
|
|
2225
|
-
if (mount.mounted) {
|
|
2226
|
-
notes.push(`Mounted widget in ${path.relative(dir, mount.layout)}.`);
|
|
2227
|
-
} else {
|
|
2228
|
-
notes.push(
|
|
2229
|
-
"Add <MaintainerProWidget /> to your root layout if it is not mounted yet."
|
|
2230
|
-
);
|
|
2231
|
-
}
|
|
2232
2096
|
const origin = hostAppUrl || "http://localhost:3000";
|
|
2233
2097
|
return {
|
|
2234
2098
|
kind: "next",
|
|
2235
|
-
notes
|
|
2099
|
+
notes: [
|
|
2100
|
+
"Existing Next.js app — chat is injected on the share URL, the host files are not changed.",
|
|
2101
|
+
],
|
|
2236
2102
|
filesWritten,
|
|
2237
2103
|
corsOrigin: origin,
|
|
2238
2104
|
appUrl: origin,
|
|
@@ -2240,14 +2106,10 @@ function configureClient(opts) {
|
|
|
2240
2106
|
};
|
|
2241
2107
|
}
|
|
2242
2108
|
|
|
2243
|
-
// other —
|
|
2109
|
+
// other — leave the host app untouched
|
|
2244
2110
|
const origin = hostAppUrl || "http://localhost:3000";
|
|
2245
|
-
mergeEnvFile(path.join(dir, ".env"), {
|
|
2246
|
-
AI_SERVER_URL: aiOrigin,
|
|
2247
|
-
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
2248
|
-
});
|
|
2249
2111
|
notes.push(
|
|
2250
|
-
"Existing project detected.
|
|
2112
|
+
"Existing project detected. The bridge injects chat/share env when it starts the app."
|
|
2251
2113
|
);
|
|
2252
2114
|
return {
|
|
2253
2115
|
kind: "other",
|
|
@@ -2259,104 +2121,629 @@ function configureClient(opts) {
|
|
|
2259
2121
|
};
|
|
2260
2122
|
}
|
|
2261
2123
|
|
|
2262
|
-
function collectOfferedFolders(
|
|
2263
|
-
|
|
2264
|
-
const folders = [];
|
|
2265
|
-
const add = (p) => {
|
|
2266
|
-
if (!p) return;
|
|
2267
|
-
const resolved = path.resolve(p);
|
|
2268
|
-
if (!folders.includes(resolved)) folders.push(resolved);
|
|
2269
|
-
};
|
|
2270
|
-
add(process.cwd());
|
|
2271
|
-
for (const f of cfg.offeredFolders || []) add(f);
|
|
2272
|
-
for (const w of cfg.workspaces || []) add(w.folderPath);
|
|
2273
|
-
return folders;
|
|
2124
|
+
function collectOfferedFolders() {
|
|
2125
|
+
return [browseRootFromCwd()];
|
|
2274
2126
|
}
|
|
2275
2127
|
|
|
2276
|
-
/**
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2128
|
+
/**
|
|
2129
|
+
* Folder picker root — not `/` or a drive letter.
|
|
2130
|
+
* Windows: `D:\Projects\app` → `D:\Projects`.
|
|
2131
|
+
* macOS/Linux: `~/projects/app` → `~/projects`.
|
|
2132
|
+
*/
|
|
2133
|
+
function browseRootFromCwd() {
|
|
2134
|
+
const cwd = path.resolve(process.cwd());
|
|
2135
|
+
if (process.platform === "win32") {
|
|
2136
|
+
const drive = path.parse(cwd).root;
|
|
2137
|
+
const first = cwd.slice(drive.length).split(/[\\/]/).filter(Boolean)[0];
|
|
2138
|
+
if (first) return path.resolve(path.join(drive, first));
|
|
2139
|
+
return cwd;
|
|
2140
|
+
}
|
|
2141
|
+
const home = path.resolve(os.homedir());
|
|
2142
|
+
if (pathInside(cwd, home)) {
|
|
2143
|
+
const first = cwd.slice(home.length).split("/").filter(Boolean)[0];
|
|
2144
|
+
if (first) return path.join(home, first);
|
|
2145
|
+
return home;
|
|
2146
|
+
}
|
|
2147
|
+
const bits = cwd.split("/").filter(Boolean);
|
|
2148
|
+
if (bits[0] === "Volumes" && bits[1]) {
|
|
2149
|
+
return path.join("/", bits[0], bits[1]);
|
|
2150
|
+
}
|
|
2151
|
+
if (bits[0]) return path.join("/", bits[0]);
|
|
2152
|
+
return cwd;
|
|
2153
|
+
}
|
|
2280
2154
|
|
|
2281
|
-
|
|
2282
|
-
|
|
2155
|
+
function normalizeFsPath(p) {
|
|
2156
|
+
let s = path.resolve(String(p || "").trim());
|
|
2157
|
+
if (process.platform === "win32") {
|
|
2158
|
+
s = s.replace(/\//g, "\\");
|
|
2159
|
+
if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
|
|
2160
|
+
else s = s.replace(/\\+$/, "");
|
|
2161
|
+
if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
|
|
2162
|
+
return s.toLowerCase();
|
|
2163
|
+
}
|
|
2164
|
+
if (s !== "/") s = s.replace(/\/+$/, "");
|
|
2165
|
+
return s;
|
|
2166
|
+
}
|
|
2283
2167
|
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2168
|
+
function pathInside(inner, outer) {
|
|
2169
|
+
const a = normalizeFsPath(inner);
|
|
2170
|
+
const b = normalizeFsPath(outer);
|
|
2171
|
+
if (!a || !b) return false;
|
|
2172
|
+
if (a === b) return true;
|
|
2173
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
2174
|
+
const prefix = b.endsWith(sep) ? b : `${b}${sep}`;
|
|
2175
|
+
return a.startsWith(prefix);
|
|
2176
|
+
}
|
|
2286
2177
|
|
|
2287
|
-
function
|
|
2288
|
-
const
|
|
2289
|
-
|
|
2290
|
-
if (!id || !name) return;
|
|
2291
|
-
let titles = openedTerminalTitles.get(id);
|
|
2292
|
-
if (!titles) {
|
|
2293
|
-
titles = new Set();
|
|
2294
|
-
openedTerminalTitles.set(id, titles);
|
|
2295
|
-
}
|
|
2296
|
-
titles.add(name);
|
|
2178
|
+
function isLaunchRoot(dirPath, allowed) {
|
|
2179
|
+
const resolved = path.resolve(dirPath);
|
|
2180
|
+
return allowed.some((root) => normalizeFsPath(root) === normalizeFsPath(resolved));
|
|
2297
2181
|
}
|
|
2298
2182
|
|
|
2299
|
-
function
|
|
2300
|
-
const
|
|
2301
|
-
if (
|
|
2302
|
-
return
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
setTimeout(done, 4000);
|
|
2313
|
-
return;
|
|
2314
|
-
}
|
|
2315
|
-
if (process.platform === "darwin") {
|
|
2316
|
-
const child = spawn(
|
|
2317
|
-
"osascript",
|
|
2318
|
-
[
|
|
2319
|
-
"-e",
|
|
2320
|
-
`tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
|
|
2321
|
-
],
|
|
2322
|
-
{ stdio: "ignore" }
|
|
2323
|
-
);
|
|
2324
|
-
child.on("exit", done);
|
|
2325
|
-
child.on("error", done);
|
|
2326
|
-
setTimeout(done, 4000);
|
|
2327
|
-
return;
|
|
2328
|
-
}
|
|
2329
|
-
done();
|
|
2330
|
-
});
|
|
2183
|
+
function listLaunchRoots(allowed) {
|
|
2184
|
+
const home = os.homedir();
|
|
2185
|
+
if (allowed.length === 1) return listDirEntries(allowed[0], allowed);
|
|
2186
|
+
return {
|
|
2187
|
+
path: "",
|
|
2188
|
+
parent: null,
|
|
2189
|
+
home,
|
|
2190
|
+
entries: allowed.map((root) => ({
|
|
2191
|
+
name: root.split(/[/\\]/).filter(Boolean).slice(-1)[0] || root,
|
|
2192
|
+
path: root,
|
|
2193
|
+
isDir: true,
|
|
2194
|
+
})),
|
|
2195
|
+
};
|
|
2331
2196
|
}
|
|
2332
2197
|
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2198
|
+
/** Prevents opening a new window on every heartbeat while a process is starting. */
|
|
2199
|
+
const launchedAt = new Map();
|
|
2200
|
+
/** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
|
|
2201
|
+
const cloudflareTunnels = new Map();
|
|
2202
|
+
/** In-process chat servers, one per sandbox/project. */
|
|
2203
|
+
/** @type {Map<string, { port: number, workspaceDir: string, close: () => Promise<void>, runChat: (body: Record<string, unknown>) => Promise<{ ok: boolean, status: number, data: Record<string, unknown> }>, handleHttp?: Function }>} */
|
|
2204
|
+
const embeddedChat = new Map();
|
|
2205
|
+
/** @type {Map<string, Promise<object | null>>} */
|
|
2206
|
+
const embeddedChatStarting = new Map();
|
|
2207
|
+
|
|
2208
|
+
const PROXY_CHUNK_BYTES = 32 * 1024;
|
|
2209
|
+
/** @type {Map<string, import("node:http").ClientRequest>} */
|
|
2210
|
+
const proxyHttpReqs = new Map();
|
|
2211
|
+
/** @type {Map<string, WebSocket>} */
|
|
2212
|
+
const proxyLocalSockets = new Map();
|
|
2213
|
+
/** @type {Record<string, unknown> | null} */
|
|
2214
|
+
let bridgeCfg = null;
|
|
2215
|
+
|
|
2216
|
+
function safeProxyPath(path) {
|
|
2217
|
+
const raw = String(path || "/");
|
|
2218
|
+
if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
|
|
2219
|
+
return raw;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function workspaceForProxy(sandboxId) {
|
|
2223
|
+
return (
|
|
2224
|
+
(Array.isArray(bridgeCfg?.workspaces) ? bridgeCfg.workspaces : []).find(
|
|
2225
|
+
(row) => row.sandboxId === sandboxId
|
|
2226
|
+
) || null
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function localPortForProxy(ws, appId) {
|
|
2231
|
+
const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
|
|
2232
|
+
const app = apps.find((row) => row.id === appId);
|
|
2233
|
+
let port = 0;
|
|
2234
|
+
if (app && usesBridgeProxy(app) && Number(app.port)) port = Number(app.port);
|
|
2235
|
+
else if (appId === "ai-server" || app?.role === "ai-server") {
|
|
2236
|
+
port = Number(ws.port) || 3100;
|
|
2338
2237
|
}
|
|
2339
|
-
|
|
2238
|
+
if (
|
|
2239
|
+
(appId === "ai-server" || app?.role === "ai-server") &&
|
|
2240
|
+
isAdminListenPort(port)
|
|
2241
|
+
) {
|
|
2242
|
+
return 3100;
|
|
2243
|
+
}
|
|
2244
|
+
return port;
|
|
2340
2245
|
}
|
|
2341
2246
|
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2247
|
+
function proxyReqHeaders(incoming) {
|
|
2248
|
+
/** @type {Record<string, string>} */
|
|
2249
|
+
const headers = {};
|
|
2250
|
+
if (!incoming || typeof incoming !== "object") return headers;
|
|
2251
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
2252
|
+
const lower = String(key).toLowerCase();
|
|
2253
|
+
if (
|
|
2254
|
+
[
|
|
2255
|
+
"connection",
|
|
2256
|
+
"keep-alive",
|
|
2257
|
+
"transfer-encoding",
|
|
2258
|
+
"upgrade",
|
|
2259
|
+
"host",
|
|
2260
|
+
"content-length",
|
|
2261
|
+
"te",
|
|
2262
|
+
"trailer",
|
|
2263
|
+
].includes(lower)
|
|
2264
|
+
) {
|
|
2265
|
+
continue;
|
|
2346
2266
|
}
|
|
2267
|
+
if (typeof value === "string" && value) headers[key] = value;
|
|
2347
2268
|
}
|
|
2348
|
-
|
|
2349
|
-
if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
|
|
2350
|
-
}
|
|
2351
|
-
await closeRememberedTerminals(sandboxId);
|
|
2352
|
-
await stopCloudflare(sandboxId);
|
|
2269
|
+
return headers;
|
|
2353
2270
|
}
|
|
2354
2271
|
|
|
2355
|
-
function
|
|
2356
|
-
|
|
2272
|
+
function isAiServerAppId(appId, ws) {
|
|
2273
|
+
if (appId === "ai-server") return true;
|
|
2274
|
+
const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
|
|
2275
|
+
const app = apps.find((row) => row.id === appId);
|
|
2276
|
+
return Boolean(app && (app.role === "ai-server" || app.id === "ai-server"));
|
|
2357
2277
|
}
|
|
2358
2278
|
|
|
2359
|
-
function
|
|
2279
|
+
function replyProxyHttp(id, status, headers, body) {
|
|
2280
|
+
const stream = randomBytes(8).toString("hex");
|
|
2281
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body || "");
|
|
2282
|
+
/** @type {Record<string, string>} */
|
|
2283
|
+
const out = {};
|
|
2284
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
2285
|
+
if (value == null) continue;
|
|
2286
|
+
out[String(key)] = String(value);
|
|
2287
|
+
}
|
|
2288
|
+
bridgeSend({ type: "proxy.http.start", id, stream, status, headers: out });
|
|
2289
|
+
for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
|
|
2290
|
+
const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
|
|
2291
|
+
bridgeSend({
|
|
2292
|
+
type: "proxy.http.chunk",
|
|
2293
|
+
id,
|
|
2294
|
+
stream,
|
|
2295
|
+
data: buf.subarray(offset, end).toString("base64"),
|
|
2296
|
+
eof: false,
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function bridgeEmbedConfigJs(ws) {
|
|
2303
|
+
const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
|
|
2304
|
+
const aiApp = { id: "ai-server", role: "ai-server" };
|
|
2305
|
+
const ai = (
|
|
2306
|
+
proxyUrlForApp(ws, aiApp) ||
|
|
2307
|
+
`http://127.0.0.1:${Number(ws.port) || 3100}`
|
|
2308
|
+
).replace(/\/$/, "");
|
|
2309
|
+
const wsUrl = ai.replace(/^http/i, "ws");
|
|
2310
|
+
const payload = {
|
|
2311
|
+
aiServerUrl: ai,
|
|
2312
|
+
apiUrl: `${ai}/api/chat`,
|
|
2313
|
+
aiServerWsUrl: `${wsUrl}/api/ws`,
|
|
2314
|
+
debug: true,
|
|
2315
|
+
logLevel: "debug",
|
|
2316
|
+
maintainerProUrl: bridgeCfg?.adminUrl || "",
|
|
2317
|
+
maintainerProApiKey: String(store.clientKey || "").trim(),
|
|
2318
|
+
};
|
|
2319
|
+
return `window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
async function handleAiProxyHttpFromAdmin(msg, ws) {
|
|
2323
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2324
|
+
const method = String(msg.method || "GET").toUpperCase();
|
|
2325
|
+
const reqPath = safeProxyPath(msg.path);
|
|
2326
|
+
const pathname = reqPath.split("?")[0] || "/";
|
|
2327
|
+
const headers = proxyReqHeaders(msg.headers);
|
|
2328
|
+
const body =
|
|
2329
|
+
typeof msg.body === "string" && msg.body
|
|
2330
|
+
? Buffer.from(msg.body, "base64")
|
|
2331
|
+
: Buffer.alloc(0);
|
|
2332
|
+
|
|
2333
|
+
if (pathname === "/ai-ui.iife.js") {
|
|
2334
|
+
const file = findIife();
|
|
2335
|
+
if (!file || !fs.existsSync(file)) {
|
|
2336
|
+
replyProxyHttp(
|
|
2337
|
+
id,
|
|
2338
|
+
404,
|
|
2339
|
+
{ "content-type": "text/plain; charset=utf-8" },
|
|
2340
|
+
"Not found"
|
|
2341
|
+
);
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
replyProxyHttp(
|
|
2345
|
+
id,
|
|
2346
|
+
200,
|
|
2347
|
+
{
|
|
2348
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
2349
|
+
"cache-control": "no-store",
|
|
2350
|
+
},
|
|
2351
|
+
fs.readFileSync(file)
|
|
2352
|
+
);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (pathname === "/embed-config.js") {
|
|
2357
|
+
const cfg = bridgeCfg || loadConfig();
|
|
2358
|
+
await loadSandboxStoreEnv(ws, cfg);
|
|
2359
|
+
replyProxyHttp(
|
|
2360
|
+
id,
|
|
2361
|
+
200,
|
|
2362
|
+
{
|
|
2363
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
2364
|
+
"cache-control": "no-store",
|
|
2365
|
+
},
|
|
2366
|
+
bridgeEmbedConfigJs(ws)
|
|
2367
|
+
);
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
let embedded = ws.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
|
|
2372
|
+
if (!embedded?.handleHttp) {
|
|
2373
|
+
embedded = await ensureEmbeddedChat(ws);
|
|
2374
|
+
}
|
|
2375
|
+
if (embedded?.handleHttp) {
|
|
2376
|
+
try {
|
|
2377
|
+
const result = await embedded.handleHttp({
|
|
2378
|
+
method,
|
|
2379
|
+
url: reqPath,
|
|
2380
|
+
headers,
|
|
2381
|
+
body,
|
|
2382
|
+
});
|
|
2383
|
+
replyProxyHttp(id, result.status, result.headers, result.body);
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
bridgeSend({
|
|
2386
|
+
type: "proxy.http.error",
|
|
2387
|
+
id,
|
|
2388
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
bridgeSend({
|
|
2395
|
+
type: "proxy.http.error",
|
|
2396
|
+
id,
|
|
2397
|
+
error: "AI chat is not running. Use Start Apps in Maintainer Pro.",
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function handleProxyHttpFromAdmin(msg) {
|
|
2402
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2403
|
+
const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
|
|
2404
|
+
const appId = typeof msg.appId === "string" ? msg.appId : "";
|
|
2405
|
+
if (!id) return;
|
|
2406
|
+
const ws = workspaceForProxy(sandboxId);
|
|
2407
|
+
if (!ws) {
|
|
2408
|
+
bridgeSend({
|
|
2409
|
+
type: "proxy.http.error",
|
|
2410
|
+
id,
|
|
2411
|
+
error: "No local port mapped for that app",
|
|
2412
|
+
});
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (isAiServerAppId(appId, ws)) {
|
|
2416
|
+
void handleAiProxyHttpFromAdmin(msg, ws);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
const existing = proxyHttpReqs.get(id);
|
|
2420
|
+
if (existing) {
|
|
2421
|
+
try {
|
|
2422
|
+
existing.destroy();
|
|
2423
|
+
} catch {
|
|
2424
|
+
/* ignore */
|
|
2425
|
+
}
|
|
2426
|
+
proxyHttpReqs.delete(id);
|
|
2427
|
+
}
|
|
2428
|
+
const port = localPortForProxy(ws, appId);
|
|
2429
|
+
if (!port) {
|
|
2430
|
+
bridgeSend({
|
|
2431
|
+
type: "proxy.http.error",
|
|
2432
|
+
id,
|
|
2433
|
+
error: "No local port mapped for that app",
|
|
2434
|
+
});
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const method = String(msg.method || "GET").toUpperCase();
|
|
2438
|
+
const path = safeProxyPath(msg.path);
|
|
2439
|
+
const headers = proxyReqHeaders(msg.headers);
|
|
2440
|
+
headers.host = `127.0.0.1:${port}`;
|
|
2441
|
+
// Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
|
|
2442
|
+
// This hop is server-to-server; drop those so chunks always load.
|
|
2443
|
+
for (const key of Object.keys(headers)) {
|
|
2444
|
+
const lower = key.toLowerCase();
|
|
2445
|
+
if (
|
|
2446
|
+
lower === "origin" ||
|
|
2447
|
+
lower === "referer" ||
|
|
2448
|
+
lower === "referrer" ||
|
|
2449
|
+
lower === "sec-fetch-site" ||
|
|
2450
|
+
lower === "sec-fetch-mode" ||
|
|
2451
|
+
lower === "sec-fetch-dest"
|
|
2452
|
+
) {
|
|
2453
|
+
delete headers[key];
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
const stream = randomBytes(8).toString("hex");
|
|
2457
|
+
let req;
|
|
2458
|
+
try {
|
|
2459
|
+
req = http.request(
|
|
2460
|
+
{
|
|
2461
|
+
hostname: "127.0.0.1",
|
|
2462
|
+
port,
|
|
2463
|
+
path,
|
|
2464
|
+
method,
|
|
2465
|
+
headers,
|
|
2466
|
+
agent: false,
|
|
2467
|
+
},
|
|
2468
|
+
(res) => {
|
|
2469
|
+
/** @type {Record<string, string>} */
|
|
2470
|
+
const outHeaders = {};
|
|
2471
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
2472
|
+
if (value == null) continue;
|
|
2473
|
+
outHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
2474
|
+
}
|
|
2475
|
+
bridgeSend({
|
|
2476
|
+
type: "proxy.http.start",
|
|
2477
|
+
id,
|
|
2478
|
+
stream,
|
|
2479
|
+
status: res.statusCode || 502,
|
|
2480
|
+
headers: outHeaders,
|
|
2481
|
+
});
|
|
2482
|
+
res.on("data", (chunk) => {
|
|
2483
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2484
|
+
for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
|
|
2485
|
+
const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
|
|
2486
|
+
bridgeSend({
|
|
2487
|
+
type: "proxy.http.chunk",
|
|
2488
|
+
id,
|
|
2489
|
+
stream,
|
|
2490
|
+
data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
|
|
2491
|
+
eof: false,
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
res.on("end", () => {
|
|
2496
|
+
proxyHttpReqs.delete(id);
|
|
2497
|
+
bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
);
|
|
2501
|
+
} catch (err) {
|
|
2502
|
+
bridgeSend({
|
|
2503
|
+
type: "proxy.http.error",
|
|
2504
|
+
id,
|
|
2505
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2506
|
+
});
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
req.on("error", (err) => {
|
|
2510
|
+
proxyHttpReqs.delete(id);
|
|
2511
|
+
bridgeSend({
|
|
2512
|
+
type: "proxy.http.error",
|
|
2513
|
+
id,
|
|
2514
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2515
|
+
});
|
|
2516
|
+
});
|
|
2517
|
+
proxyHttpReqs.set(id, req);
|
|
2518
|
+
if (typeof msg.body === "string" && msg.body) {
|
|
2519
|
+
req.write(Buffer.from(msg.body, "base64"));
|
|
2520
|
+
}
|
|
2521
|
+
if (msg.bodyEof !== false) req.end();
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function handleProxyHttpBodyFromAdmin(msg) {
|
|
2525
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2526
|
+
const req = proxyHttpReqs.get(id);
|
|
2527
|
+
if (!req) return;
|
|
2528
|
+
if (typeof msg.data === "string" && msg.data) {
|
|
2529
|
+
req.write(Buffer.from(msg.data, "base64"));
|
|
2530
|
+
}
|
|
2531
|
+
if (msg.eof === true) req.end();
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
function wsDataToBuffer(data) {
|
|
2535
|
+
if (typeof data === "string") return Buffer.from(data);
|
|
2536
|
+
if (Buffer.isBuffer(data)) return data;
|
|
2537
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
2538
|
+
if (ArrayBuffer.isView(data)) {
|
|
2539
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
2540
|
+
}
|
|
2541
|
+
throw new Error("Unsupported WebSocket payload type");
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function attachProxyLocalWs(id, socket) {
|
|
2545
|
+
try {
|
|
2546
|
+
socket.binaryType = "arraybuffer";
|
|
2547
|
+
} catch {
|
|
2548
|
+
/* WHATWG WebSocket in Node 22 */
|
|
2549
|
+
}
|
|
2550
|
+
proxyLocalSockets.set(id, socket);
|
|
2551
|
+
socket.addEventListener("open", () => {
|
|
2552
|
+
bridgeSend({ type: "proxy.ws.opened", id });
|
|
2553
|
+
});
|
|
2554
|
+
socket.addEventListener("message", (event) => {
|
|
2555
|
+
try {
|
|
2556
|
+
if (typeof event.data === "string") {
|
|
2557
|
+
bridgeSend({
|
|
2558
|
+
type: "proxy.ws.frame",
|
|
2559
|
+
id,
|
|
2560
|
+
data: event.data,
|
|
2561
|
+
binary: false,
|
|
2562
|
+
});
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
const buf = wsDataToBuffer(event.data);
|
|
2566
|
+
bridgeSend({
|
|
2567
|
+
type: "proxy.ws.frame",
|
|
2568
|
+
id,
|
|
2569
|
+
data: buf.toString("base64"),
|
|
2570
|
+
binary: true,
|
|
2571
|
+
});
|
|
2572
|
+
} catch (err) {
|
|
2573
|
+
logger.warn(
|
|
2574
|
+
`proxy ws frame encode failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
});
|
|
2578
|
+
socket.addEventListener("close", (event) => {
|
|
2579
|
+
proxyLocalSockets.delete(id);
|
|
2580
|
+
bridgeSend({
|
|
2581
|
+
type: "proxy.ws.close",
|
|
2582
|
+
id,
|
|
2583
|
+
code: event.code,
|
|
2584
|
+
reason: event.reason || "",
|
|
2585
|
+
});
|
|
2586
|
+
});
|
|
2587
|
+
socket.addEventListener("error", () => {
|
|
2588
|
+
/* close handler follows */
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function openProxyLocalWs(id, port, path) {
|
|
2593
|
+
let socket;
|
|
2594
|
+
try {
|
|
2595
|
+
socket = new WebSocket(`ws://127.0.0.1:${port}${path}`);
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
bridgeSend({
|
|
2598
|
+
type: "proxy.ws.error",
|
|
2599
|
+
id,
|
|
2600
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2601
|
+
});
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
attachProxyLocalWs(id, socket);
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
function handleProxyWsOpenFromAdmin(msg) {
|
|
2608
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2609
|
+
const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
|
|
2610
|
+
const appId = typeof msg.appId === "string" ? msg.appId : "";
|
|
2611
|
+
if (!id) return;
|
|
2612
|
+
const ws = workspaceForProxy(sandboxId);
|
|
2613
|
+
if (!ws) {
|
|
2614
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const path = safeProxyPath(msg.path);
|
|
2618
|
+
if (isAiServerAppId(appId, ws)) {
|
|
2619
|
+
void (async () => {
|
|
2620
|
+
const embedded = await ensureEmbeddedChat(ws);
|
|
2621
|
+
const port = Number(embedded?.port) || localPortForProxy(ws, appId);
|
|
2622
|
+
if (!port) {
|
|
2623
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "AI chat is not running" });
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
openProxyLocalWs(id, port, path);
|
|
2627
|
+
})();
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
const port = localPortForProxy(ws, appId);
|
|
2631
|
+
if (!port) {
|
|
2632
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
openProxyLocalWs(id, port, path);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function handleProxyWsFrameFromAdmin(msg) {
|
|
2639
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2640
|
+
const socket = proxyLocalSockets.get(id);
|
|
2641
|
+
if (!socket || socket.readyState !== 1) return;
|
|
2642
|
+
try {
|
|
2643
|
+
if (msg.binary === true) {
|
|
2644
|
+
socket.send(Buffer.from(String(msg.data || ""), "base64"));
|
|
2645
|
+
} else {
|
|
2646
|
+
socket.send(String(msg.data || ""));
|
|
2647
|
+
}
|
|
2648
|
+
} catch {
|
|
2649
|
+
/* ignore */
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
function handleProxyWsCloseFromAdmin(msg) {
|
|
2654
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2655
|
+
const socket = proxyLocalSockets.get(id);
|
|
2656
|
+
if (!socket) return;
|
|
2657
|
+
proxyLocalSockets.delete(id);
|
|
2658
|
+
try {
|
|
2659
|
+
socket.close(
|
|
2660
|
+
typeof msg.code === "number" ? msg.code : 1000,
|
|
2661
|
+
typeof msg.reason === "string" ? msg.reason.slice(0, 120) : ""
|
|
2662
|
+
);
|
|
2663
|
+
} catch {
|
|
2664
|
+
/* ignore */
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/** Last process problems to send on heartbeat. Key: sandboxId::code::role */
|
|
2669
|
+
const processProblems = new Map();
|
|
2670
|
+
|
|
2671
|
+
/** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
|
|
2672
|
+
const openedTerminalTitles = new Map();
|
|
2673
|
+
|
|
2674
|
+
function rememberTerminalTitle(sandboxId, title) {
|
|
2675
|
+
const id = String(sandboxId || "").trim();
|
|
2676
|
+
const name = String(title || "").trim();
|
|
2677
|
+
if (!id || !name) return;
|
|
2678
|
+
let titles = openedTerminalTitles.get(id);
|
|
2679
|
+
if (!titles) {
|
|
2680
|
+
titles = new Set();
|
|
2681
|
+
openedTerminalTitles.set(id, titles);
|
|
2682
|
+
}
|
|
2683
|
+
titles.add(name);
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function closeWindowsByTitle(title) {
|
|
2687
|
+
const name = String(title || "").trim();
|
|
2688
|
+
if (!name) return Promise.resolve();
|
|
2689
|
+
return new Promise((resolve) => {
|
|
2690
|
+
const done = () => resolve();
|
|
2691
|
+
if (process.platform === "win32") {
|
|
2692
|
+
const child = spawn(
|
|
2693
|
+
"taskkill",
|
|
2694
|
+
["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
|
|
2695
|
+
{ windowsHide: true, stdio: "ignore" }
|
|
2696
|
+
);
|
|
2697
|
+
child.on("exit", done);
|
|
2698
|
+
child.on("error", done);
|
|
2699
|
+
setTimeout(done, 4000);
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (process.platform === "darwin") {
|
|
2703
|
+
const child = spawn(
|
|
2704
|
+
"osascript",
|
|
2705
|
+
[
|
|
2706
|
+
"-e",
|
|
2707
|
+
`tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
|
|
2708
|
+
],
|
|
2709
|
+
{ stdio: "ignore" }
|
|
2710
|
+
);
|
|
2711
|
+
child.on("exit", done);
|
|
2712
|
+
child.on("error", done);
|
|
2713
|
+
setTimeout(done, 4000);
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
done();
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
async function closeRememberedTerminals(sandboxId) {
|
|
2721
|
+
const titles = openedTerminalTitles.get(sandboxId);
|
|
2722
|
+
if (!titles) return;
|
|
2723
|
+
for (const title of titles) {
|
|
2724
|
+
await closeWindowsByTitle(title);
|
|
2725
|
+
}
|
|
2726
|
+
openedTerminalTitles.delete(sandboxId);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
async function forgetLaunch(sandboxId) {
|
|
2730
|
+
for (const key of [...launchedAt.keys()]) {
|
|
2731
|
+
if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
|
|
2732
|
+
launchedAt.delete(key);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
for (const key of [...processProblems.keys()]) {
|
|
2736
|
+
if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
|
|
2737
|
+
}
|
|
2738
|
+
await closeRememberedTerminals(sandboxId);
|
|
2739
|
+
await stopCloudflare(sandboxId);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
function problemKey(sandboxId, code, role = "") {
|
|
2743
|
+
return `${sandboxId || ""}::${code}::${role}`;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function clipIssueText(text, max) {
|
|
2360
2747
|
const value = String(text || "").trim();
|
|
2361
2748
|
if (value.length <= max) return value;
|
|
2362
2749
|
return `${value.slice(0, max - 1)}…`;
|
|
@@ -2518,8 +2905,8 @@ function friendlyLaunchError(raw, title) {
|
|
|
2518
2905
|
return text || `Could not open a terminal for "${title}".`;
|
|
2519
2906
|
}
|
|
2520
2907
|
|
|
2521
|
-
function writeWinLaunchScript(folder, title, command, env) {
|
|
2522
|
-
const dir =
|
|
2908
|
+
function writeWinLaunchScript(folder, title, command, env, sandboxId) {
|
|
2909
|
+
const dir = dataDirFor(folder, sandboxId);
|
|
2523
2910
|
fs.mkdirSync(dir, { recursive: true });
|
|
2524
2911
|
const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
2525
2912
|
const file = path.join(dir, `launch-${safe}.cmd`);
|
|
@@ -2651,7 +3038,13 @@ async function openInNewTerminal(opts) {
|
|
|
2651
3038
|
const safeTitle =
|
|
2652
3039
|
String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
|
|
2653
3040
|
"Maintainer Pro";
|
|
2654
|
-
const script = writeWinLaunchScript(
|
|
3041
|
+
const script = writeWinLaunchScript(
|
|
3042
|
+
folder,
|
|
3043
|
+
safeTitle,
|
|
3044
|
+
command,
|
|
3045
|
+
env,
|
|
3046
|
+
sandboxId
|
|
3047
|
+
);
|
|
2655
3048
|
log(`terminal script [${title}] ${script}`);
|
|
2656
3049
|
const opened = await openWindowsConsole(script, folder);
|
|
2657
3050
|
if (!opened.ok) {
|
|
@@ -2708,12 +3101,56 @@ function commandWithPort(job, scripts, port) {
|
|
|
2708
3101
|
return job.command;
|
|
2709
3102
|
}
|
|
2710
3103
|
|
|
3104
|
+
async function stopEmbeddedChat(sandboxId) {
|
|
3105
|
+
const inst = embeddedChat.get(sandboxId);
|
|
3106
|
+
if (!inst) return;
|
|
3107
|
+
embeddedChat.delete(sandboxId);
|
|
3108
|
+
try {
|
|
3109
|
+
await inst.close();
|
|
3110
|
+
} catch {
|
|
3111
|
+
/* ignore */
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
async function stopAllEmbeddedChat() {
|
|
3116
|
+
const ids = [...embeddedChat.keys()];
|
|
3117
|
+
await Promise.all(ids.map((id) => stopEmbeddedChat(id)));
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
async function ensureEmbeddedChat(ws) {
|
|
3121
|
+
if (!ws?.sandboxId) return null;
|
|
3122
|
+
const existing = embeddedChat.get(ws.sandboxId);
|
|
3123
|
+
if (existing?.handleHttp) return existing;
|
|
3124
|
+
const inflight = embeddedChatStarting.get(ws.sandboxId);
|
|
3125
|
+
if (inflight) return inflight;
|
|
3126
|
+
const task = (async () => {
|
|
3127
|
+
const cfg = bridgeCfg || loadConfig();
|
|
3128
|
+
const jobs = jobsFromHostApps(ws);
|
|
3129
|
+
const shareEnv = {
|
|
3130
|
+
...envForWorkspacePorts(ws, jobs),
|
|
3131
|
+
...uiPublicEnv({}, ws),
|
|
3132
|
+
};
|
|
3133
|
+
log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
|
|
3134
|
+
const result = await startAiServerForWorkspace(ws, {
|
|
3135
|
+
cfg,
|
|
3136
|
+
env: shareEnv,
|
|
3137
|
+
forceEmbed: true,
|
|
3138
|
+
});
|
|
3139
|
+
if (!result.up) return null;
|
|
3140
|
+
return embeddedChat.get(ws.sandboxId) || null;
|
|
3141
|
+
})().finally(() => {
|
|
3142
|
+
embeddedChatStarting.delete(ws.sandboxId);
|
|
3143
|
+
});
|
|
3144
|
+
embeddedChatStarting.set(ws.sandboxId, task);
|
|
3145
|
+
return task;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
2711
3148
|
async function startAiServerForWorkspace(ws, opts = {}) {
|
|
2712
3149
|
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
2713
3150
|
const cfg = opts.cfg || null;
|
|
2714
3151
|
const folder = path.resolve(ws.folderPath);
|
|
2715
|
-
const
|
|
2716
|
-
const
|
|
3152
|
+
const preferredRaw = Number(ws.port) || 3100;
|
|
3153
|
+
const preferred = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
|
|
2717
3154
|
const label = ws.sandboxName || "this sandbox";
|
|
2718
3155
|
|
|
2719
3156
|
if (!fs.existsSync(folder)) {
|
|
@@ -2728,19 +3165,28 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2728
3165
|
return { port: preferred, up: false, launched: false };
|
|
2729
3166
|
}
|
|
2730
3167
|
|
|
2731
|
-
|
|
3168
|
+
const existing = embeddedChat.get(ws.sandboxId);
|
|
3169
|
+
if (existing && existing.workspaceDir === folder) {
|
|
3170
|
+
if (await isChatServerOnPort(existing.port)) {
|
|
3171
|
+
reserved.add(existing.port);
|
|
3172
|
+
ws.port = existing.port;
|
|
3173
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
3174
|
+
log(`start chat skip ${label}: already in-process on ${existing.port}`);
|
|
3175
|
+
return { port: existing.port, up: true, launched: false };
|
|
3176
|
+
}
|
|
3177
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3178
|
+
} else if (existing) {
|
|
3179
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
if (!opts.forceEmbed && (await isChatServerOnPort(preferred))) {
|
|
2732
3183
|
reserved.add(preferred);
|
|
3184
|
+
ws.port = preferred;
|
|
2733
3185
|
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2734
3186
|
log(`start chat skip ${label}: already up on ${preferred}`);
|
|
2735
3187
|
return { port: preferred, up: true, launched: false };
|
|
2736
3188
|
}
|
|
2737
3189
|
|
|
2738
|
-
if (recentlyLaunched(launchKey)) {
|
|
2739
|
-
reserved.add(preferred);
|
|
2740
|
-
log(`start chat skip ${label}: already launching on ${preferred}`);
|
|
2741
|
-
return { port: preferred, up: false, launched: false, starting: true };
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
3190
|
let port = Number(opts.port) || preferred;
|
|
2745
3191
|
try {
|
|
2746
3192
|
if (!opts.port) {
|
|
@@ -2762,7 +3208,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2762
3208
|
return { port: preferred, up: false, launched: false };
|
|
2763
3209
|
}
|
|
2764
3210
|
if (port !== preferred) {
|
|
2765
|
-
log(`port ${preferred} busy; using ${port} for chat
|
|
3211
|
+
log(`port ${preferred} busy; using ${port} for chat`);
|
|
2766
3212
|
}
|
|
2767
3213
|
ws.port = port;
|
|
2768
3214
|
const localAi = `http://localhost:${port}`;
|
|
@@ -2776,59 +3222,52 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2776
3222
|
(typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
|
|
2777
3223
|
"";
|
|
2778
3224
|
const aiUrl = publicAi || localAi;
|
|
2779
|
-
writeProjectEnv(folder, {
|
|
2780
|
-
AI_SERVER_URL: aiUrl,
|
|
2781
|
-
NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
|
|
2782
|
-
VITE_AI_SERVER_URL: aiUrl,
|
|
2783
|
-
...overrideEnv,
|
|
2784
|
-
});
|
|
2785
3225
|
persistWorkspaceEntry(cfg, ws);
|
|
2786
3226
|
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
return { port, up:
|
|
2818
|
-
}
|
|
2819
|
-
|
|
3227
|
+
const storeEnv = await loadSandboxStoreEnv(ws, cfg);
|
|
3228
|
+
const folderEnv = readProjectEnvValues(folder);
|
|
3229
|
+
const dataDir = dataDirFor(folder, ws.sandboxId);
|
|
3230
|
+
const instanceEnv = {
|
|
3231
|
+
...folderEnv,
|
|
3232
|
+
...overrideEnv,
|
|
3233
|
+
...storeEnv,
|
|
3234
|
+
AI_SERVER_URL: aiUrl,
|
|
3235
|
+
CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
|
|
3236
|
+
CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
|
|
3237
|
+
MAINTAINER_PRO_DATA_DIR: dataDir,
|
|
3238
|
+
MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
|
|
3239
|
+
};
|
|
3240
|
+
|
|
3241
|
+
try {
|
|
3242
|
+
const instance = await startAiServer({
|
|
3243
|
+
port,
|
|
3244
|
+
workspaceDir: folder,
|
|
3245
|
+
uiDir: folder,
|
|
3246
|
+
dataDir,
|
|
3247
|
+
env: instanceEnv,
|
|
3248
|
+
logger: createLogger(`ai-server:${shortId(ws.sandboxId)}`),
|
|
3249
|
+
label: `ai-server:${shortId(ws.sandboxId)}`,
|
|
3250
|
+
listen: true,
|
|
3251
|
+
});
|
|
3252
|
+
embeddedChat.set(ws.sandboxId, instance);
|
|
3253
|
+
ws.port = instance.port;
|
|
3254
|
+
persistWorkspaceEntry(cfg, ws);
|
|
3255
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
3256
|
+
log(`chat in-process for ${label} on ${instance.port} (${folder})`);
|
|
3257
|
+
return { port: instance.port, up: true, launched: true };
|
|
3258
|
+
} catch (err) {
|
|
3259
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2820
3260
|
recordProcessProblem({
|
|
2821
3261
|
sandboxId: ws.sandboxId,
|
|
2822
3262
|
code: "ai_server_launch",
|
|
2823
3263
|
role: "ai",
|
|
2824
|
-
title: `Could not start
|
|
2825
|
-
message
|
|
3264
|
+
title: `Could not start chat for ${label}`,
|
|
3265
|
+
message,
|
|
2826
3266
|
resolution:
|
|
2827
|
-
"
|
|
3267
|
+
"Keep the bridge connected to Maintainer Pro, then Start Apps.",
|
|
2828
3268
|
});
|
|
2829
3269
|
return { port, up: false, launched: false };
|
|
2830
3270
|
}
|
|
2831
|
-
return { port, up: false, launched: true, starting: true };
|
|
2832
3271
|
}
|
|
2833
3272
|
|
|
2834
3273
|
function sleep(ms) {
|
|
@@ -2839,10 +3278,6 @@ function stopAllCloudflare() {
|
|
|
2839
3278
|
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
2840
3279
|
}
|
|
2841
3280
|
|
|
2842
|
-
function parseTryCloudflareUrl(text) {
|
|
2843
|
-
return lastTryCloudflareUrl(text);
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
3281
|
function killProcessesByCommand(fragment) {
|
|
2847
3282
|
if (!fragment) return Promise.resolve();
|
|
2848
3283
|
return new Promise((resolve) => {
|
|
@@ -2901,11 +3336,27 @@ function killPort(port) {
|
|
|
2901
3336
|
});
|
|
2902
3337
|
}
|
|
2903
3338
|
|
|
2904
|
-
function stopCloudflare(sandboxId) {
|
|
3339
|
+
function stopCloudflare(sandboxId, folder) {
|
|
2905
3340
|
const row = cloudflareTunnels.get(sandboxId);
|
|
2906
|
-
const files =
|
|
3341
|
+
const files = new Set(
|
|
3342
|
+
(row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [])
|
|
3343
|
+
);
|
|
2907
3344
|
cloudflareTunnels.delete(sandboxId);
|
|
2908
|
-
|
|
3345
|
+
const logDir = folder
|
|
3346
|
+
? dataDirFor(folder, sandboxId)
|
|
3347
|
+
: null;
|
|
3348
|
+
if (logDir && fs.existsSync(logDir)) {
|
|
3349
|
+
try {
|
|
3350
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
3351
|
+
if (/^cf-.*\.log$/i.test(name) && !name.endsWith(".stale")) {
|
|
3352
|
+
files.add(path.join(logDir, name));
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
} catch {
|
|
3356
|
+
/* ignore */
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
return Promise.all([...files].map((file) => killProcessesByCommand(file)));
|
|
2909
3360
|
}
|
|
2910
3361
|
|
|
2911
3362
|
function forgetProcessLaunches(sandboxId) {
|
|
@@ -2918,12 +3369,18 @@ function forgetProcessLaunches(sandboxId) {
|
|
|
2918
3369
|
|
|
2919
3370
|
async function stopWorkspaceApps(ws) {
|
|
2920
3371
|
const folder = path.resolve(ws.folderPath || "");
|
|
2921
|
-
const jobs =
|
|
3372
|
+
const jobs = jobsFromHostApps(ws).length
|
|
3373
|
+
? jobsFromHostApps(ws)
|
|
3374
|
+
: planHostJobs(folder, null, ws.projectInfo);
|
|
2922
3375
|
const ports = new Set();
|
|
2923
3376
|
if (ws.port) ports.add(Number(ws.port));
|
|
2924
3377
|
for (const job of jobs) {
|
|
3378
|
+
if (job.port) ports.add(Number(job.port));
|
|
2925
3379
|
if (job.preferredPort) ports.add(Number(job.preferredPort));
|
|
2926
3380
|
}
|
|
3381
|
+
for (const app of Array.isArray(ws.hostApps) ? ws.hostApps : []) {
|
|
3382
|
+
if (app?.port) ports.add(Number(app.port));
|
|
3383
|
+
}
|
|
2927
3384
|
if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
|
|
2928
3385
|
try {
|
|
2929
3386
|
const port = Number(new URL(ws.appUrl).port);
|
|
@@ -2933,12 +3390,13 @@ async function stopWorkspaceApps(ws) {
|
|
|
2933
3390
|
}
|
|
2934
3391
|
}
|
|
2935
3392
|
log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
|
|
3393
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
2936
3394
|
await closeRememberedTerminals(ws.sandboxId);
|
|
2937
3395
|
for (const port of ports) {
|
|
2938
3396
|
await killPort(port);
|
|
2939
3397
|
}
|
|
2940
3398
|
forgetProcessLaunches(ws.sandboxId);
|
|
2941
|
-
await sleep(
|
|
3399
|
+
await sleep(800);
|
|
2942
3400
|
}
|
|
2943
3401
|
|
|
2944
3402
|
async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
@@ -2958,82 +3416,45 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
|
2958
3416
|
throw new Error(`${label} did not become reachable at ${url}`);
|
|
2959
3417
|
}
|
|
2960
3418
|
|
|
2961
|
-
|
|
2962
|
-
const
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
`Still waiting for a Cloudflare URL (${Math.round(elapsed / 1000)}s)…`
|
|
2974
|
-
);
|
|
2975
|
-
}
|
|
2976
|
-
await sleep(500);
|
|
2977
|
-
}
|
|
2978
|
-
throw new Error(
|
|
2979
|
-
`Cloudflare did not publish a URL in time (${path.basename(file)}). Check that terminal.`
|
|
2980
|
-
);
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
|
-
function cloudflaredCommand(localUrl, logFile) {
|
|
2984
|
-
const logArg = JSON.stringify(logFile);
|
|
2985
|
-
const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate --logfile ${logArg}`;
|
|
2986
|
-
if (process.platform === "win32") return run;
|
|
2987
|
-
return `${run} 2>&1 | tee ${logArg}`;
|
|
2988
|
-
}
|
|
2989
|
-
|
|
2990
|
-
async function startCloudflareTerminal(ws, role, localUrl, onWait) {
|
|
2991
|
-
const folder = path.resolve(ws.folderPath);
|
|
2992
|
-
const logDir = path.join(folder, ".maintainer-pro");
|
|
2993
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
2994
|
-
const logFile = path.join(
|
|
2995
|
-
logDir,
|
|
2996
|
-
`cf-${String(ws.sandboxId).slice(0, 8)}-${role}.log`
|
|
2997
|
-
);
|
|
2998
|
-
try {
|
|
2999
|
-
fs.unlinkSync(logFile);
|
|
3000
|
-
} catch {
|
|
3001
|
-
/* ignore */
|
|
3002
|
-
}
|
|
3003
|
-
const opened = await openInNewTerminal({
|
|
3004
|
-
title: `MP-cf-${role}`,
|
|
3005
|
-
folder,
|
|
3006
|
-
command: cloudflaredCommand(localUrl, logFile),
|
|
3007
|
-
launchKey: `${ws.sandboxId}:cf:${role}`,
|
|
3008
|
-
sandboxId: ws.sandboxId,
|
|
3009
|
-
force: true,
|
|
3010
|
-
});
|
|
3011
|
-
if (!opened.ok) {
|
|
3012
|
-
throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
|
|
3013
|
-
}
|
|
3014
|
-
const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
|
|
3015
|
-
return { role, localUrl, publicUrl, logFile };
|
|
3419
|
+
function applyBackendPublicEnv(env, url) {
|
|
3420
|
+
const backend = String(url || "").replace(/\/$/, "");
|
|
3421
|
+
if (!backend) return env;
|
|
3422
|
+
env.API_URL = backend;
|
|
3423
|
+
env.API_BASE_URL = backend;
|
|
3424
|
+
env.BACKEND_URL = backend;
|
|
3425
|
+
env.VITE_API_URL = backend;
|
|
3426
|
+
env.VITE_API_BASE_URL = backend;
|
|
3427
|
+
env.NEXT_PUBLIC_API_URL = backend;
|
|
3428
|
+
env.NEXT_PUBLIC_API_BASE_URL = backend;
|
|
3429
|
+
env.REACT_APP_API_URL = backend;
|
|
3430
|
+
return env;
|
|
3016
3431
|
}
|
|
3017
3432
|
|
|
3018
|
-
function uiPublicEnv(tunnels) {
|
|
3433
|
+
function uiPublicEnv(tunnels, ws) {
|
|
3019
3434
|
/** @type {Record<string, string>} */
|
|
3020
3435
|
const env = {};
|
|
3021
|
-
|
|
3022
|
-
|
|
3436
|
+
const aiApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
|
|
3437
|
+
(app) => app.role === "ai-server" || app.id === "ai-server"
|
|
3438
|
+
) || { id: "ai-server", role: "ai-server" };
|
|
3439
|
+
const ai =
|
|
3440
|
+
proxyUrlForApp(ws, aiApp) ||
|
|
3441
|
+
(tunnels.ai ? String(tunnels.ai).replace(/\/$/, "") : "");
|
|
3442
|
+
if (ai) {
|
|
3023
3443
|
env.AI_SERVER_URL = ai;
|
|
3024
3444
|
env.NEXT_PUBLIC_AI_SERVER_URL = ai;
|
|
3025
3445
|
env.VITE_AI_SERVER_URL = ai;
|
|
3026
3446
|
env.REACT_APP_AI_SERVER_URL = ai;
|
|
3027
3447
|
}
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3448
|
+
const backendApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
|
|
3449
|
+
(app) => isBackendApp(app)
|
|
3450
|
+
);
|
|
3451
|
+
const backendUrl =
|
|
3452
|
+
(backendApp ? proxyUrlForApp(ws, backendApp) : "") ||
|
|
3453
|
+
(typeof tunnels.backend === "string" && tunnels.backend) ||
|
|
3454
|
+
(backendApp ? publicUrlForApp(ws, backendApp) : "") ||
|
|
3455
|
+
"";
|
|
3456
|
+
if (backendUrl && backendApp) {
|
|
3457
|
+
applyBackendPublicEnv(env, backendUrl);
|
|
3037
3458
|
}
|
|
3038
3459
|
if (tunnels.ui) {
|
|
3039
3460
|
const ui = String(tunnels.ui).replace(/\/$/, "");
|
|
@@ -3043,156 +3464,181 @@ function uiPublicEnv(tunnels) {
|
|
|
3043
3464
|
env.NEXT_PUBLIC_APP_URL = ui;
|
|
3044
3465
|
env.VITE_APP_URL = ui;
|
|
3045
3466
|
env.REACT_APP_APP_URL = ui;
|
|
3046
|
-
} else if (tunnels.
|
|
3047
|
-
const
|
|
3048
|
-
env.
|
|
3049
|
-
env.
|
|
3050
|
-
env.
|
|
3051
|
-
env.NEXT_PUBLIC_APP_URL =
|
|
3052
|
-
env.VITE_APP_URL =
|
|
3467
|
+
} else if (tunnels.app) {
|
|
3468
|
+
const app = String(tunnels.app).replace(/\/$/, "");
|
|
3469
|
+
env.APP_URL = app;
|
|
3470
|
+
env.PUBLIC_URL = app;
|
|
3471
|
+
env.CORS_ORIGIN = app;
|
|
3472
|
+
env.NEXT_PUBLIC_APP_URL = app;
|
|
3473
|
+
env.VITE_APP_URL = app;
|
|
3474
|
+
env.REACT_APP_APP_URL = app;
|
|
3475
|
+
} else if (ai) {
|
|
3476
|
+
const hostUi = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).some(
|
|
3477
|
+
(app) => app.host || app.role === "ui" || app.role === "app"
|
|
3478
|
+
);
|
|
3479
|
+
if (!hostUi) {
|
|
3480
|
+
env.CORS_ORIGIN = ai;
|
|
3481
|
+
env.APP_URL = ai;
|
|
3482
|
+
env.PUBLIC_URL = ai;
|
|
3483
|
+
env.NEXT_PUBLIC_APP_URL = ai;
|
|
3484
|
+
env.VITE_APP_URL = ai;
|
|
3485
|
+
}
|
|
3053
3486
|
}
|
|
3054
3487
|
return env;
|
|
3055
3488
|
}
|
|
3056
3489
|
|
|
3057
|
-
function
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
const
|
|
3062
|
-
|
|
3063
|
-
.map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
|
|
3064
|
-
fs.writeFileSync(
|
|
3065
|
-
path.join(folder, ".cloudflare-tunnel-url"),
|
|
3066
|
-
`${lines.join("\n")}\n`,
|
|
3067
|
-
"utf8"
|
|
3068
|
-
);
|
|
3069
|
-
if (Object.keys(env).length) writeProjectEnv(folder, env);
|
|
3070
|
-
return { ok: true, env };
|
|
3490
|
+
function publicUrlForApp(ws, app) {
|
|
3491
|
+
if (!app) return "";
|
|
3492
|
+
const proxied = proxyUrlForApp(ws, app);
|
|
3493
|
+
if (proxied) return proxied;
|
|
3494
|
+
const port = Number(app.port) || 0;
|
|
3495
|
+
return port ? `http://localhost:${port}` : "";
|
|
3071
3496
|
}
|
|
3072
3497
|
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3498
|
+
function envFromConfiguredMaps(ws) {
|
|
3499
|
+
/** @type {Record<string, string>} */
|
|
3500
|
+
const env = {};
|
|
3501
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
3502
|
+
for (const app of apps) {
|
|
3503
|
+
const maps = Array.isArray(app.envMaps) ? app.envMaps : [];
|
|
3504
|
+
for (const row of maps) {
|
|
3505
|
+
const key = String(row?.key || "").trim();
|
|
3506
|
+
const sourceId = String(row?.sourceAppId || "").trim();
|
|
3507
|
+
if (!key || !sourceId) continue;
|
|
3508
|
+
const source =
|
|
3509
|
+
sourceId === "self"
|
|
3510
|
+
? app
|
|
3511
|
+
: apps.find((item) => item.id === sourceId) || null;
|
|
3512
|
+
const url = publicUrlForApp(ws, source);
|
|
3513
|
+
if (url) env[key] = url;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
return env;
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
function cloudflarePublicEnv(ws) {
|
|
3520
|
+
const tunnels =
|
|
3521
|
+
ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
|
|
3522
|
+
const jobs = jobsFromHostApps(ws);
|
|
3523
|
+
const env = {
|
|
3524
|
+
...envForWorkspacePorts(ws, jobs),
|
|
3525
|
+
...uiPublicEnv(tunnels, ws),
|
|
3526
|
+
};
|
|
3527
|
+
const origins = [];
|
|
3528
|
+
const add = (value) => {
|
|
3529
|
+
const origin = originFromUrl(value);
|
|
3530
|
+
if (origin && !origins.includes(origin)) origins.push(origin);
|
|
3531
|
+
};
|
|
3532
|
+
add(tunnels.ui);
|
|
3533
|
+
add(tunnels.app);
|
|
3534
|
+
add(tunnels.backend);
|
|
3535
|
+
add(tunnels.ai);
|
|
3536
|
+
add(env.APP_URL);
|
|
3537
|
+
add(env.CORS_ORIGIN);
|
|
3538
|
+
for (const job of jobs) {
|
|
3539
|
+
if (job.port) add(`http://localhost:${job.port}`);
|
|
3540
|
+
}
|
|
3541
|
+
add(`http://localhost:${Number(ws.port) || 3100}`);
|
|
3542
|
+
if (origins.length) {
|
|
3543
|
+
env.CORS_ORIGIN = origins.join(",");
|
|
3544
|
+
env.CORS_ORIGINS = origins.join(",");
|
|
3545
|
+
}
|
|
3546
|
+
Object.assign(env, envFromConfiguredMaps(ws));
|
|
3547
|
+
return env;
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
async function applyPublicUrlsToRunningApps(ws, cfg, opts = {}) {
|
|
3551
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : "";
|
|
3079
3552
|
const progress =
|
|
3080
3553
|
typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
const
|
|
3084
|
-
|
|
3085
|
-
|
|
3554
|
+
const tunnels =
|
|
3555
|
+
ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
|
|
3556
|
+
const hasProxy = Boolean(ws.proxy?.token);
|
|
3557
|
+
if (
|
|
3558
|
+
!folder ||
|
|
3559
|
+
!fs.existsSync(folder) ||
|
|
3560
|
+
(!Object.keys(tunnels).length && !hasProxy)
|
|
3561
|
+
) {
|
|
3562
|
+
return { restarted: [], rewritten: [] };
|
|
3563
|
+
}
|
|
3564
|
+
const env = {
|
|
3565
|
+
...cloudflarePublicEnv(ws),
|
|
3086
3566
|
};
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
if (!tunnels.ai) {
|
|
3096
|
-
add("ai_tunnel", false, "Missing chat-script Cloudflare URL");
|
|
3097
|
-
} else {
|
|
3098
|
-
const ai = String(tunnels.ai).replace(/\/$/, "");
|
|
3099
|
-
const embed = `${ai}/embed-config.js`;
|
|
3100
|
-
const reachable = await probeUrl(embed, 10_000);
|
|
3101
|
-
add(
|
|
3102
|
-
"ai_tunnel",
|
|
3103
|
-
reachable,
|
|
3104
|
-
reachable ? `Reachable ${embed}` : `Unreachable ${embed}`
|
|
3567
|
+
if (Object.keys(tunnels).length) writeTunnelEnv(ws, tunnels);
|
|
3568
|
+
const mappedKeys = Object.keys(envFromConfiguredMaps(ws));
|
|
3569
|
+
if (mappedKeys.length) {
|
|
3570
|
+
activity(
|
|
3571
|
+
ws.sandboxId,
|
|
3572
|
+
"info",
|
|
3573
|
+
`Injecting public URLs into process env for ${mappedKeys.join(", ")}`
|
|
3105
3574
|
);
|
|
3106
|
-
|
|
3107
|
-
const body = await fetchText(embed, 10_000);
|
|
3108
|
-
const advertises =
|
|
3109
|
-
Boolean(body) &&
|
|
3110
|
-
(body.includes(ai) ||
|
|
3111
|
-
body.includes(ai.replace(/^https:\/\//, "")));
|
|
3112
|
-
add(
|
|
3113
|
-
"embed_config",
|
|
3114
|
-
advertises,
|
|
3115
|
-
advertises
|
|
3116
|
-
? "embed-config.js advertises the public AI URL"
|
|
3117
|
-
: `embed-config.js does not advertise ${ai}`
|
|
3118
|
-
);
|
|
3119
|
-
}
|
|
3575
|
+
await progress(`Passing ${mappedKeys.join(", ")} via process env`);
|
|
3120
3576
|
}
|
|
3121
3577
|
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
);
|
|
3578
|
+
const probe = await probeRunningApps(ws, 1200);
|
|
3579
|
+
const runningRoles = new Set(
|
|
3580
|
+
(probe.hostApps || [])
|
|
3581
|
+
.filter((app) => app.running)
|
|
3582
|
+
.map((app) =>
|
|
3583
|
+
app.role === "ai-server" ? "ai-server" : app.role === "custom" ? "app" : app.role
|
|
3584
|
+
)
|
|
3585
|
+
);
|
|
3586
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3587
|
+
const restarted = [];
|
|
3588
|
+
|
|
3589
|
+
if (probe.chatUp || runningRoles.has("ai-server") || embeddedChat.has(ws.sandboxId)) {
|
|
3590
|
+
await progress("Restarting in-process chat with public URLs and CORS…");
|
|
3591
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
|
|
3592
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3593
|
+
await startAiServerForWorkspace(ws, {
|
|
3594
|
+
reserved,
|
|
3595
|
+
cfg,
|
|
3596
|
+
port: ws.port,
|
|
3597
|
+
env,
|
|
3598
|
+
});
|
|
3599
|
+
restarted.push("ai-server");
|
|
3136
3600
|
}
|
|
3137
3601
|
|
|
3138
|
-
const
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
]
|
|
3146
|
-
.map((value) => String(value || "").replace(/\/$/, ""))
|
|
3147
|
-
.filter(Boolean);
|
|
3148
|
-
const envOk = envAi.some((value) => value === ai);
|
|
3149
|
-
add(
|
|
3150
|
-
"frontend_env",
|
|
3151
|
-
envOk,
|
|
3152
|
-
envOk
|
|
3153
|
-
? `Frontend env has AI URL ${ai}`
|
|
3154
|
-
: `Frontend env missing AI URL (NEXT_PUBLIC=${
|
|
3155
|
-
env.NEXT_PUBLIC_AI_SERVER_URL || "(empty)"
|
|
3156
|
-
}, VITE=${env.VITE_AI_SERVER_URL || "(empty)"}, AI_SERVER_URL=${
|
|
3157
|
-
env.AI_SERVER_URL || "(empty)"
|
|
3158
|
-
})`
|
|
3602
|
+
const jobs = jobsFromHostApps(ws).filter((job) =>
|
|
3603
|
+
runningRoles.has(job.role === "custom" ? "app" : job.role)
|
|
3604
|
+
);
|
|
3605
|
+
if (jobs.length) {
|
|
3606
|
+
const roles = [...new Set(jobs.map((job) => job.role))];
|
|
3607
|
+
await progress(
|
|
3608
|
+
`Restarting ${roles.join(", ")} so they load the public URLs…`
|
|
3159
3609
|
);
|
|
3610
|
+
for (const job of jobs) {
|
|
3611
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
|
|
3612
|
+
await killPort(job.port);
|
|
3613
|
+
}
|
|
3614
|
+
await sleep(1500);
|
|
3615
|
+
await ensureHostProcesses(ws, {
|
|
3616
|
+
reserved,
|
|
3617
|
+
cfg,
|
|
3618
|
+
onlyRoles: roles,
|
|
3619
|
+
extraEnv: env,
|
|
3620
|
+
force: true,
|
|
3621
|
+
plannedJobs: jobsFromHostApps(ws),
|
|
3622
|
+
});
|
|
3623
|
+
restarted.push(...roles);
|
|
3160
3624
|
}
|
|
3161
3625
|
|
|
3162
|
-
if (
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
env.PUBLIC_URL,
|
|
3168
|
-
env.VITE_APP_URL,
|
|
3169
|
-
env.CORS_ORIGIN,
|
|
3170
|
-
]
|
|
3171
|
-
.map((value) => String(value || "").replace(/\/$/, ""))
|
|
3172
|
-
.filter(Boolean);
|
|
3173
|
-
const appOk = envApp.some((value) => value === ui);
|
|
3174
|
-
add(
|
|
3175
|
-
"app_url_env",
|
|
3176
|
-
appOk,
|
|
3177
|
-
appOk
|
|
3178
|
-
? `App URL env has ${ui}`
|
|
3179
|
-
: `App URL env missing ${ui} (APP_URL=${env.APP_URL || "(empty)"}, NEXT_PUBLIC_APP_URL=${
|
|
3180
|
-
env.NEXT_PUBLIC_APP_URL || "(empty)"
|
|
3181
|
-
})`
|
|
3626
|
+
if (restarted.length) {
|
|
3627
|
+
activity(
|
|
3628
|
+
ws.sandboxId,
|
|
3629
|
+
"info",
|
|
3630
|
+
`Restarted ${[...new Set(restarted)].join(", ")} with public URLs`
|
|
3182
3631
|
);
|
|
3183
3632
|
}
|
|
3633
|
+
return { restarted: [...new Set(restarted)], rewritten: [], env };
|
|
3634
|
+
}
|
|
3184
3635
|
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
.map((check) => `${check.id}: ${check.detail}`)
|
|
3192
|
-
.join(" | ");
|
|
3193
|
-
await progress(`Validation failed — not sharing yet. ${failed}`);
|
|
3194
|
-
}
|
|
3195
|
-
return { ok, checks };
|
|
3636
|
+
function writeTunnelEnv(ws, tunnels) {
|
|
3637
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
3638
|
+
if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
|
|
3639
|
+
clearCloudflareTunnelFile(folder);
|
|
3640
|
+
const env = uiPublicEnv(tunnels, ws);
|
|
3641
|
+
return { ok: true, env };
|
|
3196
3642
|
}
|
|
3197
3643
|
|
|
3198
3644
|
function reservedPortsFor(cfg, sandboxId) {
|
|
@@ -3211,271 +3657,35 @@ function appsWanted(ws) {
|
|
|
3211
3657
|
|
|
3212
3658
|
async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
|
|
3213
3659
|
const sandboxId = ws.sandboxId;
|
|
3214
|
-
const label = ws.sandboxName || "this sandbox";
|
|
3215
|
-
const progress = (message) =>
|
|
3216
|
-
reportActionProgress(cfg, opts.actionId, message);
|
|
3217
|
-
|
|
3218
|
-
// If tunnels are already up, attach instead of tearing them down.
|
|
3219
|
-
const attached = await tryAttachExistingCloudflare(ws, cfg, {
|
|
3220
|
-
onProgress: progress,
|
|
3221
|
-
});
|
|
3222
|
-
if (attached) {
|
|
3223
|
-
return {
|
|
3224
|
-
...attached,
|
|
3225
|
-
pending: false,
|
|
3226
|
-
cloudflarePending: false,
|
|
3227
|
-
waitingForStart: false,
|
|
3228
|
-
warning: null,
|
|
3229
|
-
};
|
|
3230
|
-
}
|
|
3231
|
-
|
|
3232
|
-
await progress(
|
|
3233
|
-
`Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
|
|
3234
|
-
);
|
|
3235
|
-
await stopCloudflare(sandboxId);
|
|
3236
|
-
await progress("Stopping local app terminals and freeing their ports…");
|
|
3237
|
-
await stopWorkspaceApps(ws);
|
|
3238
|
-
await forgetLaunch(sandboxId);
|
|
3239
|
-
ws.cloudflarePending = true;
|
|
3240
|
-
ws.appsRequested = false;
|
|
3241
|
-
persistWorkspaceEntry(cfg, ws);
|
|
3242
|
-
await progress(
|
|
3243
|
-
"Cloudflare is queued. Use Start Apps to create the public URLs."
|
|
3244
|
-
);
|
|
3245
|
-
|
|
3246
|
-
return {
|
|
3247
|
-
sandboxId,
|
|
3248
|
-
folderPath: ws.folderPath,
|
|
3249
|
-
port: ws.port,
|
|
3250
|
-
pending: true,
|
|
3251
|
-
cloudflarePending: true,
|
|
3252
|
-
waitingForStart: true,
|
|
3253
|
-
warning:
|
|
3254
|
-
"Cloudflare is ready in Maintainer Pro. Use Start Apps when you want to launch the apps and create the public URLs.",
|
|
3255
|
-
};
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3258
|
-
async function launchCloudflareTunnels(ws, cfg, opts = {}) {
|
|
3259
|
-
const sandboxId = ws.sandboxId;
|
|
3260
|
-
const label = ws.sandboxName || "this sandbox";
|
|
3261
|
-
const folder = path.resolve(ws.folderPath || "");
|
|
3262
|
-
const reserved = reservedPortsFor(cfg, sandboxId);
|
|
3263
3660
|
const progress = (message) =>
|
|
3264
3661
|
reportActionProgress(cfg, opts.actionId, message);
|
|
3265
|
-
|
|
3266
|
-
const attached = await tryAttachExistingCloudflare(ws, cfg, {
|
|
3267
|
-
onProgress: progress,
|
|
3268
|
-
});
|
|
3269
|
-
if (attached) return attached;
|
|
3270
|
-
|
|
3271
|
-
await progress(
|
|
3272
|
-
`Creating Cloudflare tunnels for ${label}. This usually takes 1–2 minutes.`
|
|
3273
|
-
);
|
|
3662
|
+
await progress("Using Maintainer Pro share URLs (no Cloudflare).");
|
|
3274
3663
|
try {
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
await startAiServerForWorkspace(ws, {
|
|
3279
|
-
reserved,
|
|
3280
|
-
cfg,
|
|
3281
|
-
port: plan.aiPort,
|
|
3282
|
-
});
|
|
3283
|
-
await waitUntilReachable(
|
|
3284
|
-
`http://127.0.0.1:${ws.port}/embed-config.js`,
|
|
3285
|
-
45_000,
|
|
3286
|
-
"the chat script",
|
|
3287
|
-
progress
|
|
3288
|
-
);
|
|
3289
|
-
}
|
|
3290
|
-
|
|
3291
|
-
const backendJob = plan.jobs.find((job) => job.role === "backend");
|
|
3292
|
-
const uiJob = plan.jobs.find((job) => job.role === "ui" || job.role === "app");
|
|
3293
|
-
|
|
3294
|
-
if (backendJob) {
|
|
3295
|
-
await progress(`Starting the backend on port ${backendJob.port}…`);
|
|
3296
|
-
await ensureHostProcesses(ws, {
|
|
3297
|
-
reserved,
|
|
3298
|
-
cfg,
|
|
3299
|
-
onlyRoles: ["backend"],
|
|
3300
|
-
force: true,
|
|
3301
|
-
plannedJobs: plan.jobs,
|
|
3302
|
-
});
|
|
3303
|
-
const backendPort = Number(backendJob.port) || 4100;
|
|
3304
|
-
await waitUntilReachable(
|
|
3305
|
-
`http://127.0.0.1:${backendPort}`,
|
|
3306
|
-
45_000,
|
|
3307
|
-
"the backend",
|
|
3308
|
-
progress
|
|
3309
|
-
);
|
|
3310
|
-
}
|
|
3311
|
-
|
|
3312
|
-
/** @type {Record<string, string>} */
|
|
3313
|
-
const tunnels = {};
|
|
3314
|
-
/** @type {Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }>} */
|
|
3315
|
-
const started = [];
|
|
3316
|
-
|
|
3317
|
-
const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
|
|
3318
|
-
await progress(`Opening a Cloudflare tunnel for the chat script (${aiLocal})…`);
|
|
3319
|
-
const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal, progress);
|
|
3320
|
-
tunnels.ai = aiTunnel.publicUrl;
|
|
3321
|
-
started.push(aiTunnel);
|
|
3322
|
-
await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
|
|
3323
|
-
|
|
3324
|
-
if (backendJob) {
|
|
3325
|
-
const backendLocal = `http://127.0.0.1:${Number(backendJob.port) || 4100}`;
|
|
3326
|
-
await progress(`Opening a Cloudflare tunnel for the backend (${backendLocal})…`);
|
|
3327
|
-
const backendTunnel = await startCloudflareTerminal(
|
|
3328
|
-
ws,
|
|
3329
|
-
"backend",
|
|
3330
|
-
backendLocal,
|
|
3331
|
-
progress
|
|
3332
|
-
);
|
|
3333
|
-
tunnels.backend = backendTunnel.publicUrl;
|
|
3334
|
-
started.push(backendTunnel);
|
|
3335
|
-
await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
|
|
3336
|
-
}
|
|
3337
|
-
|
|
3338
|
-
writeTunnelEnv(ws, tunnels);
|
|
3339
|
-
let uiEnv = uiPublicEnv(tunnels);
|
|
3340
|
-
const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
|
|
3341
|
-
|
|
3342
|
-
if (uiJob) {
|
|
3343
|
-
await progress(`Starting the app UI on port ${uiPort}…`);
|
|
3344
|
-
await ensureHostProcesses(ws, {
|
|
3345
|
-
reserved,
|
|
3346
|
-
cfg,
|
|
3347
|
-
onlyRoles: ["ui", "app"],
|
|
3348
|
-
extraEnv: uiEnv,
|
|
3349
|
-
force: true,
|
|
3350
|
-
plannedJobs: plan.jobs,
|
|
3351
|
-
});
|
|
3352
|
-
await waitUntilReachable(
|
|
3353
|
-
`http://127.0.0.1:${uiPort}`,
|
|
3354
|
-
60_000,
|
|
3355
|
-
"the app UI",
|
|
3356
|
-
progress
|
|
3357
|
-
);
|
|
3358
|
-
await progress(
|
|
3359
|
-
`Opening a Cloudflare tunnel for the app UI (http://127.0.0.1:${uiPort})…`
|
|
3360
|
-
);
|
|
3361
|
-
const uiTunnel = await startCloudflareTerminal(
|
|
3362
|
-
ws,
|
|
3363
|
-
"ui",
|
|
3364
|
-
`http://127.0.0.1:${uiPort}`,
|
|
3365
|
-
progress
|
|
3366
|
-
);
|
|
3367
|
-
tunnels.ui = uiTunnel.publicUrl;
|
|
3368
|
-
started.push(uiTunnel);
|
|
3369
|
-
await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
|
|
3370
|
-
writeTunnelEnv(ws, tunnels);
|
|
3371
|
-
uiEnv = uiPublicEnv(tunnels);
|
|
3372
|
-
|
|
3373
|
-
// Restart UI so Vite/Next pick up APP_URL + public AI URL from env files.
|
|
3374
|
-
await progress(
|
|
3375
|
-
"Restarting the app UI so it loads the public base URL from env…"
|
|
3376
|
-
);
|
|
3377
|
-
for (const job of plan.jobs) {
|
|
3378
|
-
if (job.role === "ui" || job.role === "app") {
|
|
3379
|
-
launchedAt.delete(`${sandboxId}:${folder}:${job.script}`);
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3382
|
-
await killPort(uiPort);
|
|
3383
|
-
await sleep(1500);
|
|
3384
|
-
await ensureHostProcesses(ws, {
|
|
3385
|
-
reserved,
|
|
3386
|
-
cfg,
|
|
3387
|
-
onlyRoles: ["ui", "app"],
|
|
3388
|
-
extraEnv: uiEnv,
|
|
3389
|
-
force: true,
|
|
3390
|
-
plannedJobs: plan.jobs,
|
|
3391
|
-
});
|
|
3392
|
-
await waitUntilReachable(
|
|
3393
|
-
`http://127.0.0.1:${uiPort}`,
|
|
3394
|
-
60_000,
|
|
3395
|
-
"the app UI",
|
|
3396
|
-
progress
|
|
3397
|
-
);
|
|
3398
|
-
}
|
|
3399
|
-
|
|
3400
|
-
// Restart chat script with the public AI URL (and CORS/app origins).
|
|
3401
|
-
if (tunnels.ai) {
|
|
3402
|
-
await progress("Restarting the chat script with the public AI URL…");
|
|
3403
|
-
launchedAt.delete(`${sandboxId}:${folder}:ai`);
|
|
3404
|
-
await killPort(ws.port);
|
|
3405
|
-
await sleep(1500);
|
|
3406
|
-
await startAiServerForWorkspace(ws, {
|
|
3407
|
-
reserved,
|
|
3408
|
-
cfg,
|
|
3409
|
-
port: ws.port,
|
|
3410
|
-
env: uiPublicEnv(tunnels),
|
|
3411
|
-
});
|
|
3412
|
-
writeTunnelEnv(ws, tunnels);
|
|
3413
|
-
await waitUntilReachable(
|
|
3414
|
-
`http://127.0.0.1:${ws.port}/embed-config.js`,
|
|
3415
|
-
45_000,
|
|
3416
|
-
"the chat script",
|
|
3417
|
-
progress
|
|
3418
|
-
);
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3421
|
-
const validation = await validateCloudflareGoLive(ws, tunnels, {
|
|
3422
|
-
onProgress: progress,
|
|
3423
|
-
});
|
|
3424
|
-
if (!validation.ok) {
|
|
3425
|
-
const failed = validation.checks
|
|
3426
|
-
.filter((check) => !check.ok)
|
|
3427
|
-
.map((check) => `${check.id}: ${check.detail}`)
|
|
3428
|
-
.join("\n");
|
|
3429
|
-
ws.cloudflarePending = true;
|
|
3430
|
-
ws.cloudflare = tunnels;
|
|
3431
|
-
ws.cloudflareUrl = null;
|
|
3432
|
-
persistWorkspaceEntry(cfg, ws);
|
|
3433
|
-
rememberCloudflareTunnels(sandboxId, tunnels);
|
|
3434
|
-
throw new Error(
|
|
3435
|
-
`Cloudflare validation failed — public URLs were not shared with Maintainer Pro yet.\n${failed}`
|
|
3436
|
-
);
|
|
3664
|
+
await stopCloudflare(sandboxId, ws.folderPath);
|
|
3665
|
+
} catch {
|
|
3666
|
+
/* leftover tunnels */
|
|
3437
3667
|
}
|
|
3438
|
-
|
|
3439
|
-
const appUrl = tunnels.ui || tunnels.ai;
|
|
3440
|
-
ws.cloudflareUrl = appUrl;
|
|
3441
|
-
ws.cloudflare = tunnels;
|
|
3442
|
-
ws.appUrl = appUrl;
|
|
3443
3668
|
ws.cloudflarePending = false;
|
|
3444
|
-
ws.appsRequested = true;
|
|
3445
3669
|
persistWorkspaceEntry(cfg, ws);
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
};
|
|
3465
|
-
} catch (err) {
|
|
3466
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3467
|
-
recordProcessProblem({
|
|
3468
|
-
sandboxId,
|
|
3469
|
-
code: "cloudflare_launch",
|
|
3470
|
-
role: "tunnel",
|
|
3471
|
-
title: `Could not start Cloudflare (${label})`,
|
|
3472
|
-
message,
|
|
3473
|
-
resolution:
|
|
3474
|
-
"Install cloudflared or allow npx to download it, then use Start Apps again.",
|
|
3475
|
-
actionCode: "start_ai_server",
|
|
3476
|
-
});
|
|
3477
|
-
throw err;
|
|
3478
|
-
}
|
|
3670
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3671
|
+
timeoutMs: 2500,
|
|
3672
|
+
});
|
|
3673
|
+
const share = proxyUrlForApp(
|
|
3674
|
+
ws,
|
|
3675
|
+
hostAppOf(ws) || { id: "ui", role: "ui", host: true }
|
|
3676
|
+
);
|
|
3677
|
+
return {
|
|
3678
|
+
sandboxId,
|
|
3679
|
+
folderPath: ws.folderPath,
|
|
3680
|
+
port: ws.port,
|
|
3681
|
+
pending: false,
|
|
3682
|
+
cloudflarePending: false,
|
|
3683
|
+
waitingForStart: false,
|
|
3684
|
+
appUrl: status.host.appUrl || share,
|
|
3685
|
+
origins: status.host.origins,
|
|
3686
|
+
publicUrl: share || null,
|
|
3687
|
+
cloudflare: false,
|
|
3688
|
+
};
|
|
3479
3689
|
}
|
|
3480
3690
|
|
|
3481
3691
|
async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
@@ -3483,12 +3693,17 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3483
3693
|
await reportActionProgress(
|
|
3484
3694
|
cfg,
|
|
3485
3695
|
opts.actionId,
|
|
3486
|
-
`Checking running apps
|
|
3696
|
+
`Checking running apps for ${label}…`
|
|
3487
3697
|
);
|
|
3488
3698
|
|
|
3489
|
-
|
|
3699
|
+
try {
|
|
3700
|
+
await stopCloudflare(ws.sandboxId, ws.folderPath);
|
|
3701
|
+
} catch {
|
|
3702
|
+
/* leftover cloudflared */
|
|
3703
|
+
}
|
|
3704
|
+
ws.cloudflarePending = false;
|
|
3705
|
+
|
|
3490
3706
|
let status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3491
|
-
writeEnv: true,
|
|
3492
3707
|
timeoutMs: 2500,
|
|
3493
3708
|
});
|
|
3494
3709
|
log(
|
|
@@ -3498,15 +3713,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3498
3713
|
status.probe.hosts
|
|
3499
3714
|
.map((h) => `${h.role}:${h.up ? "up" : "down"}`)
|
|
3500
3715
|
.join(",") || "none"
|
|
3501
|
-
}
|
|
3716
|
+
}`
|
|
3502
3717
|
);
|
|
3503
3718
|
|
|
3504
|
-
// Cloudflare tunnels only when Maintainer Pro left a pending Share signal.
|
|
3505
|
-
if (ws.cloudflarePending) {
|
|
3506
|
-
log(`start apps ${label}: Cloudflare pending, launching tunnels`);
|
|
3507
|
-
return launchCloudflareTunnels(ws, cfg, opts);
|
|
3508
|
-
}
|
|
3509
|
-
|
|
3510
3719
|
await reportActionProgress(
|
|
3511
3720
|
cfg,
|
|
3512
3721
|
opts.actionId,
|
|
@@ -3516,16 +3725,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3516
3725
|
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3517
3726
|
const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
|
|
3518
3727
|
|
|
3519
|
-
// Re-sync env after port assignment (local or cloudflare urls).
|
|
3520
3728
|
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3521
|
-
writeEnv: true,
|
|
3522
3729
|
timeoutMs: 800,
|
|
3523
3730
|
});
|
|
3524
3731
|
|
|
3525
|
-
const
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3732
|
+
const shareEnv = {
|
|
3733
|
+
...envForWorkspacePorts(ws, plan.jobs),
|
|
3734
|
+
...uiPublicEnv({}, ws),
|
|
3735
|
+
...envFromConfiguredMaps(ws),
|
|
3736
|
+
};
|
|
3529
3737
|
|
|
3530
3738
|
if (!cfg.noAiServer) {
|
|
3531
3739
|
if (status.probe.chatUp) {
|
|
@@ -3538,7 +3746,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3538
3746
|
reserved,
|
|
3539
3747
|
cfg,
|
|
3540
3748
|
port: plan.aiPort,
|
|
3541
|
-
env:
|
|
3749
|
+
env: shareEnv,
|
|
3542
3750
|
});
|
|
3543
3751
|
await sleep(1500);
|
|
3544
3752
|
}
|
|
@@ -3553,12 +3761,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3553
3761
|
const probed = status.probe.hosts.find((h) => h.role === job.role);
|
|
3554
3762
|
return probed?.up ? { ...job, up: true, port: probed.port } : job;
|
|
3555
3763
|
}),
|
|
3556
|
-
extraEnv:
|
|
3764
|
+
extraEnv: shareEnv,
|
|
3557
3765
|
});
|
|
3558
3766
|
await sleep(800);
|
|
3559
3767
|
|
|
3768
|
+
await applyPublicUrlsToRunningApps(ws, cfg, {
|
|
3769
|
+
onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
|
|
3770
|
+
});
|
|
3771
|
+
|
|
3560
3772
|
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3561
|
-
writeEnv: true,
|
|
3562
3773
|
timeoutMs: 2500,
|
|
3563
3774
|
});
|
|
3564
3775
|
await inspectHostJobs(ws);
|
|
@@ -3570,11 +3781,11 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3570
3781
|
log(
|
|
3571
3782
|
`start apps done ${label}: chat=${ws.port} chatUp=${
|
|
3572
3783
|
status.aiServerUp
|
|
3573
|
-
} hosts=${startedHosts.join(",") || "none"}
|
|
3574
|
-
status.
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
}
|
|
3784
|
+
} hosts=${startedHosts.join(",") || "none"}${
|
|
3785
|
+
status.host.appUrl ? ` app=${status.host.appUrl}` : ""
|
|
3786
|
+
} origins=${status.host.origins.join(",") || "none"}${
|
|
3787
|
+
warning ? ` warning=${warning}` : ""
|
|
3788
|
+
}`
|
|
3578
3789
|
);
|
|
3579
3790
|
return {
|
|
3580
3791
|
up: status.aiServerUp,
|
|
@@ -3584,12 +3795,196 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3584
3795
|
port: ws.port,
|
|
3585
3796
|
appUrl: status.host.appUrl || ws.appUrl,
|
|
3586
3797
|
origins: status.host.origins,
|
|
3587
|
-
|
|
3798
|
+
publicUrl:
|
|
3799
|
+
proxyUrlForApp(
|
|
3800
|
+
ws,
|
|
3801
|
+
hostAppOf(ws) || { id: "ui", role: "ui", host: true }
|
|
3802
|
+
) || null,
|
|
3803
|
+
cloudflare: false,
|
|
3804
|
+
hostApps: status.probe.hostApps || ws.hostApps || [],
|
|
3588
3805
|
processIssues,
|
|
3589
3806
|
warning,
|
|
3590
3807
|
};
|
|
3591
3808
|
}
|
|
3592
3809
|
|
|
3810
|
+
function findHostApp(ws, payload = {}) {
|
|
3811
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
3812
|
+
const id = String(payload.appId || payload.id || "").trim();
|
|
3813
|
+
const role = String(payload.role || "").trim();
|
|
3814
|
+
const port = Number(payload.port) || 0;
|
|
3815
|
+
return (
|
|
3816
|
+
apps.find((app) => id && app.id === id) ||
|
|
3817
|
+
apps.find((app) => role && app.role === role) ||
|
|
3818
|
+
apps.find((app) => port && Number(app.port) === port) ||
|
|
3819
|
+
null
|
|
3820
|
+
);
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
|
|
3824
|
+
const app = findHostApp(ws, payload);
|
|
3825
|
+
if (!app) {
|
|
3826
|
+
activity(ws.sandboxId, "error", "Start app failed: unknown app/port");
|
|
3827
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3828
|
+
}
|
|
3829
|
+
const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
|
|
3830
|
+
const alreadyUp = await portIsLive(app.port, 1200);
|
|
3831
|
+
if (alreadyUp) {
|
|
3832
|
+
activity(ws.sandboxId, "info", `${app.name} already running on ${app.port}`);
|
|
3833
|
+
await progress(`${app.name} is already running on port ${app.port}.`);
|
|
3834
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3835
|
+
timeoutMs: 1200,
|
|
3836
|
+
});
|
|
3837
|
+
return {
|
|
3838
|
+
app,
|
|
3839
|
+
running: true,
|
|
3840
|
+
port: app.port,
|
|
3841
|
+
hostApps: status.probe.hostApps || [],
|
|
3842
|
+
log: activityLogFor(ws.sandboxId),
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3845
|
+
activity(ws.sandboxId, "info", `Start ${app.name} on port ${app.port}`);
|
|
3846
|
+
await progress(`Starting ${app.name} on port ${app.port}…`);
|
|
3847
|
+
ws.appsRequested = true;
|
|
3848
|
+
persistWorkspaceEntry(cfg, ws);
|
|
3849
|
+
const publicEnv = {
|
|
3850
|
+
...cloudflarePublicEnv(ws),
|
|
3851
|
+
};
|
|
3852
|
+
if (app.role === "ai-server") {
|
|
3853
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3854
|
+
const chat = await discoverChatPort(ws, 1200);
|
|
3855
|
+
if (chat.up) {
|
|
3856
|
+
activity(ws.sandboxId, "info", `AI server already running on ${chat.port}`);
|
|
3857
|
+
ws.port = chat.port;
|
|
3858
|
+
} else {
|
|
3859
|
+
await startAiServerForWorkspace(ws, {
|
|
3860
|
+
reserved,
|
|
3861
|
+
cfg,
|
|
3862
|
+
port: Number(app.port) || ws.port || 3100,
|
|
3863
|
+
env: publicEnv,
|
|
3864
|
+
});
|
|
3865
|
+
}
|
|
3866
|
+
} else {
|
|
3867
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3868
|
+
await ensureHostProcesses(ws, {
|
|
3869
|
+
reserved,
|
|
3870
|
+
cfg,
|
|
3871
|
+
onlyRoles: [app.role === "custom" ? "app" : app.role],
|
|
3872
|
+
extraEnv: publicEnv,
|
|
3873
|
+
force: true,
|
|
3874
|
+
plannedJobs: jobsFromHostApps(ws),
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3878
|
+
timeoutMs: 2500,
|
|
3879
|
+
});
|
|
3880
|
+
const live = (status.probe.hostApps || []).find((row) => row.id === app.id);
|
|
3881
|
+
activity(
|
|
3882
|
+
ws.sandboxId,
|
|
3883
|
+
live?.running ? "info" : "warn",
|
|
3884
|
+
live?.running
|
|
3885
|
+
? `${app.name} is running on ${live.port}`
|
|
3886
|
+
: `${app.name} did not answer on port ${app.port}. Check the MP terminal.`
|
|
3887
|
+
);
|
|
3888
|
+
return {
|
|
3889
|
+
app,
|
|
3890
|
+
running: Boolean(live?.running),
|
|
3891
|
+
port: live?.port || app.port,
|
|
3892
|
+
hostApps: status.probe.hostApps || [],
|
|
3893
|
+
log: activityLogFor(ws.sandboxId),
|
|
3894
|
+
warning: live?.running
|
|
3895
|
+
? null
|
|
3896
|
+
: `${app.name} did not answer on port ${app.port}. Check the terminal on that computer.`,
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
async function stopSingleApp(ws, app) {
|
|
3901
|
+
const port = Number(app.port) || 0;
|
|
3902
|
+
const role =
|
|
3903
|
+
app.role === "ai-server"
|
|
3904
|
+
? "ai-server"
|
|
3905
|
+
: app.role === "custom"
|
|
3906
|
+
? "app"
|
|
3907
|
+
: app.role || "app";
|
|
3908
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
3909
|
+
await closeWindowsByTitle(`MP-${role}-${port}`);
|
|
3910
|
+
if (role === "app") await closeWindowsByTitle(`MP-custom-${port}`);
|
|
3911
|
+
if (app.role === "ai-server" || app.id === "ai-server") {
|
|
3912
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3913
|
+
}
|
|
3914
|
+
if (port) {
|
|
3915
|
+
await killPort(port);
|
|
3916
|
+
const until = Date.now() + 4000;
|
|
3917
|
+
while (Date.now() < until && (await portIsLive(port, 300))) {
|
|
3918
|
+
await killPort(port);
|
|
3919
|
+
await sleep(300);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
const job = jobsFromHostApps(ws).find((row) => row.appId === app.id);
|
|
3923
|
+
if (job) launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
|
|
3924
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
|
|
3925
|
+
await sleep(800);
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
|
|
3929
|
+
const app = findHostApp(ws, payload);
|
|
3930
|
+
if (!app) {
|
|
3931
|
+
activity(ws.sandboxId, "error", "Restart app failed: unknown app/port");
|
|
3932
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3933
|
+
}
|
|
3934
|
+
const progress = (message) =>
|
|
3935
|
+
reportActionProgress(cfg, opts.actionId, message);
|
|
3936
|
+
activity(ws.sandboxId, "info", `Restart ${app.name} on ${app.port}`);
|
|
3937
|
+
await progress(`Stopping ${app.name} on port ${app.port}…`);
|
|
3938
|
+
await stopSingleApp(ws, app);
|
|
3939
|
+
return startSingleApp(ws, cfg, payload, opts);
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
|
|
3943
|
+
const app = findHostApp(ws, payload);
|
|
3944
|
+
if (!app) {
|
|
3945
|
+
activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
|
|
3946
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3947
|
+
}
|
|
3948
|
+
const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
|
|
3949
|
+
if (usesBridgeProxy(app)) {
|
|
3950
|
+
const shareUrl = proxyUrlForApp(ws, app);
|
|
3951
|
+
if (!shareUrl) {
|
|
3952
|
+
return {
|
|
3953
|
+
error:
|
|
3954
|
+
"Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
|
|
3958
|
+
const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
|
|
3959
|
+
if (started.error) return started;
|
|
3960
|
+
const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
|
|
3961
|
+
onProgress: progress,
|
|
3962
|
+
});
|
|
3963
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3964
|
+
timeoutMs: 2500,
|
|
3965
|
+
});
|
|
3966
|
+
activity(ws.sandboxId, "info", `${app.name} share URL: ${shareUrl}`);
|
|
3967
|
+
return {
|
|
3968
|
+
app,
|
|
3969
|
+
running: Boolean(started.running),
|
|
3970
|
+
port: started.port,
|
|
3971
|
+
publicUrl: shareUrl,
|
|
3972
|
+
cloudflareUrl: null,
|
|
3973
|
+
appUrl: status.host.appUrl,
|
|
3974
|
+
origins: status.host.origins,
|
|
3975
|
+
hostApps: status.probe.hostApps || [],
|
|
3976
|
+
rewritten: applied.rewritten,
|
|
3977
|
+
restarted: applied.restarted,
|
|
3978
|
+
log: activityLogFor(ws.sandboxId),
|
|
3979
|
+
};
|
|
3980
|
+
}
|
|
3981
|
+
return {
|
|
3982
|
+
error:
|
|
3983
|
+
"Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
|
|
3984
|
+
app,
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3593
3988
|
async function ensureHostProcesses(ws, opts = {}) {
|
|
3594
3989
|
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
3595
3990
|
const cfg = opts.cfg || null;
|
|
@@ -3708,7 +4103,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
3708
4103
|
continue;
|
|
3709
4104
|
}
|
|
3710
4105
|
started.push(job.role);
|
|
3711
|
-
log(
|
|
4106
|
+
log(
|
|
4107
|
+
`start ${job.role} launched ${label} on ${port}: ${command}${
|
|
4108
|
+
Object.keys(extraEnv).length
|
|
4109
|
+
? ` env=${Object.keys(extraEnv).join(",")}`
|
|
4110
|
+
: ""
|
|
4111
|
+
}`
|
|
4112
|
+
);
|
|
3712
4113
|
if (
|
|
3713
4114
|
(job.role === "ui" || job.role === "app") &&
|
|
3714
4115
|
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
@@ -3724,11 +4125,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
3724
4125
|
|
|
3725
4126
|
async function inspectHostJobs(ws) {
|
|
3726
4127
|
const folder = path.resolve(ws.folderPath || "");
|
|
3727
|
-
const jobs =
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
4128
|
+
const jobs = jobsFromHostApps(ws).length
|
|
4129
|
+
? jobsFromHostApps(ws)
|
|
4130
|
+
: planHostJobs(
|
|
4131
|
+
folder,
|
|
4132
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
4133
|
+
ws.projectInfo
|
|
4134
|
+
);
|
|
3732
4135
|
const label = ws.sandboxName || "this sandbox";
|
|
3733
4136
|
const hosts = [];
|
|
3734
4137
|
for (const job of jobs) {
|
|
@@ -3788,7 +4191,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
3788
4191
|
}
|
|
3789
4192
|
}
|
|
3790
4193
|
let port = requestedPort;
|
|
3791
|
-
if (await
|
|
4194
|
+
if (await isChatServerOnPort(requestedPort)) {
|
|
3792
4195
|
reserved.add(requestedPort);
|
|
3793
4196
|
log(`setup chat port ${requestedPort} already up`);
|
|
3794
4197
|
} else {
|
|
@@ -3810,6 +4213,12 @@ async function setupWorkspace(cfg, action) {
|
|
|
3810
4213
|
throw new Error("setup_workspace requires folderPath and sandboxId");
|
|
3811
4214
|
}
|
|
3812
4215
|
const resolved = path.resolve(folderPath);
|
|
4216
|
+
const allowed = collectOfferedFolders();
|
|
4217
|
+
if (!allowed.some((root) => pathInside(resolved, root))) {
|
|
4218
|
+
throw new Error(
|
|
4219
|
+
"That folder is outside the directory where the bridge is running. Start the bridge from the project folder you want to share."
|
|
4220
|
+
);
|
|
4221
|
+
}
|
|
3813
4222
|
fs.mkdirSync(resolved, { recursive: true });
|
|
3814
4223
|
|
|
3815
4224
|
const config = await api(
|
|
@@ -3836,30 +4245,10 @@ async function setupWorkspace(cfg, action) {
|
|
|
3836
4245
|
const corsOrigin = client.corsOrigin || aiOrigin;
|
|
3837
4246
|
const appUrl = client.appUrl || corsOrigin;
|
|
3838
4247
|
|
|
3839
|
-
const envPath = path.join(resolved, ".env");
|
|
3840
4248
|
const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
|
|
3841
4249
|
? config.aiIgnorePaths
|
|
3842
4250
|
: [];
|
|
3843
|
-
const
|
|
3844
|
-
...config.env,
|
|
3845
|
-
AI_CLI_WORKSPACE: ".",
|
|
3846
|
-
AI_CLI_IGNORE_PATHS: JSON.stringify(
|
|
3847
|
-
normalizeIgnorePaths(partnerIgnorePaths)
|
|
3848
|
-
),
|
|
3849
|
-
AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
|
|
3850
|
-
AI_SERVER_PORT: String(port),
|
|
3851
|
-
AI_SERVER_URL: aiOrigin,
|
|
3852
|
-
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
3853
|
-
CORS_ORIGIN: corsOrigin,
|
|
3854
|
-
APP_URL: appUrl,
|
|
3855
|
-
AI_SERVER_PRODUCT_DESCRIPTION: appName,
|
|
3856
|
-
};
|
|
3857
|
-
mergeEnvFile(envPath, envValues, { remove: ["PORT"] });
|
|
3858
|
-
const envLocal = path.join(resolved, ".env.local");
|
|
3859
|
-
if (fs.existsSync(envLocal)) {
|
|
3860
|
-
mergeEnvFile(envLocal, {}, { remove: ["PORT"] });
|
|
3861
|
-
}
|
|
3862
|
-
const access = applyAccessPolicy(resolved, partnerIgnorePaths);
|
|
4251
|
+
const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
|
|
3863
4252
|
|
|
3864
4253
|
cfg.workspaces = cfg.workspaces || [];
|
|
3865
4254
|
const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
|
|
@@ -3874,36 +4263,42 @@ async function setupWorkspace(cfg, action) {
|
|
|
3874
4263
|
sameOrigin: Boolean(client.sameOrigin),
|
|
3875
4264
|
appsRequested: false,
|
|
3876
4265
|
cloudflarePending: false,
|
|
4266
|
+
store: {
|
|
4267
|
+
serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
|
|
4268
|
+
clientKey: String(
|
|
4269
|
+
config.env?.MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
4270
|
+
config.env?.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
4271
|
+
""
|
|
4272
|
+
).trim(),
|
|
4273
|
+
},
|
|
3877
4274
|
};
|
|
3878
4275
|
if (existing >= 0) cfg.workspaces[existing] = entry;
|
|
3879
4276
|
else cfg.workspaces.push(entry);
|
|
3880
|
-
if (!cfg.offeredFolders) cfg.offeredFolders = [];
|
|
3881
|
-
if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
|
|
3882
4277
|
saveConfig(cfg);
|
|
3883
4278
|
|
|
3884
|
-
const
|
|
4279
|
+
const ports = await resolveWorkspaceHostApps(entry, {
|
|
3885
4280
|
cfg,
|
|
3886
|
-
|
|
3887
|
-
`Scaffold kind: ${client.kind}`,
|
|
3888
|
-
`Chat script port: ${port}`,
|
|
3889
|
-
client.notes.join(" "),
|
|
3890
|
-
]
|
|
3891
|
-
.filter(Boolean)
|
|
3892
|
-
.join("\n"),
|
|
4281
|
+
allowAi: false,
|
|
3893
4282
|
});
|
|
4283
|
+
const needsReview =
|
|
4284
|
+
Boolean(ports.confused) ||
|
|
4285
|
+
!(ports.apps || []).some((app) => app && app.role !== "ai-server");
|
|
4286
|
+
if (needsReview) {
|
|
4287
|
+
activity(
|
|
4288
|
+
sandboxId,
|
|
4289
|
+
"warn",
|
|
4290
|
+
"Review the suggested apps and ports in Maintainer Pro before Start Apps."
|
|
4291
|
+
);
|
|
4292
|
+
}
|
|
4293
|
+
// Do not auto-repair the repo on attach — interactive Review setup handles suggestions.
|
|
4294
|
+
const projectInfo = entry.projectInfo || null;
|
|
3894
4295
|
|
|
3895
4296
|
await inspectHostJobs(entry);
|
|
3896
|
-
const planned = planHostJobs(
|
|
3897
|
-
resolved,
|
|
3898
|
-
entry.appUrl && isLocalAppUrl(entry.appUrl) ? entry.appUrl : null,
|
|
3899
|
-
entry.projectInfo
|
|
3900
|
-
).map((job) => ({ ...job, port: job.preferredPort }));
|
|
3901
|
-
writeProjectEnv(resolved, envForWorkspacePorts(entry, planned));
|
|
3902
4297
|
|
|
3903
4298
|
const openUrl = client.sameOrigin
|
|
3904
4299
|
? `http://localhost:${entry.port}`
|
|
3905
4300
|
: entry.appUrl || appUrl;
|
|
3906
|
-
const aiServerUp = await
|
|
4301
|
+
const aiServerUp = await isChatServerOnPort(entry.port);
|
|
3907
4302
|
if (aiServerUp) {
|
|
3908
4303
|
clearProcessProblem(sandboxId, "ai_server_launch", "ai");
|
|
3909
4304
|
clearProcessProblem(sandboxId, "apps_not_started");
|
|
@@ -3914,7 +4309,9 @@ async function setupWorkspace(cfg, action) {
|
|
|
3914
4309
|
);
|
|
3915
4310
|
const waitingForStart = !aiServerUp;
|
|
3916
4311
|
const warning = waitingForStart
|
|
3917
|
-
?
|
|
4312
|
+
? needsReview
|
|
4313
|
+
? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
|
|
4314
|
+
: "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
|
|
3918
4315
|
: processIssues[0]?.message || null;
|
|
3919
4316
|
|
|
3920
4317
|
for (const note of client.notes) log(`setup note ${note}`);
|
|
@@ -3933,7 +4330,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
3933
4330
|
origins: host.origins.length
|
|
3934
4331
|
? host.origins
|
|
3935
4332
|
: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
|
|
3936
|
-
wroteEnv:
|
|
4333
|
+
wroteEnv: false,
|
|
3937
4334
|
clientKind: client.kind,
|
|
3938
4335
|
clientFiles: client.filesWritten,
|
|
3939
4336
|
clientNotes: client.notes,
|
|
@@ -3943,11 +4340,98 @@ async function setupWorkspace(cfg, action) {
|
|
|
3943
4340
|
processIssues,
|
|
3944
4341
|
warning,
|
|
3945
4342
|
waitingForStart,
|
|
4343
|
+
needsReview,
|
|
4344
|
+
hostApps: ports.apps || entry.hostApps || [],
|
|
4345
|
+
reasons: ports.reasons || [],
|
|
3946
4346
|
projectInfo,
|
|
3947
4347
|
ignorePaths: access.ignorePaths,
|
|
3948
4348
|
};
|
|
3949
4349
|
}
|
|
3950
4350
|
|
|
4351
|
+
async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
|
|
4352
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
4353
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
4354
|
+
activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
|
|
4355
|
+
try {
|
|
4356
|
+
const cli = await loadAiCli();
|
|
4357
|
+
if (typeof cli.proposeHostAppsFromConfig !== "function") {
|
|
4358
|
+
throw new Error(
|
|
4359
|
+
"ai-cli is missing proposeHostAppsFromConfig — update @maintainer-pro/ai-cli"
|
|
4360
|
+
);
|
|
4361
|
+
}
|
|
4362
|
+
const proposal = await cli.proposeHostAppsFromConfig({
|
|
4363
|
+
workspaceDir: folder,
|
|
4364
|
+
appName: ws.applicationName || ws.sandboxName,
|
|
4365
|
+
preferredAiPort: Number(ws.port) || 3100,
|
|
4366
|
+
allowAi: opts.allowAi !== false,
|
|
4367
|
+
});
|
|
4368
|
+
const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
4369
|
+
const apps = (proposal.apps || []).map((app) => {
|
|
4370
|
+
const match = previous.find((row) => row && row.id === app.id);
|
|
4371
|
+
if (!match || (app.envMaps && app.envMaps.length)) return app;
|
|
4372
|
+
return { ...app, envMaps: match.envMaps || [] };
|
|
4373
|
+
});
|
|
4374
|
+
// Keep last known apps on the workspace; do not auto-apply proposal.
|
|
4375
|
+
if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
|
|
4376
|
+
ws.hostApps = apps;
|
|
4377
|
+
persistWorkspaceEntry(cfg, ws);
|
|
4378
|
+
}
|
|
4379
|
+
activity(
|
|
4380
|
+
ws.sandboxId,
|
|
4381
|
+
proposal.needsReview ? "warn" : "info",
|
|
4382
|
+
`setup suggestion ${proposal.confidence}${proposal.usedAi ? " + ai" : ""}: ${
|
|
4383
|
+
proposal.projectSummary || apps.map((a) => `${a.name}:${a.port}`).join(", ")
|
|
4384
|
+
}`
|
|
4385
|
+
);
|
|
4386
|
+
for (const reason of proposal.reasons || []) {
|
|
4387
|
+
activity(ws.sandboxId, "warn", reason);
|
|
4388
|
+
}
|
|
4389
|
+
return {
|
|
4390
|
+
proposal: {
|
|
4391
|
+
apps,
|
|
4392
|
+
alternatives: proposal.alternatives || [],
|
|
4393
|
+
reasons: proposal.reasons || [],
|
|
4394
|
+
confidence: proposal.confidence,
|
|
4395
|
+
projectSummary: proposal.projectSummary || "",
|
|
4396
|
+
usedAi: Boolean(proposal.usedAi),
|
|
4397
|
+
needsReview: Boolean(proposal.needsReview),
|
|
4398
|
+
fingerprint: proposal.fingerprint || null,
|
|
4399
|
+
},
|
|
4400
|
+
hostApps: apps,
|
|
4401
|
+
reasons: proposal.reasons || [],
|
|
4402
|
+
usedAi: Boolean(proposal.usedAi),
|
|
4403
|
+
needsReview: Boolean(proposal.needsReview),
|
|
4404
|
+
log: activityLogFor(ws.sandboxId),
|
|
4405
|
+
};
|
|
4406
|
+
} catch (err) {
|
|
4407
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4408
|
+
activity(ws.sandboxId, "warn", `setup suggestion failed: ${message}`);
|
|
4409
|
+
const fallback = await resolveWorkspaceHostApps(ws, {
|
|
4410
|
+
cfg,
|
|
4411
|
+
force: true,
|
|
4412
|
+
ignoreDesired: true,
|
|
4413
|
+
allowAi: false,
|
|
4414
|
+
});
|
|
4415
|
+
return {
|
|
4416
|
+
proposal: {
|
|
4417
|
+
apps: fallback.apps,
|
|
4418
|
+
alternatives: [],
|
|
4419
|
+
reasons: [...(fallback.reasons || []), message],
|
|
4420
|
+
confidence: "low",
|
|
4421
|
+
projectSummary: "Could not finish AI suggest — showing file detection.",
|
|
4422
|
+
usedAi: false,
|
|
4423
|
+
needsReview: true,
|
|
4424
|
+
fingerprint: fallback.fingerprint || null,
|
|
4425
|
+
},
|
|
4426
|
+
hostApps: fallback.apps,
|
|
4427
|
+
reasons: fallback.reasons || [],
|
|
4428
|
+
usedAi: false,
|
|
4429
|
+
needsReview: true,
|
|
4430
|
+
log: activityLogFor(ws.sandboxId),
|
|
4431
|
+
};
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
3951
4435
|
async function runActions(cfg, actions) {
|
|
3952
4436
|
if (!actions.length) return;
|
|
3953
4437
|
log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
|
|
@@ -3960,9 +4444,11 @@ async function runActions(cfg, actions) {
|
|
|
3960
4444
|
let result = {};
|
|
3961
4445
|
try {
|
|
3962
4446
|
if (action.code === "browse") {
|
|
3963
|
-
const
|
|
4447
|
+
const allowed = collectOfferedFolders();
|
|
4448
|
+
const p = String(action.payload?.path || "roots");
|
|
3964
4449
|
log(`${label} browse ${p}`);
|
|
3965
|
-
result = listDirEntries(p);
|
|
4450
|
+
result = listDirEntries(p, allowed);
|
|
4451
|
+
if (result.error) ok = false;
|
|
3966
4452
|
log(`${label} browse ${result.entries?.length ?? 0} entries`);
|
|
3967
4453
|
} else if (action.code === "setup_workspace") {
|
|
3968
4454
|
result = await setupWorkspace(cfg, action);
|
|
@@ -3984,7 +4470,11 @@ async function runActions(cfg, actions) {
|
|
|
3984
4470
|
const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
|
|
3985
4471
|
? action.payload.aiIgnorePaths
|
|
3986
4472
|
: [];
|
|
3987
|
-
const access = applyAccessPolicy(
|
|
4473
|
+
const access = applyAccessPolicy(
|
|
4474
|
+
ws.folderPath,
|
|
4475
|
+
partnerIgnorePaths,
|
|
4476
|
+
ws.sandboxId
|
|
4477
|
+
);
|
|
3988
4478
|
log(
|
|
3989
4479
|
`access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
|
|
3990
4480
|
);
|
|
@@ -4000,14 +4490,96 @@ async function runActions(cfg, actions) {
|
|
|
4000
4490
|
if (!ws) {
|
|
4001
4491
|
log(`${label} recheck: no local workspace`);
|
|
4002
4492
|
}
|
|
4003
|
-
const
|
|
4004
|
-
? await
|
|
4493
|
+
const hostApps = ws
|
|
4494
|
+
? await resolveWorkspaceHostApps(ws, { cfg, force: true, allowAi: false })
|
|
4005
4495
|
: null;
|
|
4006
4496
|
result = {
|
|
4007
4497
|
recheckedAt: new Date().toISOString(),
|
|
4008
|
-
|
|
4498
|
+
hostApps: hostApps?.apps || [],
|
|
4499
|
+
projectInfo: ws?.projectInfo || null,
|
|
4009
4500
|
};
|
|
4010
|
-
} else if (action.code === "
|
|
4501
|
+
} else if (action.code === "redetect_ports" || action.code === "propose_setup") {
|
|
4502
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4503
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4504
|
+
if (!ws) {
|
|
4505
|
+
ok = false;
|
|
4506
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4507
|
+
} else {
|
|
4508
|
+
activity(
|
|
4509
|
+
ws.sandboxId,
|
|
4510
|
+
"info",
|
|
4511
|
+
action.code === "propose_setup"
|
|
4512
|
+
? "Setup suggestions requested from Maintainer Pro"
|
|
4513
|
+
: "Detect ports requested from Maintainer Pro"
|
|
4514
|
+
);
|
|
4515
|
+
result = await proposeSetupForWorkspace(ws, cfg, {
|
|
4516
|
+
allowAi: action.payload?.allowAi !== false,
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
} else if (action.code === "update_host_apps") {
|
|
4520
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4521
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4522
|
+
if (!ws) {
|
|
4523
|
+
ok = false;
|
|
4524
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4525
|
+
} else {
|
|
4526
|
+
const desired = Array.isArray(action.payload?.hostApps)
|
|
4527
|
+
? action.payload.hostApps
|
|
4528
|
+
: [];
|
|
4529
|
+
activity(
|
|
4530
|
+
ws.sandboxId,
|
|
4531
|
+
"info",
|
|
4532
|
+
`Applying ports from Maintainer Pro: ${desired
|
|
4533
|
+
.map((app) => `${app.name || app.id}:${app.port}`)
|
|
4534
|
+
.join(", ")}`
|
|
4535
|
+
);
|
|
4536
|
+
const resolved = await resolveWorkspaceHostApps(ws, {
|
|
4537
|
+
cfg,
|
|
4538
|
+
desired,
|
|
4539
|
+
allowAi: false,
|
|
4540
|
+
});
|
|
4541
|
+
result = { hostApps: resolved.apps, log: activityLogFor(ws.sandboxId) };
|
|
4542
|
+
}
|
|
4543
|
+
} else if (action.code === "start_app") {
|
|
4544
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4545
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4546
|
+
if (!ws) {
|
|
4547
|
+
ok = false;
|
|
4548
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4549
|
+
} else {
|
|
4550
|
+
result = await startSingleApp(ws, cfg, action.payload || {}, {
|
|
4551
|
+
actionId: action.id,
|
|
4552
|
+
});
|
|
4553
|
+
if (result.error) ok = false;
|
|
4554
|
+
}
|
|
4555
|
+
} else if (action.code === "restart_app") {
|
|
4556
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4557
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4558
|
+
if (!ws) {
|
|
4559
|
+
ok = false;
|
|
4560
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4561
|
+
} else {
|
|
4562
|
+
result = await restartSingleApp(ws, cfg, action.payload || {}, {
|
|
4563
|
+
actionId: action.id,
|
|
4564
|
+
});
|
|
4565
|
+
if (result.error) ok = false;
|
|
4566
|
+
}
|
|
4567
|
+
} else if (action.code === "start_cloudflare_app") {
|
|
4568
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4569
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4570
|
+
if (!ws) {
|
|
4571
|
+
ok = false;
|
|
4572
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4573
|
+
} else {
|
|
4574
|
+
result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
|
|
4575
|
+
actionId: action.id,
|
|
4576
|
+
});
|
|
4577
|
+
if (result.error) ok = false;
|
|
4578
|
+
}
|
|
4579
|
+
} else if (
|
|
4580
|
+
action.code === "start_ai_server" ||
|
|
4581
|
+
action.code === "restart_apps"
|
|
4582
|
+
) {
|
|
4011
4583
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4012
4584
|
const ws =
|
|
4013
4585
|
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
@@ -4023,10 +4595,24 @@ async function runActions(cfg, actions) {
|
|
|
4023
4595
|
result = { error: "No workspace or --no-ai-server" };
|
|
4024
4596
|
warn(`${label} skipped: ${result.error}`);
|
|
4025
4597
|
} else {
|
|
4598
|
+
const restarting = action.code === "restart_apps";
|
|
4599
|
+
log(
|
|
4600
|
+
`${label} ${restarting ? "restart" : "start"} ${ws.folderPath}`
|
|
4601
|
+
);
|
|
4602
|
+
if (restarting) {
|
|
4603
|
+
await reportActionProgress(
|
|
4604
|
+
cfg,
|
|
4605
|
+
action.id,
|
|
4606
|
+
`Stopping apps for ${ws.sandboxName || shortId(ws.sandboxId)}…`
|
|
4607
|
+
);
|
|
4608
|
+
await stopWorkspaceApps(ws);
|
|
4609
|
+
}
|
|
4026
4610
|
// Prefer cached project inspect; only call ai-cli when missing or
|
|
4027
4611
|
// after a failed start that used the cache.
|
|
4028
|
-
|
|
4029
|
-
|
|
4612
|
+
let projectInfo = await resolveWorkspaceHostApps(ws, {
|
|
4613
|
+
cfg,
|
|
4614
|
+
allowAi: false,
|
|
4615
|
+
});
|
|
4030
4616
|
let started = await startAppsForWorkspace(ws, cfg, {
|
|
4031
4617
|
actionId: action.id,
|
|
4032
4618
|
});
|
|
@@ -4182,7 +4768,6 @@ async function collectWorkspaceStates(cfg) {
|
|
|
4182
4768
|
for (const ws of cfg.workspaces || []) {
|
|
4183
4769
|
const folder = path.resolve(ws.folderPath || "");
|
|
4184
4770
|
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
4185
|
-
writeEnv: true,
|
|
4186
4771
|
timeoutMs: 800,
|
|
4187
4772
|
});
|
|
4188
4773
|
localStates.push({
|
|
@@ -4194,14 +4779,13 @@ async function collectWorkspaceStates(cfg) {
|
|
|
4194
4779
|
appsRunning: status.appsRunning,
|
|
4195
4780
|
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
4196
4781
|
appUrl:
|
|
4197
|
-
status.appsRunning
|
|
4782
|
+
status.appsRunning
|
|
4198
4783
|
? status.host.appUrl || ws.appUrl || null
|
|
4199
4784
|
: null,
|
|
4200
|
-
origins:
|
|
4201
|
-
status.appsRunning || status.usingCloudflare
|
|
4202
|
-
? status.host.origins
|
|
4203
|
-
: [],
|
|
4785
|
+
origins: status.appsRunning ? status.host.origins : [],
|
|
4204
4786
|
appsRequested: appsWanted(ws) || status.appsRunning,
|
|
4787
|
+
hostApps: status.probe.hostApps || ws.hostApps || [],
|
|
4788
|
+
activityLog: activityLogFor(ws.sandboxId),
|
|
4205
4789
|
});
|
|
4206
4790
|
}
|
|
4207
4791
|
return localStates;
|
|
@@ -4213,24 +4797,36 @@ const lastHostReports = new Map();
|
|
|
4213
4797
|
function syncAssignedWorkspaces(cfg, remotes) {
|
|
4214
4798
|
if (!Array.isArray(remotes)) return;
|
|
4215
4799
|
for (const remote of remotes) {
|
|
4216
|
-
|
|
4800
|
+
let local = (cfg.workspaces || []).find(
|
|
4217
4801
|
(w) => w.sandboxId === remote.sandboxId
|
|
4218
4802
|
);
|
|
4219
4803
|
if (!local) {
|
|
4220
4804
|
cfg.workspaces = cfg.workspaces || [];
|
|
4221
|
-
|
|
4805
|
+
local = {
|
|
4222
4806
|
sandboxId: remote.sandboxId,
|
|
4223
4807
|
folderPath: remote.folderPath,
|
|
4224
4808
|
port: remote.port,
|
|
4225
4809
|
sandboxName: remote.sandboxName,
|
|
4226
4810
|
applicationName: remote.applicationName,
|
|
4227
|
-
}
|
|
4811
|
+
};
|
|
4812
|
+
cfg.workspaces.push(local);
|
|
4228
4813
|
saveConfig(cfg);
|
|
4229
4814
|
} else if (local.folderPath !== remote.folderPath) {
|
|
4230
4815
|
local.folderPath = remote.folderPath;
|
|
4231
4816
|
local.port = remote.port;
|
|
4232
4817
|
saveConfig(cfg);
|
|
4233
4818
|
}
|
|
4819
|
+
if (Array.isArray(remote.hostApps) && remote.hostApps.length) {
|
|
4820
|
+
const localCount = Array.isArray(local.hostApps) ? local.hostApps.length : 0;
|
|
4821
|
+
if (remote.hostApps.length >= localCount) {
|
|
4822
|
+
local.hostApps = remote.hostApps;
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
const prevToken = local.proxy?.token;
|
|
4826
|
+
applyAssignedProxy(local, remote, cfg);
|
|
4827
|
+
if (local.proxy?.token && local.proxy.token !== prevToken) {
|
|
4828
|
+
persistWorkspaceEntry(cfg, local);
|
|
4829
|
+
}
|
|
4234
4830
|
}
|
|
4235
4831
|
}
|
|
4236
4832
|
|
|
@@ -4263,6 +4859,8 @@ async function sendHeartbeat(cfg, folders, localStates) {
|
|
|
4263
4859
|
? st.origins
|
|
4264
4860
|
: undefined,
|
|
4265
4861
|
appsRequested: Boolean(st.appsRequested),
|
|
4862
|
+
hostApps: st.hostApps || [],
|
|
4863
|
+
activityLog: st.activityLog || [],
|
|
4266
4864
|
})),
|
|
4267
4865
|
};
|
|
4268
4866
|
return api(
|
|
@@ -4314,6 +4912,8 @@ async function buildHeartbeatPayload(cfg, folders, localStates) {
|
|
|
4314
4912
|
? st.origins
|
|
4315
4913
|
: undefined,
|
|
4316
4914
|
appsRequested: Boolean(st.appsRequested),
|
|
4915
|
+
hostApps: st.hostApps || [],
|
|
4916
|
+
activityLog: st.activityLog || [],
|
|
4317
4917
|
})),
|
|
4318
4918
|
};
|
|
4319
4919
|
}
|
|
@@ -4464,32 +5064,24 @@ async function main() {
|
|
|
4464
5064
|
{ logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
|
|
4465
5065
|
"bridge starting"
|
|
4466
5066
|
);
|
|
5067
|
+
void warnIfBridgeOutdated();
|
|
4467
5068
|
|
|
4468
5069
|
let cfg = loadConfig() || {};
|
|
4469
5070
|
ensureMachineId(cfg);
|
|
4470
5071
|
|
|
4471
|
-
if (typeof args.offerFolder === "string") {
|
|
4472
|
-
cfg.offeredFolders = cfg.offeredFolders || [];
|
|
4473
|
-
const resolved = path.resolve(args.offerFolder);
|
|
4474
|
-
if (!cfg.offeredFolders.includes(resolved)) {
|
|
4475
|
-
cfg.offeredFolders.push(resolved);
|
|
4476
|
-
saveConfig(cfg);
|
|
4477
|
-
log(`offering folder ${resolved}`);
|
|
4478
|
-
}
|
|
4479
|
-
}
|
|
4480
|
-
|
|
4481
5072
|
if (args.pair || !cfg.token || !cfg.adminUrl) {
|
|
4482
5073
|
cfg = await pairFlow(args);
|
|
4483
5074
|
}
|
|
4484
5075
|
|
|
4485
5076
|
cfg.noAiServer = Boolean(args.noAiServer);
|
|
4486
5077
|
saveConfig(cfg);
|
|
5078
|
+
bridgeCfg = cfg;
|
|
4487
5079
|
|
|
4488
5080
|
log(`machine ${cfg.machineId}`);
|
|
4489
5081
|
log(`admin ${cfg.adminUrl}`);
|
|
4490
5082
|
log(`config ${configPath()}`);
|
|
4491
5083
|
log(
|
|
4492
|
-
`online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
|
|
5084
|
+
`online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s; reconnects until stopped)`
|
|
4493
5085
|
);
|
|
4494
5086
|
await restoreHostsAfterReconnect(cfg);
|
|
4495
5087
|
|
|
@@ -4519,25 +5111,37 @@ async function main() {
|
|
|
4519
5111
|
void runWork();
|
|
4520
5112
|
};
|
|
4521
5113
|
|
|
4522
|
-
/** @type {
|
|
5114
|
+
/** @type {WebSocket | null} */
|
|
4523
5115
|
let socket = null;
|
|
4524
5116
|
let heartbeatTimer = null;
|
|
4525
5117
|
let pingTimer = null;
|
|
4526
5118
|
let reconnectTimer = null;
|
|
5119
|
+
let watchdogTimer = null;
|
|
4527
5120
|
let wsGeneration = 0;
|
|
4528
5121
|
let reconnectAttempt = 0;
|
|
5122
|
+
let connectStartedAt = 0;
|
|
4529
5123
|
let stopped = false;
|
|
4530
5124
|
let presenceBusy = false;
|
|
4531
5125
|
|
|
5126
|
+
let sendChain = Promise.resolve();
|
|
4532
5127
|
const sendJson = (payload) => {
|
|
4533
|
-
|
|
5128
|
+
const current = socket;
|
|
5129
|
+
if (!current || current.readyState !== 1) return false;
|
|
5130
|
+
let text;
|
|
4534
5131
|
try {
|
|
4535
|
-
|
|
4536
|
-
return true;
|
|
5132
|
+
text = JSON.stringify(payload);
|
|
4537
5133
|
} catch {
|
|
4538
5134
|
return false;
|
|
4539
5135
|
}
|
|
5136
|
+
sendChain = sendChain
|
|
5137
|
+
.then(() => {
|
|
5138
|
+
if (!socket || socket !== current || socket.readyState !== 1) return;
|
|
5139
|
+
socket.send(text);
|
|
5140
|
+
})
|
|
5141
|
+
.catch(() => {});
|
|
5142
|
+
return true;
|
|
4540
5143
|
};
|
|
5144
|
+
bridgeSend = sendJson;
|
|
4541
5145
|
|
|
4542
5146
|
const clearHeartbeatTimer = () => {
|
|
4543
5147
|
if (heartbeatTimer) {
|
|
@@ -4566,7 +5170,7 @@ async function main() {
|
|
|
4566
5170
|
};
|
|
4567
5171
|
|
|
4568
5172
|
const sendLightPresence = () => {
|
|
4569
|
-
const folders = collectOfferedFolders(
|
|
5173
|
+
const folders = collectOfferedFolders();
|
|
4570
5174
|
sendJson(buildLightHeartbeatPayload(cfg, folders));
|
|
4571
5175
|
};
|
|
4572
5176
|
|
|
@@ -4574,7 +5178,7 @@ async function main() {
|
|
|
4574
5178
|
if (!socket || socket.readyState !== 1 || presenceBusy) return;
|
|
4575
5179
|
presenceBusy = true;
|
|
4576
5180
|
try {
|
|
4577
|
-
const folders = collectOfferedFolders(
|
|
5181
|
+
const folders = collectOfferedFolders();
|
|
4578
5182
|
const localStates = await collectWorkspaceStates(cfg);
|
|
4579
5183
|
const payload = await buildHeartbeatPayload(cfg, folders, localStates);
|
|
4580
5184
|
sendJson(payload);
|
|
@@ -4627,6 +5231,38 @@ async function main() {
|
|
|
4627
5231
|
fail(`no local workspace for sandbox ${sandboxId || "?"}`);
|
|
4628
5232
|
return;
|
|
4629
5233
|
}
|
|
5234
|
+
const body = {
|
|
5235
|
+
conversationId,
|
|
5236
|
+
messages: payloadMessages,
|
|
5237
|
+
userMessage: content || undefined,
|
|
5238
|
+
skipPersistUser: true,
|
|
5239
|
+
userMessageId: userMessageId || undefined,
|
|
5240
|
+
senderType: msg.senderType === "client" ? "client" : undefined,
|
|
5241
|
+
senderName:
|
|
5242
|
+
typeof msg.senderName === "string" ? msg.senderName : undefined,
|
|
5243
|
+
};
|
|
5244
|
+
const embedded = embeddedChat.get(sandboxId);
|
|
5245
|
+
if (embedded?.runChat) {
|
|
5246
|
+
log(`chat.run → in-process ${embedded.workspaceDir} (${conversationId})`);
|
|
5247
|
+
try {
|
|
5248
|
+
const result = await embedded.runChat(body);
|
|
5249
|
+
if (!result.ok) {
|
|
5250
|
+
fail(result.data?.error || `AI server ${result.status}`);
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
log(`chat.run ok (${conversationId})`);
|
|
5254
|
+
sendJson({
|
|
5255
|
+
type: "chat.run.result",
|
|
5256
|
+
ok: true,
|
|
5257
|
+
sandboxId,
|
|
5258
|
+
conversationId,
|
|
5259
|
+
userMessageId,
|
|
5260
|
+
});
|
|
5261
|
+
} catch (err) {
|
|
5262
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
5263
|
+
}
|
|
5264
|
+
return;
|
|
5265
|
+
}
|
|
4630
5266
|
const chat = await discoverChatPort(ws, 2500);
|
|
4631
5267
|
if (chat.up) ws.port = chat.port;
|
|
4632
5268
|
const port = Number(chat.port || ws.port) || 3100;
|
|
@@ -4639,16 +5275,7 @@ async function main() {
|
|
|
4639
5275
|
"content-type": "application/json",
|
|
4640
5276
|
accept: "application/json",
|
|
4641
5277
|
},
|
|
4642
|
-
body: JSON.stringify(
|
|
4643
|
-
conversationId,
|
|
4644
|
-
messages: payloadMessages,
|
|
4645
|
-
userMessage: content || undefined,
|
|
4646
|
-
skipPersistUser: true,
|
|
4647
|
-
userMessageId: userMessageId || undefined,
|
|
4648
|
-
senderType: msg.senderType === "client" ? "client" : undefined,
|
|
4649
|
-
senderName:
|
|
4650
|
-
typeof msg.senderName === "string" ? msg.senderName : undefined,
|
|
4651
|
-
}),
|
|
5278
|
+
body: JSON.stringify(body),
|
|
4652
5279
|
});
|
|
4653
5280
|
const text = await res.text();
|
|
4654
5281
|
let data = null;
|
|
@@ -4692,6 +5319,26 @@ async function main() {
|
|
|
4692
5319
|
queueActions(msg.actions);
|
|
4693
5320
|
return;
|
|
4694
5321
|
}
|
|
5322
|
+
if (msg.type === "proxy.http") {
|
|
5323
|
+
handleProxyHttpFromAdmin(msg);
|
|
5324
|
+
return;
|
|
5325
|
+
}
|
|
5326
|
+
if (msg.type === "proxy.http.body") {
|
|
5327
|
+
handleProxyHttpBodyFromAdmin(msg);
|
|
5328
|
+
return;
|
|
5329
|
+
}
|
|
5330
|
+
if (msg.type === "proxy.ws.open") {
|
|
5331
|
+
handleProxyWsOpenFromAdmin(msg);
|
|
5332
|
+
return;
|
|
5333
|
+
}
|
|
5334
|
+
if (msg.type === "proxy.ws.frame") {
|
|
5335
|
+
handleProxyWsFrameFromAdmin(msg);
|
|
5336
|
+
return;
|
|
5337
|
+
}
|
|
5338
|
+
if (msg.type === "proxy.ws.close") {
|
|
5339
|
+
handleProxyWsCloseFromAdmin(msg);
|
|
5340
|
+
return;
|
|
5341
|
+
}
|
|
4695
5342
|
if (msg.type === "chat.run") {
|
|
4696
5343
|
void handleChatRun(msg);
|
|
4697
5344
|
return;
|
|
@@ -4701,37 +5348,56 @@ async function main() {
|
|
|
4701
5348
|
}
|
|
4702
5349
|
};
|
|
4703
5350
|
|
|
5351
|
+
const socketState = () => {
|
|
5352
|
+
if (!socket) return -1;
|
|
5353
|
+
return socket.readyState;
|
|
5354
|
+
};
|
|
5355
|
+
|
|
5356
|
+
const dropSocket = (ws) => {
|
|
5357
|
+
if (!ws) return;
|
|
5358
|
+
try {
|
|
5359
|
+
ws.close();
|
|
5360
|
+
} catch {
|
|
5361
|
+
/* ignore */
|
|
5362
|
+
}
|
|
5363
|
+
if (socket === ws) socket = null;
|
|
5364
|
+
};
|
|
5365
|
+
|
|
4704
5366
|
const scheduleReconnect = (code, reason) => {
|
|
4705
5367
|
if (stopped || reconnectTimer) return;
|
|
5368
|
+
const state = socketState();
|
|
5369
|
+
if (state === 0 || state === 1) return;
|
|
4706
5370
|
const delay = Math.min(
|
|
4707
5371
|
WS_RECONNECT_MAX_MS,
|
|
4708
|
-
WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt,
|
|
5372
|
+
WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 5)
|
|
4709
5373
|
);
|
|
4710
5374
|
reconnectAttempt += 1;
|
|
4711
5375
|
const detail = reason ? ` ${reason}` : "";
|
|
4712
5376
|
const wait =
|
|
4713
5377
|
delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
|
|
4714
5378
|
warn(
|
|
4715
|
-
`
|
|
5379
|
+
`admin connection lost (${code || "?"}${detail}); retrying in ${wait}`
|
|
4716
5380
|
);
|
|
4717
5381
|
reconnectTimer = setTimeout(() => {
|
|
4718
5382
|
reconnectTimer = null;
|
|
5383
|
+
if (stopped) return;
|
|
5384
|
+
const next = socketState();
|
|
5385
|
+
if (next === 0 || next === 1) return;
|
|
4719
5386
|
connectWs();
|
|
4720
5387
|
}, delay);
|
|
4721
5388
|
};
|
|
4722
5389
|
|
|
4723
5390
|
const connectWs = () => {
|
|
4724
5391
|
if (stopped) return;
|
|
5392
|
+
const state = socketState();
|
|
5393
|
+
if (state === 0 || state === 1) return;
|
|
4725
5394
|
clearReconnectTimer();
|
|
4726
5395
|
const generation = ++wsGeneration;
|
|
4727
5396
|
if (socket) {
|
|
4728
|
-
|
|
4729
|
-
socket.close();
|
|
4730
|
-
} catch {
|
|
4731
|
-
/* ignore */
|
|
4732
|
-
}
|
|
5397
|
+
dropSocket(socket);
|
|
4733
5398
|
socket = null;
|
|
4734
5399
|
}
|
|
5400
|
+
sendChain = Promise.resolve();
|
|
4735
5401
|
const url = adminWsUrl(cfg.adminUrl, cfg.token);
|
|
4736
5402
|
log(
|
|
4737
5403
|
reconnectAttempt
|
|
@@ -4739,12 +5405,35 @@ async function main() {
|
|
|
4739
5405
|
: "websocket connecting…"
|
|
4740
5406
|
);
|
|
4741
5407
|
/** @type {WebSocket} */
|
|
4742
|
-
|
|
5408
|
+
let ws;
|
|
5409
|
+
try {
|
|
5410
|
+
ws = new WebSocket(url);
|
|
5411
|
+
} catch (err) {
|
|
5412
|
+
warn(
|
|
5413
|
+
`websocket connect failed: ${
|
|
5414
|
+
err instanceof Error ? err.message : String(err)
|
|
5415
|
+
}`
|
|
5416
|
+
);
|
|
5417
|
+
scheduleReconnect(0, "connect failed");
|
|
5418
|
+
return;
|
|
5419
|
+
}
|
|
4743
5420
|
socket = ws;
|
|
5421
|
+
connectStartedAt = Date.now();
|
|
5422
|
+
|
|
5423
|
+
const onDisconnected = (code, reason) => {
|
|
5424
|
+
if (generation !== wsGeneration) return;
|
|
5425
|
+
// Node fires `error` while CONNECTING/OPEN; that is not a drop.
|
|
5426
|
+
if (ws.readyState === 0 || ws.readyState === 1) return;
|
|
5427
|
+
clearHeartbeatTimer();
|
|
5428
|
+
clearPingTimer();
|
|
5429
|
+
if (socket === ws) socket = null;
|
|
5430
|
+
scheduleReconnect(code, reason);
|
|
5431
|
+
};
|
|
4744
5432
|
|
|
4745
5433
|
ws.addEventListener("open", () => {
|
|
4746
5434
|
if (generation !== wsGeneration) return;
|
|
4747
5435
|
reconnectAttempt = 0;
|
|
5436
|
+
connectStartedAt = Date.now();
|
|
4748
5437
|
log("websocket connected");
|
|
4749
5438
|
clearHeartbeatTimer();
|
|
4750
5439
|
clearPingTimer();
|
|
@@ -4763,34 +5452,61 @@ async function main() {
|
|
|
4763
5452
|
});
|
|
4764
5453
|
|
|
4765
5454
|
ws.addEventListener("close", (event) => {
|
|
4766
|
-
|
|
4767
|
-
clearHeartbeatTimer();
|
|
4768
|
-
clearPingTimer();
|
|
4769
|
-
if (socket === ws) socket = null;
|
|
4770
|
-
scheduleReconnect(event.code, event.reason);
|
|
5455
|
+
onDisconnected(event.code, event.reason);
|
|
4771
5456
|
});
|
|
4772
5457
|
|
|
4773
5458
|
ws.addEventListener("error", () => {
|
|
4774
|
-
// close
|
|
5459
|
+
// `close` follows for real failures. Reconnecting here races `open`
|
|
5460
|
+
// and makes admin close the live socket with 1000 "replaced".
|
|
4775
5461
|
});
|
|
4776
5462
|
};
|
|
4777
5463
|
|
|
5464
|
+
const watchdogTick = () => {
|
|
5465
|
+
if (stopped) return;
|
|
5466
|
+
const state = socketState();
|
|
5467
|
+
if (state === 1) return;
|
|
5468
|
+
if (state === 0) {
|
|
5469
|
+
if (Date.now() - connectStartedAt > WS_CONNECT_TIMEOUT_MS) {
|
|
5470
|
+
warn("websocket connect timed out; retrying");
|
|
5471
|
+
wsGeneration += 1;
|
|
5472
|
+
dropSocket(socket);
|
|
5473
|
+
socket = null;
|
|
5474
|
+
scheduleReconnect(0, "connect timeout");
|
|
5475
|
+
}
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
5478
|
+
scheduleReconnect(0, "watchdog");
|
|
5479
|
+
};
|
|
5480
|
+
|
|
4778
5481
|
connectWs();
|
|
5482
|
+
watchdogTimer = setInterval(watchdogTick, WS_WATCHDOG_MS);
|
|
5483
|
+
|
|
5484
|
+
const onFatal = (kind, err) => {
|
|
5485
|
+
const msg = err instanceof Error ? err.message : String(err || kind);
|
|
5486
|
+
warn(`${kind}: ${msg}`);
|
|
5487
|
+
if (!stopped) scheduleReconnect(kind, msg);
|
|
5488
|
+
};
|
|
5489
|
+
process.on("uncaughtException", (err) => onFatal("uncaughtException", err));
|
|
5490
|
+
process.on("unhandledRejection", (err) =>
|
|
5491
|
+
onFatal("unhandledRejection", err)
|
|
5492
|
+
);
|
|
4779
5493
|
|
|
4780
5494
|
const shutdown = () => {
|
|
5495
|
+
if (stopped) return;
|
|
4781
5496
|
stopped = true;
|
|
4782
5497
|
wsGeneration += 1;
|
|
4783
5498
|
clearHeartbeatTimer();
|
|
4784
5499
|
clearPingTimer();
|
|
4785
5500
|
clearReconnectTimer();
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
} catch {
|
|
4790
|
-
/* ignore */
|
|
5501
|
+
if (watchdogTimer) {
|
|
5502
|
+
clearInterval(watchdogTimer);
|
|
5503
|
+
watchdogTimer = null;
|
|
4791
5504
|
}
|
|
5505
|
+
stopAllCloudflare();
|
|
5506
|
+
dropSocket(socket);
|
|
5507
|
+
socket = null;
|
|
4792
5508
|
log("shutting down (other terminals stay open)");
|
|
4793
|
-
process.exit(0);
|
|
5509
|
+
void stopAllEmbeddedChat().finally(() => process.exit(0));
|
|
4794
5510
|
};
|
|
4795
5511
|
process.on("SIGINT", shutdown);
|
|
4796
5512
|
process.on("SIGTERM", shutdown);
|