@maintainer-pro/ai-bridge 0.1.7 → 0.1.9
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 +2247 -1547
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,110 +2121,619 @@ 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
|
+
/** Windows: `D:\Projects\…` → `D:\Projects`. Never the drive root. */
|
|
2129
|
+
function browseRootFromCwd() {
|
|
2130
|
+
const cwd = path.resolve(process.cwd());
|
|
2131
|
+
if (process.platform === "win32") {
|
|
2132
|
+
const drive = path.parse(cwd).root;
|
|
2133
|
+
const first = cwd.slice(drive.length).split(/[\\/]/).filter(Boolean)[0];
|
|
2134
|
+
if (first) return path.resolve(path.join(drive, first));
|
|
2135
|
+
}
|
|
2136
|
+
return cwd;
|
|
2137
|
+
}
|
|
2280
2138
|
|
|
2281
|
-
|
|
2282
|
-
|
|
2139
|
+
function normalizeFsPath(p) {
|
|
2140
|
+
let s = path.resolve(String(p || "").trim());
|
|
2141
|
+
if (process.platform === "win32") {
|
|
2142
|
+
s = s.replace(/\//g, "\\");
|
|
2143
|
+
if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
|
|
2144
|
+
else s = s.replace(/\\+$/, "");
|
|
2145
|
+
if (/^[a-zA-Z]:$/.test(s)) s = `${s}\\`;
|
|
2146
|
+
return s.toLowerCase();
|
|
2147
|
+
}
|
|
2148
|
+
if (s !== "/") s = s.replace(/\/+$/, "");
|
|
2149
|
+
return s;
|
|
2150
|
+
}
|
|
2283
2151
|
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2152
|
+
function pathInside(inner, outer) {
|
|
2153
|
+
const a = normalizeFsPath(inner);
|
|
2154
|
+
const b = normalizeFsPath(outer);
|
|
2155
|
+
if (!a || !b) return false;
|
|
2156
|
+
if (a === b) return true;
|
|
2157
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
2158
|
+
const prefix = b.endsWith(sep) ? b : `${b}${sep}`;
|
|
2159
|
+
return a.startsWith(prefix);
|
|
2160
|
+
}
|
|
2286
2161
|
|
|
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);
|
|
2162
|
+
function isLaunchRoot(dirPath, allowed) {
|
|
2163
|
+
const resolved = path.resolve(dirPath);
|
|
2164
|
+
return allowed.some((root) => normalizeFsPath(root) === normalizeFsPath(resolved));
|
|
2297
2165
|
}
|
|
2298
2166
|
|
|
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
|
-
});
|
|
2167
|
+
function listLaunchRoots(allowed) {
|
|
2168
|
+
const home = os.homedir();
|
|
2169
|
+
if (allowed.length === 1) return listDirEntries(allowed[0], allowed);
|
|
2170
|
+
return {
|
|
2171
|
+
path: "",
|
|
2172
|
+
parent: null,
|
|
2173
|
+
home,
|
|
2174
|
+
entries: allowed.map((root) => ({
|
|
2175
|
+
name: root.split(/[/\\]/).filter(Boolean).slice(-1)[0] || root,
|
|
2176
|
+
path: root,
|
|
2177
|
+
isDir: true,
|
|
2178
|
+
})),
|
|
2179
|
+
};
|
|
2331
2180
|
}
|
|
2332
2181
|
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2182
|
+
/** Prevents opening a new window on every heartbeat while a process is starting. */
|
|
2183
|
+
const launchedAt = new Map();
|
|
2184
|
+
/** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
|
|
2185
|
+
const cloudflareTunnels = new Map();
|
|
2186
|
+
/** In-process chat servers, one per sandbox/project. */
|
|
2187
|
+
/** @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 }>} */
|
|
2188
|
+
const embeddedChat = new Map();
|
|
2189
|
+
/** @type {Map<string, Promise<object | null>>} */
|
|
2190
|
+
const embeddedChatStarting = new Map();
|
|
2191
|
+
|
|
2192
|
+
const PROXY_CHUNK_BYTES = 32 * 1024;
|
|
2193
|
+
/** @type {Map<string, import("node:http").ClientRequest>} */
|
|
2194
|
+
const proxyHttpReqs = new Map();
|
|
2195
|
+
/** @type {Map<string, WebSocket>} */
|
|
2196
|
+
const proxyLocalSockets = new Map();
|
|
2197
|
+
/** @type {Record<string, unknown> | null} */
|
|
2198
|
+
let bridgeCfg = null;
|
|
2199
|
+
|
|
2200
|
+
function safeProxyPath(path) {
|
|
2201
|
+
const raw = String(path || "/");
|
|
2202
|
+
if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
|
|
2203
|
+
return raw;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
function workspaceForProxy(sandboxId) {
|
|
2207
|
+
return (
|
|
2208
|
+
(Array.isArray(bridgeCfg?.workspaces) ? bridgeCfg.workspaces : []).find(
|
|
2209
|
+
(row) => row.sandboxId === sandboxId
|
|
2210
|
+
) || null
|
|
2211
|
+
);
|
|
2340
2212
|
}
|
|
2341
2213
|
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2214
|
+
function localPortForProxy(ws, appId) {
|
|
2215
|
+
const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
|
|
2216
|
+
const app = apps.find((row) => row.id === appId);
|
|
2217
|
+
let port = 0;
|
|
2218
|
+
if (app && usesBridgeProxy(app) && Number(app.port)) port = Number(app.port);
|
|
2219
|
+
else if (appId === "ai-server" || app?.role === "ai-server") {
|
|
2220
|
+
port = Number(ws.port) || 3100;
|
|
2347
2221
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2222
|
+
if (
|
|
2223
|
+
(appId === "ai-server" || app?.role === "ai-server") &&
|
|
2224
|
+
isAdminListenPort(port)
|
|
2225
|
+
) {
|
|
2226
|
+
return 3100;
|
|
2350
2227
|
}
|
|
2351
|
-
|
|
2352
|
-
await stopCloudflare(sandboxId);
|
|
2228
|
+
return port;
|
|
2353
2229
|
}
|
|
2354
2230
|
|
|
2355
|
-
function
|
|
2356
|
-
|
|
2231
|
+
function proxyReqHeaders(incoming) {
|
|
2232
|
+
/** @type {Record<string, string>} */
|
|
2233
|
+
const headers = {};
|
|
2234
|
+
if (!incoming || typeof incoming !== "object") return headers;
|
|
2235
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
2236
|
+
const lower = String(key).toLowerCase();
|
|
2237
|
+
if (
|
|
2238
|
+
[
|
|
2239
|
+
"connection",
|
|
2240
|
+
"keep-alive",
|
|
2241
|
+
"transfer-encoding",
|
|
2242
|
+
"upgrade",
|
|
2243
|
+
"host",
|
|
2244
|
+
"content-length",
|
|
2245
|
+
"te",
|
|
2246
|
+
"trailer",
|
|
2247
|
+
].includes(lower)
|
|
2248
|
+
) {
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
if (typeof value === "string" && value) headers[key] = value;
|
|
2252
|
+
}
|
|
2253
|
+
return headers;
|
|
2357
2254
|
}
|
|
2358
2255
|
|
|
2359
|
-
function
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2256
|
+
function isAiServerAppId(appId, ws) {
|
|
2257
|
+
if (appId === "ai-server") return true;
|
|
2258
|
+
const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
|
|
2259
|
+
const app = apps.find((row) => row.id === appId);
|
|
2260
|
+
return Boolean(app && (app.role === "ai-server" || app.id === "ai-server"));
|
|
2363
2261
|
}
|
|
2364
2262
|
|
|
2365
|
-
function
|
|
2263
|
+
function replyProxyHttp(id, status, headers, body) {
|
|
2264
|
+
const stream = randomBytes(8).toString("hex");
|
|
2265
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body || "");
|
|
2266
|
+
/** @type {Record<string, string>} */
|
|
2267
|
+
const out = {};
|
|
2268
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
2269
|
+
if (value == null) continue;
|
|
2270
|
+
out[String(key)] = String(value);
|
|
2271
|
+
}
|
|
2272
|
+
bridgeSend({ type: "proxy.http.start", id, stream, status, headers: out });
|
|
2273
|
+
for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
|
|
2274
|
+
const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
|
|
2275
|
+
bridgeSend({
|
|
2276
|
+
type: "proxy.http.chunk",
|
|
2277
|
+
id,
|
|
2278
|
+
stream,
|
|
2279
|
+
data: buf.subarray(offset, end).toString("base64"),
|
|
2280
|
+
eof: false,
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function bridgeEmbedConfigJs(ws) {
|
|
2287
|
+
const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
|
|
2288
|
+
const aiApp = { id: "ai-server", role: "ai-server" };
|
|
2289
|
+
const ai = (
|
|
2290
|
+
proxyUrlForApp(ws, aiApp) ||
|
|
2291
|
+
`http://127.0.0.1:${Number(ws.port) || 3100}`
|
|
2292
|
+
).replace(/\/$/, "");
|
|
2293
|
+
const wsUrl = ai.replace(/^http/i, "ws");
|
|
2294
|
+
const payload = {
|
|
2295
|
+
aiServerUrl: ai,
|
|
2296
|
+
apiUrl: `${ai}/api/chat`,
|
|
2297
|
+
aiServerWsUrl: `${wsUrl}/api/ws`,
|
|
2298
|
+
debug: true,
|
|
2299
|
+
logLevel: "debug",
|
|
2300
|
+
maintainerProUrl: bridgeCfg?.adminUrl || "",
|
|
2301
|
+
maintainerProApiKey: String(store.clientKey || "").trim(),
|
|
2302
|
+
};
|
|
2303
|
+
return `window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
async function handleAiProxyHttpFromAdmin(msg, ws) {
|
|
2307
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2308
|
+
const method = String(msg.method || "GET").toUpperCase();
|
|
2309
|
+
const reqPath = safeProxyPath(msg.path);
|
|
2310
|
+
const pathname = reqPath.split("?")[0] || "/";
|
|
2311
|
+
const headers = proxyReqHeaders(msg.headers);
|
|
2312
|
+
const body =
|
|
2313
|
+
typeof msg.body === "string" && msg.body
|
|
2314
|
+
? Buffer.from(msg.body, "base64")
|
|
2315
|
+
: Buffer.alloc(0);
|
|
2316
|
+
|
|
2317
|
+
if (pathname === "/ai-ui.iife.js") {
|
|
2318
|
+
const file = findIife();
|
|
2319
|
+
if (!file || !fs.existsSync(file)) {
|
|
2320
|
+
replyProxyHttp(
|
|
2321
|
+
id,
|
|
2322
|
+
404,
|
|
2323
|
+
{ "content-type": "text/plain; charset=utf-8" },
|
|
2324
|
+
"Not found"
|
|
2325
|
+
);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
replyProxyHttp(
|
|
2329
|
+
id,
|
|
2330
|
+
200,
|
|
2331
|
+
{
|
|
2332
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
2333
|
+
"cache-control": "no-store",
|
|
2334
|
+
},
|
|
2335
|
+
fs.readFileSync(file)
|
|
2336
|
+
);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (pathname === "/embed-config.js") {
|
|
2341
|
+
const cfg = bridgeCfg || loadConfig();
|
|
2342
|
+
await loadSandboxStoreEnv(ws, cfg);
|
|
2343
|
+
replyProxyHttp(
|
|
2344
|
+
id,
|
|
2345
|
+
200,
|
|
2346
|
+
{
|
|
2347
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
2348
|
+
"cache-control": "no-store",
|
|
2349
|
+
},
|
|
2350
|
+
bridgeEmbedConfigJs(ws)
|
|
2351
|
+
);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
let embedded = ws.sandboxId ? embeddedChat.get(ws.sandboxId) : null;
|
|
2356
|
+
if (!embedded?.handleHttp) {
|
|
2357
|
+
embedded = await ensureEmbeddedChat(ws);
|
|
2358
|
+
}
|
|
2359
|
+
if (embedded?.handleHttp) {
|
|
2360
|
+
try {
|
|
2361
|
+
const result = await embedded.handleHttp({
|
|
2362
|
+
method,
|
|
2363
|
+
url: reqPath,
|
|
2364
|
+
headers,
|
|
2365
|
+
body,
|
|
2366
|
+
});
|
|
2367
|
+
replyProxyHttp(id, result.status, result.headers, result.body);
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
bridgeSend({
|
|
2370
|
+
type: "proxy.http.error",
|
|
2371
|
+
id,
|
|
2372
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
bridgeSend({
|
|
2379
|
+
type: "proxy.http.error",
|
|
2380
|
+
id,
|
|
2381
|
+
error: "AI chat is not running. Use Start Apps in Maintainer Pro.",
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
function handleProxyHttpFromAdmin(msg) {
|
|
2386
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2387
|
+
const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
|
|
2388
|
+
const appId = typeof msg.appId === "string" ? msg.appId : "";
|
|
2389
|
+
if (!id) return;
|
|
2390
|
+
const ws = workspaceForProxy(sandboxId);
|
|
2391
|
+
if (!ws) {
|
|
2392
|
+
bridgeSend({
|
|
2393
|
+
type: "proxy.http.error",
|
|
2394
|
+
id,
|
|
2395
|
+
error: "No local port mapped for that app",
|
|
2396
|
+
});
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
if (isAiServerAppId(appId, ws)) {
|
|
2400
|
+
void handleAiProxyHttpFromAdmin(msg, ws);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
const existing = proxyHttpReqs.get(id);
|
|
2404
|
+
if (existing) {
|
|
2405
|
+
try {
|
|
2406
|
+
existing.destroy();
|
|
2407
|
+
} catch {
|
|
2408
|
+
/* ignore */
|
|
2409
|
+
}
|
|
2410
|
+
proxyHttpReqs.delete(id);
|
|
2411
|
+
}
|
|
2412
|
+
const port = localPortForProxy(ws, appId);
|
|
2413
|
+
if (!port) {
|
|
2414
|
+
bridgeSend({
|
|
2415
|
+
type: "proxy.http.error",
|
|
2416
|
+
id,
|
|
2417
|
+
error: "No local port mapped for that app",
|
|
2418
|
+
});
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
const method = String(msg.method || "GET").toUpperCase();
|
|
2422
|
+
const path = safeProxyPath(msg.path);
|
|
2423
|
+
const headers = proxyReqHeaders(msg.headers);
|
|
2424
|
+
headers.host = `127.0.0.1:${port}`;
|
|
2425
|
+
// Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
|
|
2426
|
+
// This hop is server-to-server; drop those so chunks always load.
|
|
2427
|
+
for (const key of Object.keys(headers)) {
|
|
2428
|
+
const lower = key.toLowerCase();
|
|
2429
|
+
if (
|
|
2430
|
+
lower === "origin" ||
|
|
2431
|
+
lower === "referer" ||
|
|
2432
|
+
lower === "referrer" ||
|
|
2433
|
+
lower === "sec-fetch-site" ||
|
|
2434
|
+
lower === "sec-fetch-mode" ||
|
|
2435
|
+
lower === "sec-fetch-dest"
|
|
2436
|
+
) {
|
|
2437
|
+
delete headers[key];
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
const stream = randomBytes(8).toString("hex");
|
|
2441
|
+
let req;
|
|
2442
|
+
try {
|
|
2443
|
+
req = http.request(
|
|
2444
|
+
{
|
|
2445
|
+
hostname: "127.0.0.1",
|
|
2446
|
+
port,
|
|
2447
|
+
path,
|
|
2448
|
+
method,
|
|
2449
|
+
headers,
|
|
2450
|
+
agent: false,
|
|
2451
|
+
},
|
|
2452
|
+
(res) => {
|
|
2453
|
+
/** @type {Record<string, string>} */
|
|
2454
|
+
const outHeaders = {};
|
|
2455
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
2456
|
+
if (value == null) continue;
|
|
2457
|
+
outHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
2458
|
+
}
|
|
2459
|
+
bridgeSend({
|
|
2460
|
+
type: "proxy.http.start",
|
|
2461
|
+
id,
|
|
2462
|
+
stream,
|
|
2463
|
+
status: res.statusCode || 502,
|
|
2464
|
+
headers: outHeaders,
|
|
2465
|
+
});
|
|
2466
|
+
res.on("data", (chunk) => {
|
|
2467
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2468
|
+
for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
|
|
2469
|
+
const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
|
|
2470
|
+
bridgeSend({
|
|
2471
|
+
type: "proxy.http.chunk",
|
|
2472
|
+
id,
|
|
2473
|
+
stream,
|
|
2474
|
+
data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
|
|
2475
|
+
eof: false,
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
});
|
|
2479
|
+
res.on("end", () => {
|
|
2480
|
+
proxyHttpReqs.delete(id);
|
|
2481
|
+
bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
);
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
bridgeSend({
|
|
2487
|
+
type: "proxy.http.error",
|
|
2488
|
+
id,
|
|
2489
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2490
|
+
});
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
req.on("error", (err) => {
|
|
2494
|
+
proxyHttpReqs.delete(id);
|
|
2495
|
+
bridgeSend({
|
|
2496
|
+
type: "proxy.http.error",
|
|
2497
|
+
id,
|
|
2498
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2499
|
+
});
|
|
2500
|
+
});
|
|
2501
|
+
proxyHttpReqs.set(id, req);
|
|
2502
|
+
if (typeof msg.body === "string" && msg.body) {
|
|
2503
|
+
req.write(Buffer.from(msg.body, "base64"));
|
|
2504
|
+
}
|
|
2505
|
+
if (msg.bodyEof !== false) req.end();
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function handleProxyHttpBodyFromAdmin(msg) {
|
|
2509
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2510
|
+
const req = proxyHttpReqs.get(id);
|
|
2511
|
+
if (!req) return;
|
|
2512
|
+
if (typeof msg.data === "string" && msg.data) {
|
|
2513
|
+
req.write(Buffer.from(msg.data, "base64"));
|
|
2514
|
+
}
|
|
2515
|
+
if (msg.eof === true) req.end();
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function wsDataToBuffer(data) {
|
|
2519
|
+
if (typeof data === "string") return Buffer.from(data);
|
|
2520
|
+
if (Buffer.isBuffer(data)) return data;
|
|
2521
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
2522
|
+
if (ArrayBuffer.isView(data)) {
|
|
2523
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
2524
|
+
}
|
|
2525
|
+
throw new Error("Unsupported WebSocket payload type");
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function attachProxyLocalWs(id, socket) {
|
|
2529
|
+
try {
|
|
2530
|
+
socket.binaryType = "arraybuffer";
|
|
2531
|
+
} catch {
|
|
2532
|
+
/* WHATWG WebSocket in Node 22 */
|
|
2533
|
+
}
|
|
2534
|
+
proxyLocalSockets.set(id, socket);
|
|
2535
|
+
socket.addEventListener("open", () => {
|
|
2536
|
+
bridgeSend({ type: "proxy.ws.opened", id });
|
|
2537
|
+
});
|
|
2538
|
+
socket.addEventListener("message", (event) => {
|
|
2539
|
+
try {
|
|
2540
|
+
if (typeof event.data === "string") {
|
|
2541
|
+
bridgeSend({
|
|
2542
|
+
type: "proxy.ws.frame",
|
|
2543
|
+
id,
|
|
2544
|
+
data: event.data,
|
|
2545
|
+
binary: false,
|
|
2546
|
+
});
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
const buf = wsDataToBuffer(event.data);
|
|
2550
|
+
bridgeSend({
|
|
2551
|
+
type: "proxy.ws.frame",
|
|
2552
|
+
id,
|
|
2553
|
+
data: buf.toString("base64"),
|
|
2554
|
+
binary: true,
|
|
2555
|
+
});
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
logger.warn(
|
|
2558
|
+
`proxy ws frame encode failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
socket.addEventListener("close", (event) => {
|
|
2563
|
+
proxyLocalSockets.delete(id);
|
|
2564
|
+
bridgeSend({
|
|
2565
|
+
type: "proxy.ws.close",
|
|
2566
|
+
id,
|
|
2567
|
+
code: event.code,
|
|
2568
|
+
reason: event.reason || "",
|
|
2569
|
+
});
|
|
2570
|
+
});
|
|
2571
|
+
socket.addEventListener("error", () => {
|
|
2572
|
+
/* close handler follows */
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function openProxyLocalWs(id, port, path) {
|
|
2577
|
+
let socket;
|
|
2578
|
+
try {
|
|
2579
|
+
socket = new WebSocket(`ws://127.0.0.1:${port}${path}`);
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
bridgeSend({
|
|
2582
|
+
type: "proxy.ws.error",
|
|
2583
|
+
id,
|
|
2584
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2585
|
+
});
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
attachProxyLocalWs(id, socket);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function handleProxyWsOpenFromAdmin(msg) {
|
|
2592
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2593
|
+
const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
|
|
2594
|
+
const appId = typeof msg.appId === "string" ? msg.appId : "";
|
|
2595
|
+
if (!id) return;
|
|
2596
|
+
const ws = workspaceForProxy(sandboxId);
|
|
2597
|
+
if (!ws) {
|
|
2598
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
const path = safeProxyPath(msg.path);
|
|
2602
|
+
if (isAiServerAppId(appId, ws)) {
|
|
2603
|
+
void (async () => {
|
|
2604
|
+
const embedded = await ensureEmbeddedChat(ws);
|
|
2605
|
+
const port = Number(embedded?.port) || localPortForProxy(ws, appId);
|
|
2606
|
+
if (!port) {
|
|
2607
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "AI chat is not running" });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
openProxyLocalWs(id, port, path);
|
|
2611
|
+
})();
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
const port = localPortForProxy(ws, appId);
|
|
2615
|
+
if (!port) {
|
|
2616
|
+
bridgeSend({ type: "proxy.ws.error", id, error: "No local port mapped" });
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
openProxyLocalWs(id, port, path);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function handleProxyWsFrameFromAdmin(msg) {
|
|
2623
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2624
|
+
const socket = proxyLocalSockets.get(id);
|
|
2625
|
+
if (!socket || socket.readyState !== 1) return;
|
|
2626
|
+
try {
|
|
2627
|
+
if (msg.binary === true) {
|
|
2628
|
+
socket.send(Buffer.from(String(msg.data || ""), "base64"));
|
|
2629
|
+
} else {
|
|
2630
|
+
socket.send(String(msg.data || ""));
|
|
2631
|
+
}
|
|
2632
|
+
} catch {
|
|
2633
|
+
/* ignore */
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
function handleProxyWsCloseFromAdmin(msg) {
|
|
2638
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
2639
|
+
const socket = proxyLocalSockets.get(id);
|
|
2640
|
+
if (!socket) return;
|
|
2641
|
+
proxyLocalSockets.delete(id);
|
|
2642
|
+
try {
|
|
2643
|
+
socket.close(
|
|
2644
|
+
typeof msg.code === "number" ? msg.code : 1000,
|
|
2645
|
+
typeof msg.reason === "string" ? msg.reason.slice(0, 120) : ""
|
|
2646
|
+
);
|
|
2647
|
+
} catch {
|
|
2648
|
+
/* ignore */
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
/** Last process problems to send on heartbeat. Key: sandboxId::code::role */
|
|
2653
|
+
const processProblems = new Map();
|
|
2654
|
+
|
|
2655
|
+
/** @type {Map<string, Set<string>>} sandboxId -> CMD/terminal titles we opened */
|
|
2656
|
+
const openedTerminalTitles = new Map();
|
|
2657
|
+
|
|
2658
|
+
function rememberTerminalTitle(sandboxId, title) {
|
|
2659
|
+
const id = String(sandboxId || "").trim();
|
|
2660
|
+
const name = String(title || "").trim();
|
|
2661
|
+
if (!id || !name) return;
|
|
2662
|
+
let titles = openedTerminalTitles.get(id);
|
|
2663
|
+
if (!titles) {
|
|
2664
|
+
titles = new Set();
|
|
2665
|
+
openedTerminalTitles.set(id, titles);
|
|
2666
|
+
}
|
|
2667
|
+
titles.add(name);
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function closeWindowsByTitle(title) {
|
|
2671
|
+
const name = String(title || "").trim();
|
|
2672
|
+
if (!name) return Promise.resolve();
|
|
2673
|
+
return new Promise((resolve) => {
|
|
2674
|
+
const done = () => resolve();
|
|
2675
|
+
if (process.platform === "win32") {
|
|
2676
|
+
const child = spawn(
|
|
2677
|
+
"taskkill",
|
|
2678
|
+
["/F", "/T", "/FI", `WINDOWTITLE eq ${name}*`],
|
|
2679
|
+
{ windowsHide: true, stdio: "ignore" }
|
|
2680
|
+
);
|
|
2681
|
+
child.on("exit", done);
|
|
2682
|
+
child.on("error", done);
|
|
2683
|
+
setTimeout(done, 4000);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
if (process.platform === "darwin") {
|
|
2687
|
+
const child = spawn(
|
|
2688
|
+
"osascript",
|
|
2689
|
+
[
|
|
2690
|
+
"-e",
|
|
2691
|
+
`tell application "Terminal" to close (every window whose name contains ${JSON.stringify(name)})`,
|
|
2692
|
+
],
|
|
2693
|
+
{ stdio: "ignore" }
|
|
2694
|
+
);
|
|
2695
|
+
child.on("exit", done);
|
|
2696
|
+
child.on("error", done);
|
|
2697
|
+
setTimeout(done, 4000);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
done();
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
async function closeRememberedTerminals(sandboxId) {
|
|
2705
|
+
const titles = openedTerminalTitles.get(sandboxId);
|
|
2706
|
+
if (!titles) return;
|
|
2707
|
+
for (const title of titles) {
|
|
2708
|
+
await closeWindowsByTitle(title);
|
|
2709
|
+
}
|
|
2710
|
+
openedTerminalTitles.delete(sandboxId);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
async function forgetLaunch(sandboxId) {
|
|
2714
|
+
for (const key of [...launchedAt.keys()]) {
|
|
2715
|
+
if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
|
|
2716
|
+
launchedAt.delete(key);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
for (const key of [...processProblems.keys()]) {
|
|
2720
|
+
if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
|
|
2721
|
+
}
|
|
2722
|
+
await closeRememberedTerminals(sandboxId);
|
|
2723
|
+
await stopCloudflare(sandboxId);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
function problemKey(sandboxId, code, role = "") {
|
|
2727
|
+
return `${sandboxId || ""}::${code}::${role}`;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function clipIssueText(text, max) {
|
|
2731
|
+
const value = String(text || "").trim();
|
|
2732
|
+
if (value.length <= max) return value;
|
|
2733
|
+
return `${value.slice(0, max - 1)}…`;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function processRoleLabel(role) {
|
|
2366
2737
|
if (role === "ui") return "app UI";
|
|
2367
2738
|
if (role === "backend") return "backend";
|
|
2368
2739
|
if (role === "ai") return "chat script";
|
|
@@ -2518,8 +2889,8 @@ function friendlyLaunchError(raw, title) {
|
|
|
2518
2889
|
return text || `Could not open a terminal for "${title}".`;
|
|
2519
2890
|
}
|
|
2520
2891
|
|
|
2521
|
-
function writeWinLaunchScript(folder, title, command, env) {
|
|
2522
|
-
const dir =
|
|
2892
|
+
function writeWinLaunchScript(folder, title, command, env, sandboxId) {
|
|
2893
|
+
const dir = dataDirFor(folder, sandboxId);
|
|
2523
2894
|
fs.mkdirSync(dir, { recursive: true });
|
|
2524
2895
|
const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
2525
2896
|
const file = path.join(dir, `launch-${safe}.cmd`);
|
|
@@ -2651,7 +3022,13 @@ async function openInNewTerminal(opts) {
|
|
|
2651
3022
|
const safeTitle =
|
|
2652
3023
|
String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
|
|
2653
3024
|
"Maintainer Pro";
|
|
2654
|
-
const script = writeWinLaunchScript(
|
|
3025
|
+
const script = writeWinLaunchScript(
|
|
3026
|
+
folder,
|
|
3027
|
+
safeTitle,
|
|
3028
|
+
command,
|
|
3029
|
+
env,
|
|
3030
|
+
sandboxId
|
|
3031
|
+
);
|
|
2655
3032
|
log(`terminal script [${title}] ${script}`);
|
|
2656
3033
|
const opened = await openWindowsConsole(script, folder);
|
|
2657
3034
|
if (!opened.ok) {
|
|
@@ -2708,12 +3085,56 @@ function commandWithPort(job, scripts, port) {
|
|
|
2708
3085
|
return job.command;
|
|
2709
3086
|
}
|
|
2710
3087
|
|
|
3088
|
+
async function stopEmbeddedChat(sandboxId) {
|
|
3089
|
+
const inst = embeddedChat.get(sandboxId);
|
|
3090
|
+
if (!inst) return;
|
|
3091
|
+
embeddedChat.delete(sandboxId);
|
|
3092
|
+
try {
|
|
3093
|
+
await inst.close();
|
|
3094
|
+
} catch {
|
|
3095
|
+
/* ignore */
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
async function stopAllEmbeddedChat() {
|
|
3100
|
+
const ids = [...embeddedChat.keys()];
|
|
3101
|
+
await Promise.all(ids.map((id) => stopEmbeddedChat(id)));
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
async function ensureEmbeddedChat(ws) {
|
|
3105
|
+
if (!ws?.sandboxId) return null;
|
|
3106
|
+
const existing = embeddedChat.get(ws.sandboxId);
|
|
3107
|
+
if (existing?.handleHttp) return existing;
|
|
3108
|
+
const inflight = embeddedChatStarting.get(ws.sandboxId);
|
|
3109
|
+
if (inflight) return inflight;
|
|
3110
|
+
const task = (async () => {
|
|
3111
|
+
const cfg = bridgeCfg || loadConfig();
|
|
3112
|
+
const jobs = jobsFromHostApps(ws);
|
|
3113
|
+
const shareEnv = {
|
|
3114
|
+
...envForWorkspacePorts(ws, jobs),
|
|
3115
|
+
...uiPublicEnv({}, ws),
|
|
3116
|
+
};
|
|
3117
|
+
log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
|
|
3118
|
+
const result = await startAiServerForWorkspace(ws, {
|
|
3119
|
+
cfg,
|
|
3120
|
+
env: shareEnv,
|
|
3121
|
+
forceEmbed: true,
|
|
3122
|
+
});
|
|
3123
|
+
if (!result.up) return null;
|
|
3124
|
+
return embeddedChat.get(ws.sandboxId) || null;
|
|
3125
|
+
})().finally(() => {
|
|
3126
|
+
embeddedChatStarting.delete(ws.sandboxId);
|
|
3127
|
+
});
|
|
3128
|
+
embeddedChatStarting.set(ws.sandboxId, task);
|
|
3129
|
+
return task;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
2711
3132
|
async function startAiServerForWorkspace(ws, opts = {}) {
|
|
2712
3133
|
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
2713
3134
|
const cfg = opts.cfg || null;
|
|
2714
3135
|
const folder = path.resolve(ws.folderPath);
|
|
2715
|
-
const
|
|
2716
|
-
const
|
|
3136
|
+
const preferredRaw = Number(ws.port) || 3100;
|
|
3137
|
+
const preferred = isAdminListenPort(preferredRaw) ? 3100 : preferredRaw;
|
|
2717
3138
|
const label = ws.sandboxName || "this sandbox";
|
|
2718
3139
|
|
|
2719
3140
|
if (!fs.existsSync(folder)) {
|
|
@@ -2728,19 +3149,28 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2728
3149
|
return { port: preferred, up: false, launched: false };
|
|
2729
3150
|
}
|
|
2730
3151
|
|
|
2731
|
-
|
|
3152
|
+
const existing = embeddedChat.get(ws.sandboxId);
|
|
3153
|
+
if (existing && existing.workspaceDir === folder) {
|
|
3154
|
+
if (await isChatServerOnPort(existing.port)) {
|
|
3155
|
+
reserved.add(existing.port);
|
|
3156
|
+
ws.port = existing.port;
|
|
3157
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
3158
|
+
log(`start chat skip ${label}: already in-process on ${existing.port}`);
|
|
3159
|
+
return { port: existing.port, up: true, launched: false };
|
|
3160
|
+
}
|
|
3161
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3162
|
+
} else if (existing) {
|
|
3163
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
if (!opts.forceEmbed && (await isChatServerOnPort(preferred))) {
|
|
2732
3167
|
reserved.add(preferred);
|
|
3168
|
+
ws.port = preferred;
|
|
2733
3169
|
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2734
3170
|
log(`start chat skip ${label}: already up on ${preferred}`);
|
|
2735
3171
|
return { port: preferred, up: true, launched: false };
|
|
2736
3172
|
}
|
|
2737
3173
|
|
|
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
3174
|
let port = Number(opts.port) || preferred;
|
|
2745
3175
|
try {
|
|
2746
3176
|
if (!opts.port) {
|
|
@@ -2762,7 +3192,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2762
3192
|
return { port: preferred, up: false, launched: false };
|
|
2763
3193
|
}
|
|
2764
3194
|
if (port !== preferred) {
|
|
2765
|
-
log(`port ${preferred} busy; using ${port} for chat
|
|
3195
|
+
log(`port ${preferred} busy; using ${port} for chat`);
|
|
2766
3196
|
}
|
|
2767
3197
|
ws.port = port;
|
|
2768
3198
|
const localAi = `http://localhost:${port}`;
|
|
@@ -2776,59 +3206,52 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
2776
3206
|
(typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
|
|
2777
3207
|
"";
|
|
2778
3208
|
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
3209
|
persistWorkspaceEntry(cfg, ws);
|
|
2786
3210
|
|
|
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
|
-
|
|
3211
|
+
const storeEnv = await loadSandboxStoreEnv(ws, cfg);
|
|
3212
|
+
const folderEnv = readProjectEnvValues(folder);
|
|
3213
|
+
const dataDir = dataDirFor(folder, ws.sandboxId);
|
|
3214
|
+
const instanceEnv = {
|
|
3215
|
+
...folderEnv,
|
|
3216
|
+
...overrideEnv,
|
|
3217
|
+
...storeEnv,
|
|
3218
|
+
AI_SERVER_URL: aiUrl,
|
|
3219
|
+
CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
|
|
3220
|
+
CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
|
|
3221
|
+
MAINTAINER_PRO_DATA_DIR: dataDir,
|
|
3222
|
+
MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
|
|
3223
|
+
};
|
|
3224
|
+
|
|
3225
|
+
try {
|
|
3226
|
+
const instance = await startAiServer({
|
|
3227
|
+
port,
|
|
3228
|
+
workspaceDir: folder,
|
|
3229
|
+
uiDir: folder,
|
|
3230
|
+
dataDir,
|
|
3231
|
+
env: instanceEnv,
|
|
3232
|
+
logger: createLogger(`ai-server:${shortId(ws.sandboxId)}`),
|
|
3233
|
+
label: `ai-server:${shortId(ws.sandboxId)}`,
|
|
3234
|
+
listen: true,
|
|
3235
|
+
});
|
|
3236
|
+
embeddedChat.set(ws.sandboxId, instance);
|
|
3237
|
+
ws.port = instance.port;
|
|
3238
|
+
persistWorkspaceEntry(cfg, ws);
|
|
3239
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
3240
|
+
log(`chat in-process for ${label} on ${instance.port} (${folder})`);
|
|
3241
|
+
return { port: instance.port, up: true, launched: true };
|
|
3242
|
+
} catch (err) {
|
|
3243
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2820
3244
|
recordProcessProblem({
|
|
2821
3245
|
sandboxId: ws.sandboxId,
|
|
2822
3246
|
code: "ai_server_launch",
|
|
2823
3247
|
role: "ai",
|
|
2824
|
-
title: `Could not start
|
|
2825
|
-
message
|
|
3248
|
+
title: `Could not start chat for ${label}`,
|
|
3249
|
+
message,
|
|
2826
3250
|
resolution:
|
|
2827
|
-
"
|
|
3251
|
+
"Keep the bridge connected to Maintainer Pro, then Start Apps.",
|
|
2828
3252
|
});
|
|
2829
3253
|
return { port, up: false, launched: false };
|
|
2830
3254
|
}
|
|
2831
|
-
return { port, up: false, launched: true, starting: true };
|
|
2832
3255
|
}
|
|
2833
3256
|
|
|
2834
3257
|
function sleep(ms) {
|
|
@@ -2839,10 +3262,6 @@ function stopAllCloudflare() {
|
|
|
2839
3262
|
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
2840
3263
|
}
|
|
2841
3264
|
|
|
2842
|
-
function parseTryCloudflareUrl(text) {
|
|
2843
|
-
return lastTryCloudflareUrl(text);
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
3265
|
function killProcessesByCommand(fragment) {
|
|
2847
3266
|
if (!fragment) return Promise.resolve();
|
|
2848
3267
|
return new Promise((resolve) => {
|
|
@@ -2901,11 +3320,27 @@ function killPort(port) {
|
|
|
2901
3320
|
});
|
|
2902
3321
|
}
|
|
2903
3322
|
|
|
2904
|
-
function stopCloudflare(sandboxId) {
|
|
3323
|
+
function stopCloudflare(sandboxId, folder) {
|
|
2905
3324
|
const row = cloudflareTunnels.get(sandboxId);
|
|
2906
|
-
const files =
|
|
3325
|
+
const files = new Set(
|
|
3326
|
+
(row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [])
|
|
3327
|
+
);
|
|
2907
3328
|
cloudflareTunnels.delete(sandboxId);
|
|
2908
|
-
|
|
3329
|
+
const logDir = folder
|
|
3330
|
+
? dataDirFor(folder, sandboxId)
|
|
3331
|
+
: null;
|
|
3332
|
+
if (logDir && fs.existsSync(logDir)) {
|
|
3333
|
+
try {
|
|
3334
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
3335
|
+
if (/^cf-.*\.log$/i.test(name) && !name.endsWith(".stale")) {
|
|
3336
|
+
files.add(path.join(logDir, name));
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
} catch {
|
|
3340
|
+
/* ignore */
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return Promise.all([...files].map((file) => killProcessesByCommand(file)));
|
|
2909
3344
|
}
|
|
2910
3345
|
|
|
2911
3346
|
function forgetProcessLaunches(sandboxId) {
|
|
@@ -2918,12 +3353,18 @@ function forgetProcessLaunches(sandboxId) {
|
|
|
2918
3353
|
|
|
2919
3354
|
async function stopWorkspaceApps(ws) {
|
|
2920
3355
|
const folder = path.resolve(ws.folderPath || "");
|
|
2921
|
-
const jobs =
|
|
3356
|
+
const jobs = jobsFromHostApps(ws).length
|
|
3357
|
+
? jobsFromHostApps(ws)
|
|
3358
|
+
: planHostJobs(folder, null, ws.projectInfo);
|
|
2922
3359
|
const ports = new Set();
|
|
2923
3360
|
if (ws.port) ports.add(Number(ws.port));
|
|
2924
3361
|
for (const job of jobs) {
|
|
3362
|
+
if (job.port) ports.add(Number(job.port));
|
|
2925
3363
|
if (job.preferredPort) ports.add(Number(job.preferredPort));
|
|
2926
3364
|
}
|
|
3365
|
+
for (const app of Array.isArray(ws.hostApps) ? ws.hostApps : []) {
|
|
3366
|
+
if (app?.port) ports.add(Number(app.port));
|
|
3367
|
+
}
|
|
2927
3368
|
if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
|
|
2928
3369
|
try {
|
|
2929
3370
|
const port = Number(new URL(ws.appUrl).port);
|
|
@@ -2933,12 +3374,13 @@ async function stopWorkspaceApps(ws) {
|
|
|
2933
3374
|
}
|
|
2934
3375
|
}
|
|
2935
3376
|
log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
|
|
3377
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
2936
3378
|
await closeRememberedTerminals(ws.sandboxId);
|
|
2937
3379
|
for (const port of ports) {
|
|
2938
3380
|
await killPort(port);
|
|
2939
3381
|
}
|
|
2940
3382
|
forgetProcessLaunches(ws.sandboxId);
|
|
2941
|
-
await sleep(
|
|
3383
|
+
await sleep(800);
|
|
2942
3384
|
}
|
|
2943
3385
|
|
|
2944
3386
|
async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
@@ -2958,82 +3400,45 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
|
2958
3400
|
throw new Error(`${label} did not become reachable at ${url}`);
|
|
2959
3401
|
}
|
|
2960
3402
|
|
|
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 };
|
|
3403
|
+
function applyBackendPublicEnv(env, url) {
|
|
3404
|
+
const backend = String(url || "").replace(/\/$/, "");
|
|
3405
|
+
if (!backend) return env;
|
|
3406
|
+
env.API_URL = backend;
|
|
3407
|
+
env.API_BASE_URL = backend;
|
|
3408
|
+
env.BACKEND_URL = backend;
|
|
3409
|
+
env.VITE_API_URL = backend;
|
|
3410
|
+
env.VITE_API_BASE_URL = backend;
|
|
3411
|
+
env.NEXT_PUBLIC_API_URL = backend;
|
|
3412
|
+
env.NEXT_PUBLIC_API_BASE_URL = backend;
|
|
3413
|
+
env.REACT_APP_API_URL = backend;
|
|
3414
|
+
return env;
|
|
3016
3415
|
}
|
|
3017
3416
|
|
|
3018
|
-
function uiPublicEnv(tunnels) {
|
|
3417
|
+
function uiPublicEnv(tunnels, ws) {
|
|
3019
3418
|
/** @type {Record<string, string>} */
|
|
3020
3419
|
const env = {};
|
|
3021
|
-
|
|
3022
|
-
|
|
3420
|
+
const aiApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
|
|
3421
|
+
(app) => app.role === "ai-server" || app.id === "ai-server"
|
|
3422
|
+
) || { id: "ai-server", role: "ai-server" };
|
|
3423
|
+
const ai =
|
|
3424
|
+
proxyUrlForApp(ws, aiApp) ||
|
|
3425
|
+
(tunnels.ai ? String(tunnels.ai).replace(/\/$/, "") : "");
|
|
3426
|
+
if (ai) {
|
|
3023
3427
|
env.AI_SERVER_URL = ai;
|
|
3024
3428
|
env.NEXT_PUBLIC_AI_SERVER_URL = ai;
|
|
3025
3429
|
env.VITE_AI_SERVER_URL = ai;
|
|
3026
3430
|
env.REACT_APP_AI_SERVER_URL = ai;
|
|
3027
3431
|
}
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3432
|
+
const backendApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
|
|
3433
|
+
(app) => isBackendApp(app)
|
|
3434
|
+
);
|
|
3435
|
+
const backendUrl =
|
|
3436
|
+
(backendApp ? proxyUrlForApp(ws, backendApp) : "") ||
|
|
3437
|
+
(typeof tunnels.backend === "string" && tunnels.backend) ||
|
|
3438
|
+
(backendApp ? publicUrlForApp(ws, backendApp) : "") ||
|
|
3439
|
+
"";
|
|
3440
|
+
if (backendUrl && backendApp) {
|
|
3441
|
+
applyBackendPublicEnv(env, backendUrl);
|
|
3037
3442
|
}
|
|
3038
3443
|
if (tunnels.ui) {
|
|
3039
3444
|
const ui = String(tunnels.ui).replace(/\/$/, "");
|
|
@@ -3043,156 +3448,181 @@ function uiPublicEnv(tunnels) {
|
|
|
3043
3448
|
env.NEXT_PUBLIC_APP_URL = ui;
|
|
3044
3449
|
env.VITE_APP_URL = ui;
|
|
3045
3450
|
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 =
|
|
3451
|
+
} else if (tunnels.app) {
|
|
3452
|
+
const app = String(tunnels.app).replace(/\/$/, "");
|
|
3453
|
+
env.APP_URL = app;
|
|
3454
|
+
env.PUBLIC_URL = app;
|
|
3455
|
+
env.CORS_ORIGIN = app;
|
|
3456
|
+
env.NEXT_PUBLIC_APP_URL = app;
|
|
3457
|
+
env.VITE_APP_URL = app;
|
|
3458
|
+
env.REACT_APP_APP_URL = app;
|
|
3459
|
+
} else if (ai) {
|
|
3460
|
+
const hostUi = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).some(
|
|
3461
|
+
(app) => app.host || app.role === "ui" || app.role === "app"
|
|
3462
|
+
);
|
|
3463
|
+
if (!hostUi) {
|
|
3464
|
+
env.CORS_ORIGIN = ai;
|
|
3465
|
+
env.APP_URL = ai;
|
|
3466
|
+
env.PUBLIC_URL = ai;
|
|
3467
|
+
env.NEXT_PUBLIC_APP_URL = ai;
|
|
3468
|
+
env.VITE_APP_URL = ai;
|
|
3469
|
+
}
|
|
3053
3470
|
}
|
|
3054
3471
|
return env;
|
|
3055
3472
|
}
|
|
3056
3473
|
|
|
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 };
|
|
3474
|
+
function publicUrlForApp(ws, app) {
|
|
3475
|
+
if (!app) return "";
|
|
3476
|
+
const proxied = proxyUrlForApp(ws, app);
|
|
3477
|
+
if (proxied) return proxied;
|
|
3478
|
+
const port = Number(app.port) || 0;
|
|
3479
|
+
return port ? `http://localhost:${port}` : "";
|
|
3071
3480
|
}
|
|
3072
3481
|
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3482
|
+
function envFromConfiguredMaps(ws) {
|
|
3483
|
+
/** @type {Record<string, string>} */
|
|
3484
|
+
const env = {};
|
|
3485
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
3486
|
+
for (const app of apps) {
|
|
3487
|
+
const maps = Array.isArray(app.envMaps) ? app.envMaps : [];
|
|
3488
|
+
for (const row of maps) {
|
|
3489
|
+
const key = String(row?.key || "").trim();
|
|
3490
|
+
const sourceId = String(row?.sourceAppId || "").trim();
|
|
3491
|
+
if (!key || !sourceId) continue;
|
|
3492
|
+
const source =
|
|
3493
|
+
sourceId === "self"
|
|
3494
|
+
? app
|
|
3495
|
+
: apps.find((item) => item.id === sourceId) || null;
|
|
3496
|
+
const url = publicUrlForApp(ws, source);
|
|
3497
|
+
if (url) env[key] = url;
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
return env;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
function cloudflarePublicEnv(ws) {
|
|
3504
|
+
const tunnels =
|
|
3505
|
+
ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
|
|
3506
|
+
const jobs = jobsFromHostApps(ws);
|
|
3507
|
+
const env = {
|
|
3508
|
+
...envForWorkspacePorts(ws, jobs),
|
|
3509
|
+
...uiPublicEnv(tunnels, ws),
|
|
3510
|
+
};
|
|
3511
|
+
const origins = [];
|
|
3512
|
+
const add = (value) => {
|
|
3513
|
+
const origin = originFromUrl(value);
|
|
3514
|
+
if (origin && !origins.includes(origin)) origins.push(origin);
|
|
3515
|
+
};
|
|
3516
|
+
add(tunnels.ui);
|
|
3517
|
+
add(tunnels.app);
|
|
3518
|
+
add(tunnels.backend);
|
|
3519
|
+
add(tunnels.ai);
|
|
3520
|
+
add(env.APP_URL);
|
|
3521
|
+
add(env.CORS_ORIGIN);
|
|
3522
|
+
for (const job of jobs) {
|
|
3523
|
+
if (job.port) add(`http://localhost:${job.port}`);
|
|
3524
|
+
}
|
|
3525
|
+
add(`http://localhost:${Number(ws.port) || 3100}`);
|
|
3526
|
+
if (origins.length) {
|
|
3527
|
+
env.CORS_ORIGIN = origins.join(",");
|
|
3528
|
+
env.CORS_ORIGINS = origins.join(",");
|
|
3529
|
+
}
|
|
3530
|
+
Object.assign(env, envFromConfiguredMaps(ws));
|
|
3531
|
+
return env;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
async function applyPublicUrlsToRunningApps(ws, cfg, opts = {}) {
|
|
3535
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : "";
|
|
3079
3536
|
const progress =
|
|
3080
3537
|
typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
const
|
|
3084
|
-
|
|
3085
|
-
|
|
3538
|
+
const tunnels =
|
|
3539
|
+
ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
|
|
3540
|
+
const hasProxy = Boolean(ws.proxy?.token);
|
|
3541
|
+
if (
|
|
3542
|
+
!folder ||
|
|
3543
|
+
!fs.existsSync(folder) ||
|
|
3544
|
+
(!Object.keys(tunnels).length && !hasProxy)
|
|
3545
|
+
) {
|
|
3546
|
+
return { restarted: [], rewritten: [] };
|
|
3547
|
+
}
|
|
3548
|
+
const env = {
|
|
3549
|
+
...cloudflarePublicEnv(ws),
|
|
3086
3550
|
};
|
|
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}`
|
|
3551
|
+
if (Object.keys(tunnels).length) writeTunnelEnv(ws, tunnels);
|
|
3552
|
+
const mappedKeys = Object.keys(envFromConfiguredMaps(ws));
|
|
3553
|
+
if (mappedKeys.length) {
|
|
3554
|
+
activity(
|
|
3555
|
+
ws.sandboxId,
|
|
3556
|
+
"info",
|
|
3557
|
+
`Injecting public URLs into process env for ${mappedKeys.join(", ")}`
|
|
3105
3558
|
);
|
|
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
|
-
}
|
|
3559
|
+
await progress(`Passing ${mappedKeys.join(", ")} via process env`);
|
|
3120
3560
|
}
|
|
3121
3561
|
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
);
|
|
3562
|
+
const probe = await probeRunningApps(ws, 1200);
|
|
3563
|
+
const runningRoles = new Set(
|
|
3564
|
+
(probe.hostApps || [])
|
|
3565
|
+
.filter((app) => app.running)
|
|
3566
|
+
.map((app) =>
|
|
3567
|
+
app.role === "ai-server" ? "ai-server" : app.role === "custom" ? "app" : app.role
|
|
3568
|
+
)
|
|
3569
|
+
);
|
|
3570
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3571
|
+
const restarted = [];
|
|
3572
|
+
|
|
3573
|
+
if (probe.chatUp || runningRoles.has("ai-server") || embeddedChat.has(ws.sandboxId)) {
|
|
3574
|
+
await progress("Restarting in-process chat with public URLs and CORS…");
|
|
3575
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
|
|
3576
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3577
|
+
await startAiServerForWorkspace(ws, {
|
|
3578
|
+
reserved,
|
|
3579
|
+
cfg,
|
|
3580
|
+
port: ws.port,
|
|
3581
|
+
env,
|
|
3582
|
+
});
|
|
3583
|
+
restarted.push("ai-server");
|
|
3136
3584
|
}
|
|
3137
3585
|
|
|
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
|
-
})`
|
|
3586
|
+
const jobs = jobsFromHostApps(ws).filter((job) =>
|
|
3587
|
+
runningRoles.has(job.role === "custom" ? "app" : job.role)
|
|
3588
|
+
);
|
|
3589
|
+
if (jobs.length) {
|
|
3590
|
+
const roles = [...new Set(jobs.map((job) => job.role))];
|
|
3591
|
+
await progress(
|
|
3592
|
+
`Restarting ${roles.join(", ")} so they load the public URLs…`
|
|
3159
3593
|
);
|
|
3594
|
+
for (const job of jobs) {
|
|
3595
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
|
|
3596
|
+
await killPort(job.port);
|
|
3597
|
+
}
|
|
3598
|
+
await sleep(1500);
|
|
3599
|
+
await ensureHostProcesses(ws, {
|
|
3600
|
+
reserved,
|
|
3601
|
+
cfg,
|
|
3602
|
+
onlyRoles: roles,
|
|
3603
|
+
extraEnv: env,
|
|
3604
|
+
force: true,
|
|
3605
|
+
plannedJobs: jobsFromHostApps(ws),
|
|
3606
|
+
});
|
|
3607
|
+
restarted.push(...roles);
|
|
3160
3608
|
}
|
|
3161
3609
|
|
|
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
|
-
})`
|
|
3610
|
+
if (restarted.length) {
|
|
3611
|
+
activity(
|
|
3612
|
+
ws.sandboxId,
|
|
3613
|
+
"info",
|
|
3614
|
+
`Restarted ${[...new Set(restarted)].join(", ")} with public URLs`
|
|
3182
3615
|
);
|
|
3183
3616
|
}
|
|
3617
|
+
return { restarted: [...new Set(restarted)], rewritten: [], env };
|
|
3618
|
+
}
|
|
3184
3619
|
|
|
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 };
|
|
3620
|
+
function writeTunnelEnv(ws, tunnels) {
|
|
3621
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
3622
|
+
if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
|
|
3623
|
+
clearCloudflareTunnelFile(folder);
|
|
3624
|
+
const env = uiPublicEnv(tunnels, ws);
|
|
3625
|
+
return { ok: true, env };
|
|
3196
3626
|
}
|
|
3197
3627
|
|
|
3198
3628
|
function reservedPortsFor(cfg, sandboxId) {
|
|
@@ -3211,271 +3641,35 @@ function appsWanted(ws) {
|
|
|
3211
3641
|
|
|
3212
3642
|
async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
|
|
3213
3643
|
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
3644
|
const progress = (message) =>
|
|
3264
3645
|
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
|
-
);
|
|
3646
|
+
await progress("Using Maintainer Pro share URLs (no Cloudflare).");
|
|
3274
3647
|
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
|
-
);
|
|
3648
|
+
await stopCloudflare(sandboxId, ws.folderPath);
|
|
3649
|
+
} catch {
|
|
3650
|
+
/* leftover tunnels */
|
|
3437
3651
|
}
|
|
3438
|
-
|
|
3439
|
-
const appUrl = tunnels.ui || tunnels.ai;
|
|
3440
|
-
ws.cloudflareUrl = appUrl;
|
|
3441
|
-
ws.cloudflare = tunnels;
|
|
3442
|
-
ws.appUrl = appUrl;
|
|
3443
3652
|
ws.cloudflarePending = false;
|
|
3444
|
-
ws.appsRequested = true;
|
|
3445
3653
|
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
|
-
}
|
|
3654
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3655
|
+
timeoutMs: 2500,
|
|
3656
|
+
});
|
|
3657
|
+
const share = proxyUrlForApp(
|
|
3658
|
+
ws,
|
|
3659
|
+
hostAppOf(ws) || { id: "ui", role: "ui", host: true }
|
|
3660
|
+
);
|
|
3661
|
+
return {
|
|
3662
|
+
sandboxId,
|
|
3663
|
+
folderPath: ws.folderPath,
|
|
3664
|
+
port: ws.port,
|
|
3665
|
+
pending: false,
|
|
3666
|
+
cloudflarePending: false,
|
|
3667
|
+
waitingForStart: false,
|
|
3668
|
+
appUrl: status.host.appUrl || share,
|
|
3669
|
+
origins: status.host.origins,
|
|
3670
|
+
publicUrl: share || null,
|
|
3671
|
+
cloudflare: false,
|
|
3672
|
+
};
|
|
3479
3673
|
}
|
|
3480
3674
|
|
|
3481
3675
|
async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
@@ -3483,12 +3677,17 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3483
3677
|
await reportActionProgress(
|
|
3484
3678
|
cfg,
|
|
3485
3679
|
opts.actionId,
|
|
3486
|
-
`Checking running apps
|
|
3680
|
+
`Checking running apps for ${label}…`
|
|
3487
3681
|
);
|
|
3488
3682
|
|
|
3489
|
-
|
|
3683
|
+
try {
|
|
3684
|
+
await stopCloudflare(ws.sandboxId, ws.folderPath);
|
|
3685
|
+
} catch {
|
|
3686
|
+
/* leftover cloudflared */
|
|
3687
|
+
}
|
|
3688
|
+
ws.cloudflarePending = false;
|
|
3689
|
+
|
|
3490
3690
|
let status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3491
|
-
writeEnv: true,
|
|
3492
3691
|
timeoutMs: 2500,
|
|
3493
3692
|
});
|
|
3494
3693
|
log(
|
|
@@ -3498,15 +3697,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3498
3697
|
status.probe.hosts
|
|
3499
3698
|
.map((h) => `${h.role}:${h.up ? "up" : "down"}`)
|
|
3500
3699
|
.join(",") || "none"
|
|
3501
|
-
}
|
|
3700
|
+
}`
|
|
3502
3701
|
);
|
|
3503
3702
|
|
|
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
3703
|
await reportActionProgress(
|
|
3511
3704
|
cfg,
|
|
3512
3705
|
opts.actionId,
|
|
@@ -3516,16 +3709,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3516
3709
|
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3517
3710
|
const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
|
|
3518
3711
|
|
|
3519
|
-
// Re-sync env after port assignment (local or cloudflare urls).
|
|
3520
3712
|
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3521
|
-
writeEnv: true,
|
|
3522
3713
|
timeoutMs: 800,
|
|
3523
3714
|
});
|
|
3524
3715
|
|
|
3525
|
-
const
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3716
|
+
const shareEnv = {
|
|
3717
|
+
...envForWorkspacePorts(ws, plan.jobs),
|
|
3718
|
+
...uiPublicEnv({}, ws),
|
|
3719
|
+
...envFromConfiguredMaps(ws),
|
|
3720
|
+
};
|
|
3529
3721
|
|
|
3530
3722
|
if (!cfg.noAiServer) {
|
|
3531
3723
|
if (status.probe.chatUp) {
|
|
@@ -3538,7 +3730,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3538
3730
|
reserved,
|
|
3539
3731
|
cfg,
|
|
3540
3732
|
port: plan.aiPort,
|
|
3541
|
-
env:
|
|
3733
|
+
env: shareEnv,
|
|
3542
3734
|
});
|
|
3543
3735
|
await sleep(1500);
|
|
3544
3736
|
}
|
|
@@ -3553,12 +3745,15 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3553
3745
|
const probed = status.probe.hosts.find((h) => h.role === job.role);
|
|
3554
3746
|
return probed?.up ? { ...job, up: true, port: probed.port } : job;
|
|
3555
3747
|
}),
|
|
3556
|
-
extraEnv:
|
|
3748
|
+
extraEnv: shareEnv,
|
|
3557
3749
|
});
|
|
3558
3750
|
await sleep(800);
|
|
3559
3751
|
|
|
3752
|
+
await applyPublicUrlsToRunningApps(ws, cfg, {
|
|
3753
|
+
onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
|
|
3754
|
+
});
|
|
3755
|
+
|
|
3560
3756
|
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3561
|
-
writeEnv: true,
|
|
3562
3757
|
timeoutMs: 2500,
|
|
3563
3758
|
});
|
|
3564
3759
|
await inspectHostJobs(ws);
|
|
@@ -3570,11 +3765,11 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3570
3765
|
log(
|
|
3571
3766
|
`start apps done ${label}: chat=${ws.port} chatUp=${
|
|
3572
3767
|
status.aiServerUp
|
|
3573
|
-
} hosts=${startedHosts.join(",") || "none"}
|
|
3574
|
-
status.
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
}
|
|
3768
|
+
} hosts=${startedHosts.join(",") || "none"}${
|
|
3769
|
+
status.host.appUrl ? ` app=${status.host.appUrl}` : ""
|
|
3770
|
+
} origins=${status.host.origins.join(",") || "none"}${
|
|
3771
|
+
warning ? ` warning=${warning}` : ""
|
|
3772
|
+
}`
|
|
3578
3773
|
);
|
|
3579
3774
|
return {
|
|
3580
3775
|
up: status.aiServerUp,
|
|
@@ -3584,12 +3779,196 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
|
3584
3779
|
port: ws.port,
|
|
3585
3780
|
appUrl: status.host.appUrl || ws.appUrl,
|
|
3586
3781
|
origins: status.host.origins,
|
|
3587
|
-
|
|
3782
|
+
publicUrl:
|
|
3783
|
+
proxyUrlForApp(
|
|
3784
|
+
ws,
|
|
3785
|
+
hostAppOf(ws) || { id: "ui", role: "ui", host: true }
|
|
3786
|
+
) || null,
|
|
3787
|
+
cloudflare: false,
|
|
3788
|
+
hostApps: status.probe.hostApps || ws.hostApps || [],
|
|
3588
3789
|
processIssues,
|
|
3589
3790
|
warning,
|
|
3590
3791
|
};
|
|
3591
3792
|
}
|
|
3592
3793
|
|
|
3794
|
+
function findHostApp(ws, payload = {}) {
|
|
3795
|
+
const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
3796
|
+
const id = String(payload.appId || payload.id || "").trim();
|
|
3797
|
+
const role = String(payload.role || "").trim();
|
|
3798
|
+
const port = Number(payload.port) || 0;
|
|
3799
|
+
return (
|
|
3800
|
+
apps.find((app) => id && app.id === id) ||
|
|
3801
|
+
apps.find((app) => role && app.role === role) ||
|
|
3802
|
+
apps.find((app) => port && Number(app.port) === port) ||
|
|
3803
|
+
null
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
|
|
3808
|
+
const app = findHostApp(ws, payload);
|
|
3809
|
+
if (!app) {
|
|
3810
|
+
activity(ws.sandboxId, "error", "Start app failed: unknown app/port");
|
|
3811
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3812
|
+
}
|
|
3813
|
+
const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
|
|
3814
|
+
const alreadyUp = await portIsLive(app.port, 1200);
|
|
3815
|
+
if (alreadyUp) {
|
|
3816
|
+
activity(ws.sandboxId, "info", `${app.name} already running on ${app.port}`);
|
|
3817
|
+
await progress(`${app.name} is already running on port ${app.port}.`);
|
|
3818
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3819
|
+
timeoutMs: 1200,
|
|
3820
|
+
});
|
|
3821
|
+
return {
|
|
3822
|
+
app,
|
|
3823
|
+
running: true,
|
|
3824
|
+
port: app.port,
|
|
3825
|
+
hostApps: status.probe.hostApps || [],
|
|
3826
|
+
log: activityLogFor(ws.sandboxId),
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
activity(ws.sandboxId, "info", `Start ${app.name} on port ${app.port}`);
|
|
3830
|
+
await progress(`Starting ${app.name} on port ${app.port}…`);
|
|
3831
|
+
ws.appsRequested = true;
|
|
3832
|
+
persistWorkspaceEntry(cfg, ws);
|
|
3833
|
+
const publicEnv = {
|
|
3834
|
+
...cloudflarePublicEnv(ws),
|
|
3835
|
+
};
|
|
3836
|
+
if (app.role === "ai-server") {
|
|
3837
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3838
|
+
const chat = await discoverChatPort(ws, 1200);
|
|
3839
|
+
if (chat.up) {
|
|
3840
|
+
activity(ws.sandboxId, "info", `AI server already running on ${chat.port}`);
|
|
3841
|
+
ws.port = chat.port;
|
|
3842
|
+
} else {
|
|
3843
|
+
await startAiServerForWorkspace(ws, {
|
|
3844
|
+
reserved,
|
|
3845
|
+
cfg,
|
|
3846
|
+
port: Number(app.port) || ws.port || 3100,
|
|
3847
|
+
env: publicEnv,
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
} else {
|
|
3851
|
+
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3852
|
+
await ensureHostProcesses(ws, {
|
|
3853
|
+
reserved,
|
|
3854
|
+
cfg,
|
|
3855
|
+
onlyRoles: [app.role === "custom" ? "app" : app.role],
|
|
3856
|
+
extraEnv: publicEnv,
|
|
3857
|
+
force: true,
|
|
3858
|
+
plannedJobs: jobsFromHostApps(ws),
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3862
|
+
timeoutMs: 2500,
|
|
3863
|
+
});
|
|
3864
|
+
const live = (status.probe.hostApps || []).find((row) => row.id === app.id);
|
|
3865
|
+
activity(
|
|
3866
|
+
ws.sandboxId,
|
|
3867
|
+
live?.running ? "info" : "warn",
|
|
3868
|
+
live?.running
|
|
3869
|
+
? `${app.name} is running on ${live.port}`
|
|
3870
|
+
: `${app.name} did not answer on port ${app.port}. Check the MP terminal.`
|
|
3871
|
+
);
|
|
3872
|
+
return {
|
|
3873
|
+
app,
|
|
3874
|
+
running: Boolean(live?.running),
|
|
3875
|
+
port: live?.port || app.port,
|
|
3876
|
+
hostApps: status.probe.hostApps || [],
|
|
3877
|
+
log: activityLogFor(ws.sandboxId),
|
|
3878
|
+
warning: live?.running
|
|
3879
|
+
? null
|
|
3880
|
+
: `${app.name} did not answer on port ${app.port}. Check the terminal on that computer.`,
|
|
3881
|
+
};
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
async function stopSingleApp(ws, app) {
|
|
3885
|
+
const port = Number(app.port) || 0;
|
|
3886
|
+
const role =
|
|
3887
|
+
app.role === "ai-server"
|
|
3888
|
+
? "ai-server"
|
|
3889
|
+
: app.role === "custom"
|
|
3890
|
+
? "app"
|
|
3891
|
+
: app.role || "app";
|
|
3892
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
3893
|
+
await closeWindowsByTitle(`MP-${role}-${port}`);
|
|
3894
|
+
if (role === "app") await closeWindowsByTitle(`MP-custom-${port}`);
|
|
3895
|
+
if (app.role === "ai-server" || app.id === "ai-server") {
|
|
3896
|
+
await stopEmbeddedChat(ws.sandboxId);
|
|
3897
|
+
}
|
|
3898
|
+
if (port) {
|
|
3899
|
+
await killPort(port);
|
|
3900
|
+
const until = Date.now() + 4000;
|
|
3901
|
+
while (Date.now() < until && (await portIsLive(port, 300))) {
|
|
3902
|
+
await killPort(port);
|
|
3903
|
+
await sleep(300);
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
const job = jobsFromHostApps(ws).find((row) => row.appId === app.id);
|
|
3907
|
+
if (job) launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
|
|
3908
|
+
launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
|
|
3909
|
+
await sleep(800);
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
|
|
3913
|
+
const app = findHostApp(ws, payload);
|
|
3914
|
+
if (!app) {
|
|
3915
|
+
activity(ws.sandboxId, "error", "Restart app failed: unknown app/port");
|
|
3916
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3917
|
+
}
|
|
3918
|
+
const progress = (message) =>
|
|
3919
|
+
reportActionProgress(cfg, opts.actionId, message);
|
|
3920
|
+
activity(ws.sandboxId, "info", `Restart ${app.name} on ${app.port}`);
|
|
3921
|
+
await progress(`Stopping ${app.name} on port ${app.port}…`);
|
|
3922
|
+
await stopSingleApp(ws, app);
|
|
3923
|
+
return startSingleApp(ws, cfg, payload, opts);
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
|
|
3927
|
+
const app = findHostApp(ws, payload);
|
|
3928
|
+
if (!app) {
|
|
3929
|
+
activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
|
|
3930
|
+
return { error: "Unknown app. Detect or add the port first." };
|
|
3931
|
+
}
|
|
3932
|
+
const progress = (message) => reportActionProgress(cfg, opts.actionId, message);
|
|
3933
|
+
if (usesBridgeProxy(app)) {
|
|
3934
|
+
const shareUrl = proxyUrlForApp(ws, app);
|
|
3935
|
+
if (!shareUrl) {
|
|
3936
|
+
return {
|
|
3937
|
+
error:
|
|
3938
|
+
"Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
|
|
3939
|
+
};
|
|
3940
|
+
}
|
|
3941
|
+
await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
|
|
3942
|
+
const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
|
|
3943
|
+
if (started.error) return started;
|
|
3944
|
+
const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
|
|
3945
|
+
onProgress: progress,
|
|
3946
|
+
});
|
|
3947
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3948
|
+
timeoutMs: 2500,
|
|
3949
|
+
});
|
|
3950
|
+
activity(ws.sandboxId, "info", `${app.name} share URL: ${shareUrl}`);
|
|
3951
|
+
return {
|
|
3952
|
+
app,
|
|
3953
|
+
running: Boolean(started.running),
|
|
3954
|
+
port: started.port,
|
|
3955
|
+
publicUrl: shareUrl,
|
|
3956
|
+
cloudflareUrl: null,
|
|
3957
|
+
appUrl: status.host.appUrl,
|
|
3958
|
+
origins: status.host.origins,
|
|
3959
|
+
hostApps: status.probe.hostApps || [],
|
|
3960
|
+
rewritten: applied.rewritten,
|
|
3961
|
+
restarted: applied.restarted,
|
|
3962
|
+
log: activityLogFor(ws.sandboxId),
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
return {
|
|
3966
|
+
error:
|
|
3967
|
+
"Share URL is not ready. Keep the bridge connected to Maintainer Pro.",
|
|
3968
|
+
app,
|
|
3969
|
+
};
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3593
3972
|
async function ensureHostProcesses(ws, opts = {}) {
|
|
3594
3973
|
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
3595
3974
|
const cfg = opts.cfg || null;
|
|
@@ -3708,7 +4087,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
3708
4087
|
continue;
|
|
3709
4088
|
}
|
|
3710
4089
|
started.push(job.role);
|
|
3711
|
-
log(
|
|
4090
|
+
log(
|
|
4091
|
+
`start ${job.role} launched ${label} on ${port}: ${command}${
|
|
4092
|
+
Object.keys(extraEnv).length
|
|
4093
|
+
? ` env=${Object.keys(extraEnv).join(",")}`
|
|
4094
|
+
: ""
|
|
4095
|
+
}`
|
|
4096
|
+
);
|
|
3712
4097
|
if (
|
|
3713
4098
|
(job.role === "ui" || job.role === "app") &&
|
|
3714
4099
|
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
@@ -3724,11 +4109,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
3724
4109
|
|
|
3725
4110
|
async function inspectHostJobs(ws) {
|
|
3726
4111
|
const folder = path.resolve(ws.folderPath || "");
|
|
3727
|
-
const jobs =
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
4112
|
+
const jobs = jobsFromHostApps(ws).length
|
|
4113
|
+
? jobsFromHostApps(ws)
|
|
4114
|
+
: planHostJobs(
|
|
4115
|
+
folder,
|
|
4116
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
4117
|
+
ws.projectInfo
|
|
4118
|
+
);
|
|
3732
4119
|
const label = ws.sandboxName || "this sandbox";
|
|
3733
4120
|
const hosts = [];
|
|
3734
4121
|
for (const job of jobs) {
|
|
@@ -3788,7 +4175,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
3788
4175
|
}
|
|
3789
4176
|
}
|
|
3790
4177
|
let port = requestedPort;
|
|
3791
|
-
if (await
|
|
4178
|
+
if (await isChatServerOnPort(requestedPort)) {
|
|
3792
4179
|
reserved.add(requestedPort);
|
|
3793
4180
|
log(`setup chat port ${requestedPort} already up`);
|
|
3794
4181
|
} else {
|
|
@@ -3810,6 +4197,12 @@ async function setupWorkspace(cfg, action) {
|
|
|
3810
4197
|
throw new Error("setup_workspace requires folderPath and sandboxId");
|
|
3811
4198
|
}
|
|
3812
4199
|
const resolved = path.resolve(folderPath);
|
|
4200
|
+
const allowed = collectOfferedFolders();
|
|
4201
|
+
if (!allowed.some((root) => pathInside(resolved, root))) {
|
|
4202
|
+
throw new Error(
|
|
4203
|
+
"That folder is outside the directory where the bridge is running. Start the bridge from the project folder you want to share."
|
|
4204
|
+
);
|
|
4205
|
+
}
|
|
3813
4206
|
fs.mkdirSync(resolved, { recursive: true });
|
|
3814
4207
|
|
|
3815
4208
|
const config = await api(
|
|
@@ -3836,30 +4229,10 @@ async function setupWorkspace(cfg, action) {
|
|
|
3836
4229
|
const corsOrigin = client.corsOrigin || aiOrigin;
|
|
3837
4230
|
const appUrl = client.appUrl || corsOrigin;
|
|
3838
4231
|
|
|
3839
|
-
const envPath = path.join(resolved, ".env");
|
|
3840
4232
|
const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
|
|
3841
4233
|
? config.aiIgnorePaths
|
|
3842
4234
|
: [];
|
|
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);
|
|
4235
|
+
const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
|
|
3863
4236
|
|
|
3864
4237
|
cfg.workspaces = cfg.workspaces || [];
|
|
3865
4238
|
const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
|
|
@@ -3874,36 +4247,42 @@ async function setupWorkspace(cfg, action) {
|
|
|
3874
4247
|
sameOrigin: Boolean(client.sameOrigin),
|
|
3875
4248
|
appsRequested: false,
|
|
3876
4249
|
cloudflarePending: false,
|
|
4250
|
+
store: {
|
|
4251
|
+
serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
|
|
4252
|
+
clientKey: String(
|
|
4253
|
+
config.env?.MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
4254
|
+
config.env?.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ||
|
|
4255
|
+
""
|
|
4256
|
+
).trim(),
|
|
4257
|
+
},
|
|
3877
4258
|
};
|
|
3878
4259
|
if (existing >= 0) cfg.workspaces[existing] = entry;
|
|
3879
4260
|
else cfg.workspaces.push(entry);
|
|
3880
|
-
if (!cfg.offeredFolders) cfg.offeredFolders = [];
|
|
3881
|
-
if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
|
|
3882
4261
|
saveConfig(cfg);
|
|
3883
4262
|
|
|
3884
|
-
const
|
|
4263
|
+
const ports = await resolveWorkspaceHostApps(entry, {
|
|
3885
4264
|
cfg,
|
|
3886
|
-
|
|
3887
|
-
`Scaffold kind: ${client.kind}`,
|
|
3888
|
-
`Chat script port: ${port}`,
|
|
3889
|
-
client.notes.join(" "),
|
|
3890
|
-
]
|
|
3891
|
-
.filter(Boolean)
|
|
3892
|
-
.join("\n"),
|
|
4265
|
+
allowAi: false,
|
|
3893
4266
|
});
|
|
4267
|
+
const needsReview =
|
|
4268
|
+
Boolean(ports.confused) ||
|
|
4269
|
+
!(ports.apps || []).some((app) => app && app.role !== "ai-server");
|
|
4270
|
+
if (needsReview) {
|
|
4271
|
+
activity(
|
|
4272
|
+
sandboxId,
|
|
4273
|
+
"warn",
|
|
4274
|
+
"Review the suggested apps and ports in Maintainer Pro before Start Apps."
|
|
4275
|
+
);
|
|
4276
|
+
}
|
|
4277
|
+
// Do not auto-repair the repo on attach — interactive Review setup handles suggestions.
|
|
4278
|
+
const projectInfo = entry.projectInfo || null;
|
|
3894
4279
|
|
|
3895
4280
|
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
4281
|
|
|
3903
4282
|
const openUrl = client.sameOrigin
|
|
3904
4283
|
? `http://localhost:${entry.port}`
|
|
3905
4284
|
: entry.appUrl || appUrl;
|
|
3906
|
-
const aiServerUp = await
|
|
4285
|
+
const aiServerUp = await isChatServerOnPort(entry.port);
|
|
3907
4286
|
if (aiServerUp) {
|
|
3908
4287
|
clearProcessProblem(sandboxId, "ai_server_launch", "ai");
|
|
3909
4288
|
clearProcessProblem(sandboxId, "apps_not_started");
|
|
@@ -3914,7 +4293,9 @@ async function setupWorkspace(cfg, action) {
|
|
|
3914
4293
|
);
|
|
3915
4294
|
const waitingForStart = !aiServerUp;
|
|
3916
4295
|
const warning = waitingForStart
|
|
3917
|
-
?
|
|
4296
|
+
? needsReview
|
|
4297
|
+
? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
|
|
4298
|
+
: "Folder is attached in Maintainer Pro. Review apps if needed, then use Start Apps."
|
|
3918
4299
|
: processIssues[0]?.message || null;
|
|
3919
4300
|
|
|
3920
4301
|
for (const note of client.notes) log(`setup note ${note}`);
|
|
@@ -3933,7 +4314,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
3933
4314
|
origins: host.origins.length
|
|
3934
4315
|
? host.origins
|
|
3935
4316
|
: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
|
|
3936
|
-
wroteEnv:
|
|
4317
|
+
wroteEnv: false,
|
|
3937
4318
|
clientKind: client.kind,
|
|
3938
4319
|
clientFiles: client.filesWritten,
|
|
3939
4320
|
clientNotes: client.notes,
|
|
@@ -3943,11 +4324,98 @@ async function setupWorkspace(cfg, action) {
|
|
|
3943
4324
|
processIssues,
|
|
3944
4325
|
warning,
|
|
3945
4326
|
waitingForStart,
|
|
4327
|
+
needsReview,
|
|
4328
|
+
hostApps: ports.apps || entry.hostApps || [],
|
|
4329
|
+
reasons: ports.reasons || [],
|
|
3946
4330
|
projectInfo,
|
|
3947
4331
|
ignorePaths: access.ignorePaths,
|
|
3948
4332
|
};
|
|
3949
4333
|
}
|
|
3950
4334
|
|
|
4335
|
+
async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
|
|
4336
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
4337
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
4338
|
+
activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
|
|
4339
|
+
try {
|
|
4340
|
+
const cli = await loadAiCli();
|
|
4341
|
+
if (typeof cli.proposeHostAppsFromConfig !== "function") {
|
|
4342
|
+
throw new Error(
|
|
4343
|
+
"ai-cli is missing proposeHostAppsFromConfig — update @maintainer-pro/ai-cli"
|
|
4344
|
+
);
|
|
4345
|
+
}
|
|
4346
|
+
const proposal = await cli.proposeHostAppsFromConfig({
|
|
4347
|
+
workspaceDir: folder,
|
|
4348
|
+
appName: ws.applicationName || ws.sandboxName,
|
|
4349
|
+
preferredAiPort: Number(ws.port) || 3100,
|
|
4350
|
+
allowAi: opts.allowAi !== false,
|
|
4351
|
+
});
|
|
4352
|
+
const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
|
|
4353
|
+
const apps = (proposal.apps || []).map((app) => {
|
|
4354
|
+
const match = previous.find((row) => row && row.id === app.id);
|
|
4355
|
+
if (!match || (app.envMaps && app.envMaps.length)) return app;
|
|
4356
|
+
return { ...app, envMaps: match.envMaps || [] };
|
|
4357
|
+
});
|
|
4358
|
+
// Keep last known apps on the workspace; do not auto-apply proposal.
|
|
4359
|
+
if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
|
|
4360
|
+
ws.hostApps = apps;
|
|
4361
|
+
persistWorkspaceEntry(cfg, ws);
|
|
4362
|
+
}
|
|
4363
|
+
activity(
|
|
4364
|
+
ws.sandboxId,
|
|
4365
|
+
proposal.needsReview ? "warn" : "info",
|
|
4366
|
+
`setup suggestion ${proposal.confidence}${proposal.usedAi ? " + ai" : ""}: ${
|
|
4367
|
+
proposal.projectSummary || apps.map((a) => `${a.name}:${a.port}`).join(", ")
|
|
4368
|
+
}`
|
|
4369
|
+
);
|
|
4370
|
+
for (const reason of proposal.reasons || []) {
|
|
4371
|
+
activity(ws.sandboxId, "warn", reason);
|
|
4372
|
+
}
|
|
4373
|
+
return {
|
|
4374
|
+
proposal: {
|
|
4375
|
+
apps,
|
|
4376
|
+
alternatives: proposal.alternatives || [],
|
|
4377
|
+
reasons: proposal.reasons || [],
|
|
4378
|
+
confidence: proposal.confidence,
|
|
4379
|
+
projectSummary: proposal.projectSummary || "",
|
|
4380
|
+
usedAi: Boolean(proposal.usedAi),
|
|
4381
|
+
needsReview: Boolean(proposal.needsReview),
|
|
4382
|
+
fingerprint: proposal.fingerprint || null,
|
|
4383
|
+
},
|
|
4384
|
+
hostApps: apps,
|
|
4385
|
+
reasons: proposal.reasons || [],
|
|
4386
|
+
usedAi: Boolean(proposal.usedAi),
|
|
4387
|
+
needsReview: Boolean(proposal.needsReview),
|
|
4388
|
+
log: activityLogFor(ws.sandboxId),
|
|
4389
|
+
};
|
|
4390
|
+
} catch (err) {
|
|
4391
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4392
|
+
activity(ws.sandboxId, "warn", `setup suggestion failed: ${message}`);
|
|
4393
|
+
const fallback = await resolveWorkspaceHostApps(ws, {
|
|
4394
|
+
cfg,
|
|
4395
|
+
force: true,
|
|
4396
|
+
ignoreDesired: true,
|
|
4397
|
+
allowAi: false,
|
|
4398
|
+
});
|
|
4399
|
+
return {
|
|
4400
|
+
proposal: {
|
|
4401
|
+
apps: fallback.apps,
|
|
4402
|
+
alternatives: [],
|
|
4403
|
+
reasons: [...(fallback.reasons || []), message],
|
|
4404
|
+
confidence: "low",
|
|
4405
|
+
projectSummary: "Could not finish AI suggest — showing file detection.",
|
|
4406
|
+
usedAi: false,
|
|
4407
|
+
needsReview: true,
|
|
4408
|
+
fingerprint: fallback.fingerprint || null,
|
|
4409
|
+
},
|
|
4410
|
+
hostApps: fallback.apps,
|
|
4411
|
+
reasons: fallback.reasons || [],
|
|
4412
|
+
usedAi: false,
|
|
4413
|
+
needsReview: true,
|
|
4414
|
+
log: activityLogFor(ws.sandboxId),
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
|
|
3951
4419
|
async function runActions(cfg, actions) {
|
|
3952
4420
|
if (!actions.length) return;
|
|
3953
4421
|
log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
|
|
@@ -3960,9 +4428,11 @@ async function runActions(cfg, actions) {
|
|
|
3960
4428
|
let result = {};
|
|
3961
4429
|
try {
|
|
3962
4430
|
if (action.code === "browse") {
|
|
3963
|
-
const
|
|
4431
|
+
const allowed = collectOfferedFolders();
|
|
4432
|
+
const p = String(action.payload?.path || "roots");
|
|
3964
4433
|
log(`${label} browse ${p}`);
|
|
3965
|
-
result = listDirEntries(p);
|
|
4434
|
+
result = listDirEntries(p, allowed);
|
|
4435
|
+
if (result.error) ok = false;
|
|
3966
4436
|
log(`${label} browse ${result.entries?.length ?? 0} entries`);
|
|
3967
4437
|
} else if (action.code === "setup_workspace") {
|
|
3968
4438
|
result = await setupWorkspace(cfg, action);
|
|
@@ -3984,7 +4454,11 @@ async function runActions(cfg, actions) {
|
|
|
3984
4454
|
const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
|
|
3985
4455
|
? action.payload.aiIgnorePaths
|
|
3986
4456
|
: [];
|
|
3987
|
-
const access = applyAccessPolicy(
|
|
4457
|
+
const access = applyAccessPolicy(
|
|
4458
|
+
ws.folderPath,
|
|
4459
|
+
partnerIgnorePaths,
|
|
4460
|
+
ws.sandboxId
|
|
4461
|
+
);
|
|
3988
4462
|
log(
|
|
3989
4463
|
`access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
|
|
3990
4464
|
);
|
|
@@ -4000,14 +4474,96 @@ async function runActions(cfg, actions) {
|
|
|
4000
4474
|
if (!ws) {
|
|
4001
4475
|
log(`${label} recheck: no local workspace`);
|
|
4002
4476
|
}
|
|
4003
|
-
const
|
|
4004
|
-
? await
|
|
4477
|
+
const hostApps = ws
|
|
4478
|
+
? await resolveWorkspaceHostApps(ws, { cfg, force: true, allowAi: false })
|
|
4005
4479
|
: null;
|
|
4006
4480
|
result = {
|
|
4007
4481
|
recheckedAt: new Date().toISOString(),
|
|
4008
|
-
|
|
4482
|
+
hostApps: hostApps?.apps || [],
|
|
4483
|
+
projectInfo: ws?.projectInfo || null,
|
|
4009
4484
|
};
|
|
4010
|
-
} else if (action.code === "
|
|
4485
|
+
} else if (action.code === "redetect_ports" || action.code === "propose_setup") {
|
|
4486
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4487
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4488
|
+
if (!ws) {
|
|
4489
|
+
ok = false;
|
|
4490
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4491
|
+
} else {
|
|
4492
|
+
activity(
|
|
4493
|
+
ws.sandboxId,
|
|
4494
|
+
"info",
|
|
4495
|
+
action.code === "propose_setup"
|
|
4496
|
+
? "Setup suggestions requested from Maintainer Pro"
|
|
4497
|
+
: "Detect ports requested from Maintainer Pro"
|
|
4498
|
+
);
|
|
4499
|
+
result = await proposeSetupForWorkspace(ws, cfg, {
|
|
4500
|
+
allowAi: action.payload?.allowAi !== false,
|
|
4501
|
+
});
|
|
4502
|
+
}
|
|
4503
|
+
} else if (action.code === "update_host_apps") {
|
|
4504
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4505
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4506
|
+
if (!ws) {
|
|
4507
|
+
ok = false;
|
|
4508
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4509
|
+
} else {
|
|
4510
|
+
const desired = Array.isArray(action.payload?.hostApps)
|
|
4511
|
+
? action.payload.hostApps
|
|
4512
|
+
: [];
|
|
4513
|
+
activity(
|
|
4514
|
+
ws.sandboxId,
|
|
4515
|
+
"info",
|
|
4516
|
+
`Applying ports from Maintainer Pro: ${desired
|
|
4517
|
+
.map((app) => `${app.name || app.id}:${app.port}`)
|
|
4518
|
+
.join(", ")}`
|
|
4519
|
+
);
|
|
4520
|
+
const resolved = await resolveWorkspaceHostApps(ws, {
|
|
4521
|
+
cfg,
|
|
4522
|
+
desired,
|
|
4523
|
+
allowAi: false,
|
|
4524
|
+
});
|
|
4525
|
+
result = { hostApps: resolved.apps, log: activityLogFor(ws.sandboxId) };
|
|
4526
|
+
}
|
|
4527
|
+
} else if (action.code === "start_app") {
|
|
4528
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4529
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4530
|
+
if (!ws) {
|
|
4531
|
+
ok = false;
|
|
4532
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4533
|
+
} else {
|
|
4534
|
+
result = await startSingleApp(ws, cfg, action.payload || {}, {
|
|
4535
|
+
actionId: action.id,
|
|
4536
|
+
});
|
|
4537
|
+
if (result.error) ok = false;
|
|
4538
|
+
}
|
|
4539
|
+
} else if (action.code === "restart_app") {
|
|
4540
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4541
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4542
|
+
if (!ws) {
|
|
4543
|
+
ok = false;
|
|
4544
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4545
|
+
} else {
|
|
4546
|
+
result = await restartSingleApp(ws, cfg, action.payload || {}, {
|
|
4547
|
+
actionId: action.id,
|
|
4548
|
+
});
|
|
4549
|
+
if (result.error) ok = false;
|
|
4550
|
+
}
|
|
4551
|
+
} else if (action.code === "start_cloudflare_app") {
|
|
4552
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4553
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4554
|
+
if (!ws) {
|
|
4555
|
+
ok = false;
|
|
4556
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
4557
|
+
} else {
|
|
4558
|
+
result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
|
|
4559
|
+
actionId: action.id,
|
|
4560
|
+
});
|
|
4561
|
+
if (result.error) ok = false;
|
|
4562
|
+
}
|
|
4563
|
+
} else if (
|
|
4564
|
+
action.code === "start_ai_server" ||
|
|
4565
|
+
action.code === "restart_apps"
|
|
4566
|
+
) {
|
|
4011
4567
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
4012
4568
|
const ws =
|
|
4013
4569
|
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
@@ -4023,10 +4579,24 @@ async function runActions(cfg, actions) {
|
|
|
4023
4579
|
result = { error: "No workspace or --no-ai-server" };
|
|
4024
4580
|
warn(`${label} skipped: ${result.error}`);
|
|
4025
4581
|
} else {
|
|
4582
|
+
const restarting = action.code === "restart_apps";
|
|
4583
|
+
log(
|
|
4584
|
+
`${label} ${restarting ? "restart" : "start"} ${ws.folderPath}`
|
|
4585
|
+
);
|
|
4586
|
+
if (restarting) {
|
|
4587
|
+
await reportActionProgress(
|
|
4588
|
+
cfg,
|
|
4589
|
+
action.id,
|
|
4590
|
+
`Stopping apps for ${ws.sandboxName || shortId(ws.sandboxId)}…`
|
|
4591
|
+
);
|
|
4592
|
+
await stopWorkspaceApps(ws);
|
|
4593
|
+
}
|
|
4026
4594
|
// Prefer cached project inspect; only call ai-cli when missing or
|
|
4027
4595
|
// after a failed start that used the cache.
|
|
4028
|
-
|
|
4029
|
-
|
|
4596
|
+
let projectInfo = await resolveWorkspaceHostApps(ws, {
|
|
4597
|
+
cfg,
|
|
4598
|
+
allowAi: false,
|
|
4599
|
+
});
|
|
4030
4600
|
let started = await startAppsForWorkspace(ws, cfg, {
|
|
4031
4601
|
actionId: action.id,
|
|
4032
4602
|
});
|
|
@@ -4182,7 +4752,6 @@ async function collectWorkspaceStates(cfg) {
|
|
|
4182
4752
|
for (const ws of cfg.workspaces || []) {
|
|
4183
4753
|
const folder = path.resolve(ws.folderPath || "");
|
|
4184
4754
|
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
4185
|
-
writeEnv: true,
|
|
4186
4755
|
timeoutMs: 800,
|
|
4187
4756
|
});
|
|
4188
4757
|
localStates.push({
|
|
@@ -4194,14 +4763,13 @@ async function collectWorkspaceStates(cfg) {
|
|
|
4194
4763
|
appsRunning: status.appsRunning,
|
|
4195
4764
|
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
4196
4765
|
appUrl:
|
|
4197
|
-
status.appsRunning
|
|
4766
|
+
status.appsRunning
|
|
4198
4767
|
? status.host.appUrl || ws.appUrl || null
|
|
4199
4768
|
: null,
|
|
4200
|
-
origins:
|
|
4201
|
-
status.appsRunning || status.usingCloudflare
|
|
4202
|
-
? status.host.origins
|
|
4203
|
-
: [],
|
|
4769
|
+
origins: status.appsRunning ? status.host.origins : [],
|
|
4204
4770
|
appsRequested: appsWanted(ws) || status.appsRunning,
|
|
4771
|
+
hostApps: status.probe.hostApps || ws.hostApps || [],
|
|
4772
|
+
activityLog: activityLogFor(ws.sandboxId),
|
|
4205
4773
|
});
|
|
4206
4774
|
}
|
|
4207
4775
|
return localStates;
|
|
@@ -4213,24 +4781,36 @@ const lastHostReports = new Map();
|
|
|
4213
4781
|
function syncAssignedWorkspaces(cfg, remotes) {
|
|
4214
4782
|
if (!Array.isArray(remotes)) return;
|
|
4215
4783
|
for (const remote of remotes) {
|
|
4216
|
-
|
|
4784
|
+
let local = (cfg.workspaces || []).find(
|
|
4217
4785
|
(w) => w.sandboxId === remote.sandboxId
|
|
4218
4786
|
);
|
|
4219
4787
|
if (!local) {
|
|
4220
4788
|
cfg.workspaces = cfg.workspaces || [];
|
|
4221
|
-
|
|
4789
|
+
local = {
|
|
4222
4790
|
sandboxId: remote.sandboxId,
|
|
4223
4791
|
folderPath: remote.folderPath,
|
|
4224
4792
|
port: remote.port,
|
|
4225
4793
|
sandboxName: remote.sandboxName,
|
|
4226
4794
|
applicationName: remote.applicationName,
|
|
4227
|
-
}
|
|
4795
|
+
};
|
|
4796
|
+
cfg.workspaces.push(local);
|
|
4228
4797
|
saveConfig(cfg);
|
|
4229
4798
|
} else if (local.folderPath !== remote.folderPath) {
|
|
4230
4799
|
local.folderPath = remote.folderPath;
|
|
4231
4800
|
local.port = remote.port;
|
|
4232
4801
|
saveConfig(cfg);
|
|
4233
4802
|
}
|
|
4803
|
+
if (Array.isArray(remote.hostApps) && remote.hostApps.length) {
|
|
4804
|
+
const localCount = Array.isArray(local.hostApps) ? local.hostApps.length : 0;
|
|
4805
|
+
if (remote.hostApps.length >= localCount) {
|
|
4806
|
+
local.hostApps = remote.hostApps;
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
const prevToken = local.proxy?.token;
|
|
4810
|
+
applyAssignedProxy(local, remote, cfg);
|
|
4811
|
+
if (local.proxy?.token && local.proxy.token !== prevToken) {
|
|
4812
|
+
persistWorkspaceEntry(cfg, local);
|
|
4813
|
+
}
|
|
4234
4814
|
}
|
|
4235
4815
|
}
|
|
4236
4816
|
|
|
@@ -4263,6 +4843,8 @@ async function sendHeartbeat(cfg, folders, localStates) {
|
|
|
4263
4843
|
? st.origins
|
|
4264
4844
|
: undefined,
|
|
4265
4845
|
appsRequested: Boolean(st.appsRequested),
|
|
4846
|
+
hostApps: st.hostApps || [],
|
|
4847
|
+
activityLog: st.activityLog || [],
|
|
4266
4848
|
})),
|
|
4267
4849
|
};
|
|
4268
4850
|
return api(
|
|
@@ -4314,6 +4896,8 @@ async function buildHeartbeatPayload(cfg, folders, localStates) {
|
|
|
4314
4896
|
? st.origins
|
|
4315
4897
|
: undefined,
|
|
4316
4898
|
appsRequested: Boolean(st.appsRequested),
|
|
4899
|
+
hostApps: st.hostApps || [],
|
|
4900
|
+
activityLog: st.activityLog || [],
|
|
4317
4901
|
})),
|
|
4318
4902
|
};
|
|
4319
4903
|
}
|
|
@@ -4464,32 +5048,24 @@ async function main() {
|
|
|
4464
5048
|
{ logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
|
|
4465
5049
|
"bridge starting"
|
|
4466
5050
|
);
|
|
5051
|
+
void warnIfBridgeOutdated();
|
|
4467
5052
|
|
|
4468
5053
|
let cfg = loadConfig() || {};
|
|
4469
5054
|
ensureMachineId(cfg);
|
|
4470
5055
|
|
|
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
5056
|
if (args.pair || !cfg.token || !cfg.adminUrl) {
|
|
4482
5057
|
cfg = await pairFlow(args);
|
|
4483
5058
|
}
|
|
4484
5059
|
|
|
4485
5060
|
cfg.noAiServer = Boolean(args.noAiServer);
|
|
4486
5061
|
saveConfig(cfg);
|
|
5062
|
+
bridgeCfg = cfg;
|
|
4487
5063
|
|
|
4488
5064
|
log(`machine ${cfg.machineId}`);
|
|
4489
5065
|
log(`admin ${cfg.adminUrl}`);
|
|
4490
5066
|
log(`config ${configPath()}`);
|
|
4491
5067
|
log(
|
|
4492
|
-
`online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
|
|
5068
|
+
`online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s; reconnects until stopped)`
|
|
4493
5069
|
);
|
|
4494
5070
|
await restoreHostsAfterReconnect(cfg);
|
|
4495
5071
|
|
|
@@ -4519,25 +5095,37 @@ async function main() {
|
|
|
4519
5095
|
void runWork();
|
|
4520
5096
|
};
|
|
4521
5097
|
|
|
4522
|
-
/** @type {
|
|
5098
|
+
/** @type {WebSocket | null} */
|
|
4523
5099
|
let socket = null;
|
|
4524
5100
|
let heartbeatTimer = null;
|
|
4525
5101
|
let pingTimer = null;
|
|
4526
5102
|
let reconnectTimer = null;
|
|
5103
|
+
let watchdogTimer = null;
|
|
4527
5104
|
let wsGeneration = 0;
|
|
4528
5105
|
let reconnectAttempt = 0;
|
|
5106
|
+
let connectStartedAt = 0;
|
|
4529
5107
|
let stopped = false;
|
|
4530
5108
|
let presenceBusy = false;
|
|
4531
5109
|
|
|
5110
|
+
let sendChain = Promise.resolve();
|
|
4532
5111
|
const sendJson = (payload) => {
|
|
4533
|
-
|
|
5112
|
+
const current = socket;
|
|
5113
|
+
if (!current || current.readyState !== 1) return false;
|
|
5114
|
+
let text;
|
|
4534
5115
|
try {
|
|
4535
|
-
|
|
4536
|
-
return true;
|
|
5116
|
+
text = JSON.stringify(payload);
|
|
4537
5117
|
} catch {
|
|
4538
5118
|
return false;
|
|
4539
5119
|
}
|
|
5120
|
+
sendChain = sendChain
|
|
5121
|
+
.then(() => {
|
|
5122
|
+
if (!socket || socket !== current || socket.readyState !== 1) return;
|
|
5123
|
+
socket.send(text);
|
|
5124
|
+
})
|
|
5125
|
+
.catch(() => {});
|
|
5126
|
+
return true;
|
|
4540
5127
|
};
|
|
5128
|
+
bridgeSend = sendJson;
|
|
4541
5129
|
|
|
4542
5130
|
const clearHeartbeatTimer = () => {
|
|
4543
5131
|
if (heartbeatTimer) {
|
|
@@ -4566,7 +5154,7 @@ async function main() {
|
|
|
4566
5154
|
};
|
|
4567
5155
|
|
|
4568
5156
|
const sendLightPresence = () => {
|
|
4569
|
-
const folders = collectOfferedFolders(
|
|
5157
|
+
const folders = collectOfferedFolders();
|
|
4570
5158
|
sendJson(buildLightHeartbeatPayload(cfg, folders));
|
|
4571
5159
|
};
|
|
4572
5160
|
|
|
@@ -4574,7 +5162,7 @@ async function main() {
|
|
|
4574
5162
|
if (!socket || socket.readyState !== 1 || presenceBusy) return;
|
|
4575
5163
|
presenceBusy = true;
|
|
4576
5164
|
try {
|
|
4577
|
-
const folders = collectOfferedFolders(
|
|
5165
|
+
const folders = collectOfferedFolders();
|
|
4578
5166
|
const localStates = await collectWorkspaceStates(cfg);
|
|
4579
5167
|
const payload = await buildHeartbeatPayload(cfg, folders, localStates);
|
|
4580
5168
|
sendJson(payload);
|
|
@@ -4627,6 +5215,38 @@ async function main() {
|
|
|
4627
5215
|
fail(`no local workspace for sandbox ${sandboxId || "?"}`);
|
|
4628
5216
|
return;
|
|
4629
5217
|
}
|
|
5218
|
+
const body = {
|
|
5219
|
+
conversationId,
|
|
5220
|
+
messages: payloadMessages,
|
|
5221
|
+
userMessage: content || undefined,
|
|
5222
|
+
skipPersistUser: true,
|
|
5223
|
+
userMessageId: userMessageId || undefined,
|
|
5224
|
+
senderType: msg.senderType === "client" ? "client" : undefined,
|
|
5225
|
+
senderName:
|
|
5226
|
+
typeof msg.senderName === "string" ? msg.senderName : undefined,
|
|
5227
|
+
};
|
|
5228
|
+
const embedded = embeddedChat.get(sandboxId);
|
|
5229
|
+
if (embedded?.runChat) {
|
|
5230
|
+
log(`chat.run → in-process ${embedded.workspaceDir} (${conversationId})`);
|
|
5231
|
+
try {
|
|
5232
|
+
const result = await embedded.runChat(body);
|
|
5233
|
+
if (!result.ok) {
|
|
5234
|
+
fail(result.data?.error || `AI server ${result.status}`);
|
|
5235
|
+
return;
|
|
5236
|
+
}
|
|
5237
|
+
log(`chat.run ok (${conversationId})`);
|
|
5238
|
+
sendJson({
|
|
5239
|
+
type: "chat.run.result",
|
|
5240
|
+
ok: true,
|
|
5241
|
+
sandboxId,
|
|
5242
|
+
conversationId,
|
|
5243
|
+
userMessageId,
|
|
5244
|
+
});
|
|
5245
|
+
} catch (err) {
|
|
5246
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
5247
|
+
}
|
|
5248
|
+
return;
|
|
5249
|
+
}
|
|
4630
5250
|
const chat = await discoverChatPort(ws, 2500);
|
|
4631
5251
|
if (chat.up) ws.port = chat.port;
|
|
4632
5252
|
const port = Number(chat.port || ws.port) || 3100;
|
|
@@ -4639,16 +5259,7 @@ async function main() {
|
|
|
4639
5259
|
"content-type": "application/json",
|
|
4640
5260
|
accept: "application/json",
|
|
4641
5261
|
},
|
|
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
|
-
}),
|
|
5262
|
+
body: JSON.stringify(body),
|
|
4652
5263
|
});
|
|
4653
5264
|
const text = await res.text();
|
|
4654
5265
|
let data = null;
|
|
@@ -4692,6 +5303,26 @@ async function main() {
|
|
|
4692
5303
|
queueActions(msg.actions);
|
|
4693
5304
|
return;
|
|
4694
5305
|
}
|
|
5306
|
+
if (msg.type === "proxy.http") {
|
|
5307
|
+
handleProxyHttpFromAdmin(msg);
|
|
5308
|
+
return;
|
|
5309
|
+
}
|
|
5310
|
+
if (msg.type === "proxy.http.body") {
|
|
5311
|
+
handleProxyHttpBodyFromAdmin(msg);
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
if (msg.type === "proxy.ws.open") {
|
|
5315
|
+
handleProxyWsOpenFromAdmin(msg);
|
|
5316
|
+
return;
|
|
5317
|
+
}
|
|
5318
|
+
if (msg.type === "proxy.ws.frame") {
|
|
5319
|
+
handleProxyWsFrameFromAdmin(msg);
|
|
5320
|
+
return;
|
|
5321
|
+
}
|
|
5322
|
+
if (msg.type === "proxy.ws.close") {
|
|
5323
|
+
handleProxyWsCloseFromAdmin(msg);
|
|
5324
|
+
return;
|
|
5325
|
+
}
|
|
4695
5326
|
if (msg.type === "chat.run") {
|
|
4696
5327
|
void handleChatRun(msg);
|
|
4697
5328
|
return;
|
|
@@ -4701,37 +5332,56 @@ async function main() {
|
|
|
4701
5332
|
}
|
|
4702
5333
|
};
|
|
4703
5334
|
|
|
5335
|
+
const socketState = () => {
|
|
5336
|
+
if (!socket) return -1;
|
|
5337
|
+
return socket.readyState;
|
|
5338
|
+
};
|
|
5339
|
+
|
|
5340
|
+
const dropSocket = (ws) => {
|
|
5341
|
+
if (!ws) return;
|
|
5342
|
+
try {
|
|
5343
|
+
ws.close();
|
|
5344
|
+
} catch {
|
|
5345
|
+
/* ignore */
|
|
5346
|
+
}
|
|
5347
|
+
if (socket === ws) socket = null;
|
|
5348
|
+
};
|
|
5349
|
+
|
|
4704
5350
|
const scheduleReconnect = (code, reason) => {
|
|
4705
5351
|
if (stopped || reconnectTimer) return;
|
|
5352
|
+
const state = socketState();
|
|
5353
|
+
if (state === 0 || state === 1) return;
|
|
4706
5354
|
const delay = Math.min(
|
|
4707
5355
|
WS_RECONNECT_MAX_MS,
|
|
4708
|
-
WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt,
|
|
5356
|
+
WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 5)
|
|
4709
5357
|
);
|
|
4710
5358
|
reconnectAttempt += 1;
|
|
4711
5359
|
const detail = reason ? ` ${reason}` : "";
|
|
4712
5360
|
const wait =
|
|
4713
5361
|
delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
|
|
4714
5362
|
warn(
|
|
4715
|
-
`
|
|
5363
|
+
`admin connection lost (${code || "?"}${detail}); retrying in ${wait}`
|
|
4716
5364
|
);
|
|
4717
5365
|
reconnectTimer = setTimeout(() => {
|
|
4718
5366
|
reconnectTimer = null;
|
|
5367
|
+
if (stopped) return;
|
|
5368
|
+
const next = socketState();
|
|
5369
|
+
if (next === 0 || next === 1) return;
|
|
4719
5370
|
connectWs();
|
|
4720
5371
|
}, delay);
|
|
4721
5372
|
};
|
|
4722
5373
|
|
|
4723
5374
|
const connectWs = () => {
|
|
4724
5375
|
if (stopped) return;
|
|
5376
|
+
const state = socketState();
|
|
5377
|
+
if (state === 0 || state === 1) return;
|
|
4725
5378
|
clearReconnectTimer();
|
|
4726
5379
|
const generation = ++wsGeneration;
|
|
4727
5380
|
if (socket) {
|
|
4728
|
-
|
|
4729
|
-
socket.close();
|
|
4730
|
-
} catch {
|
|
4731
|
-
/* ignore */
|
|
4732
|
-
}
|
|
5381
|
+
dropSocket(socket);
|
|
4733
5382
|
socket = null;
|
|
4734
5383
|
}
|
|
5384
|
+
sendChain = Promise.resolve();
|
|
4735
5385
|
const url = adminWsUrl(cfg.adminUrl, cfg.token);
|
|
4736
5386
|
log(
|
|
4737
5387
|
reconnectAttempt
|
|
@@ -4739,12 +5389,35 @@ async function main() {
|
|
|
4739
5389
|
: "websocket connecting…"
|
|
4740
5390
|
);
|
|
4741
5391
|
/** @type {WebSocket} */
|
|
4742
|
-
|
|
5392
|
+
let ws;
|
|
5393
|
+
try {
|
|
5394
|
+
ws = new WebSocket(url);
|
|
5395
|
+
} catch (err) {
|
|
5396
|
+
warn(
|
|
5397
|
+
`websocket connect failed: ${
|
|
5398
|
+
err instanceof Error ? err.message : String(err)
|
|
5399
|
+
}`
|
|
5400
|
+
);
|
|
5401
|
+
scheduleReconnect(0, "connect failed");
|
|
5402
|
+
return;
|
|
5403
|
+
}
|
|
4743
5404
|
socket = ws;
|
|
5405
|
+
connectStartedAt = Date.now();
|
|
5406
|
+
|
|
5407
|
+
const onDisconnected = (code, reason) => {
|
|
5408
|
+
if (generation !== wsGeneration) return;
|
|
5409
|
+
// Node fires `error` while CONNECTING/OPEN; that is not a drop.
|
|
5410
|
+
if (ws.readyState === 0 || ws.readyState === 1) return;
|
|
5411
|
+
clearHeartbeatTimer();
|
|
5412
|
+
clearPingTimer();
|
|
5413
|
+
if (socket === ws) socket = null;
|
|
5414
|
+
scheduleReconnect(code, reason);
|
|
5415
|
+
};
|
|
4744
5416
|
|
|
4745
5417
|
ws.addEventListener("open", () => {
|
|
4746
5418
|
if (generation !== wsGeneration) return;
|
|
4747
5419
|
reconnectAttempt = 0;
|
|
5420
|
+
connectStartedAt = Date.now();
|
|
4748
5421
|
log("websocket connected");
|
|
4749
5422
|
clearHeartbeatTimer();
|
|
4750
5423
|
clearPingTimer();
|
|
@@ -4763,34 +5436,61 @@ async function main() {
|
|
|
4763
5436
|
});
|
|
4764
5437
|
|
|
4765
5438
|
ws.addEventListener("close", (event) => {
|
|
4766
|
-
|
|
4767
|
-
clearHeartbeatTimer();
|
|
4768
|
-
clearPingTimer();
|
|
4769
|
-
if (socket === ws) socket = null;
|
|
4770
|
-
scheduleReconnect(event.code, event.reason);
|
|
5439
|
+
onDisconnected(event.code, event.reason);
|
|
4771
5440
|
});
|
|
4772
5441
|
|
|
4773
5442
|
ws.addEventListener("error", () => {
|
|
4774
|
-
// close
|
|
5443
|
+
// `close` follows for real failures. Reconnecting here races `open`
|
|
5444
|
+
// and makes admin close the live socket with 1000 "replaced".
|
|
4775
5445
|
});
|
|
4776
5446
|
};
|
|
4777
5447
|
|
|
5448
|
+
const watchdogTick = () => {
|
|
5449
|
+
if (stopped) return;
|
|
5450
|
+
const state = socketState();
|
|
5451
|
+
if (state === 1) return;
|
|
5452
|
+
if (state === 0) {
|
|
5453
|
+
if (Date.now() - connectStartedAt > WS_CONNECT_TIMEOUT_MS) {
|
|
5454
|
+
warn("websocket connect timed out; retrying");
|
|
5455
|
+
wsGeneration += 1;
|
|
5456
|
+
dropSocket(socket);
|
|
5457
|
+
socket = null;
|
|
5458
|
+
scheduleReconnect(0, "connect timeout");
|
|
5459
|
+
}
|
|
5460
|
+
return;
|
|
5461
|
+
}
|
|
5462
|
+
scheduleReconnect(0, "watchdog");
|
|
5463
|
+
};
|
|
5464
|
+
|
|
4778
5465
|
connectWs();
|
|
5466
|
+
watchdogTimer = setInterval(watchdogTick, WS_WATCHDOG_MS);
|
|
5467
|
+
|
|
5468
|
+
const onFatal = (kind, err) => {
|
|
5469
|
+
const msg = err instanceof Error ? err.message : String(err || kind);
|
|
5470
|
+
warn(`${kind}: ${msg}`);
|
|
5471
|
+
if (!stopped) scheduleReconnect(kind, msg);
|
|
5472
|
+
};
|
|
5473
|
+
process.on("uncaughtException", (err) => onFatal("uncaughtException", err));
|
|
5474
|
+
process.on("unhandledRejection", (err) =>
|
|
5475
|
+
onFatal("unhandledRejection", err)
|
|
5476
|
+
);
|
|
4779
5477
|
|
|
4780
5478
|
const shutdown = () => {
|
|
5479
|
+
if (stopped) return;
|
|
4781
5480
|
stopped = true;
|
|
4782
5481
|
wsGeneration += 1;
|
|
4783
5482
|
clearHeartbeatTimer();
|
|
4784
5483
|
clearPingTimer();
|
|
4785
5484
|
clearReconnectTimer();
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
} catch {
|
|
4790
|
-
/* ignore */
|
|
5485
|
+
if (watchdogTimer) {
|
|
5486
|
+
clearInterval(watchdogTimer);
|
|
5487
|
+
watchdogTimer = null;
|
|
4791
5488
|
}
|
|
5489
|
+
stopAllCloudflare();
|
|
5490
|
+
dropSocket(socket);
|
|
5491
|
+
socket = null;
|
|
4792
5492
|
log("shutting down (other terminals stay open)");
|
|
4793
|
-
process.exit(0);
|
|
5493
|
+
void stopAllEmbeddedChat().finally(() => process.exit(0));
|
|
4794
5494
|
};
|
|
4795
5495
|
process.on("SIGINT", shutdown);
|
|
4796
5496
|
process.on("SIGTERM", shutdown);
|