@maintainer-pro/ai-bridge 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/daemon.mjs +2688 -404
package/src/daemon.mjs
CHANGED
|
@@ -8,27 +8,70 @@
|
|
|
8
8
|
* npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
|
|
9
9
|
*/
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import http from "node:http";
|
|
14
|
+
import https from "node:https";
|
|
14
15
|
import net from "node:net";
|
|
15
16
|
import os from "node:os";
|
|
16
17
|
import path from "node:path";
|
|
17
18
|
import readline from "node:readline";
|
|
18
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { createLogger } from "@maintainer-pro/ai-cli";
|
|
19
21
|
|
|
20
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
23
|
const PACKAGE_VERSION = readPackageVersion();
|
|
22
24
|
const HEARTBEAT_MS = 15_000;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const WS_PING_MS = 10_000;
|
|
26
|
+
const WS_RECONNECT_MIN_MS = 500;
|
|
27
|
+
const WS_RECONNECT_MAX_MS = 8_000;
|
|
28
|
+
|
|
29
|
+
/** @type {import("@maintainer-pro/ai-cli").Logger} */
|
|
30
|
+
let logger = createLogger("ai-bridge");
|
|
31
|
+
const log = (msg) => logger.info(msg);
|
|
32
|
+
const warn = (msg) => logger.warn(msg);
|
|
27
33
|
const fail = (msg) => {
|
|
28
|
-
|
|
34
|
+
logger.fatal(msg);
|
|
29
35
|
process.exit(1);
|
|
30
36
|
};
|
|
31
37
|
|
|
38
|
+
function shortId(value) {
|
|
39
|
+
const text = String(value || "");
|
|
40
|
+
return text.length > 12 ? `${text.slice(0, 8)}…` : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function actionLabel(action) {
|
|
44
|
+
const sandbox = action.sandboxId || action.payload?.sandboxId;
|
|
45
|
+
const folder = action.payload?.folderPath;
|
|
46
|
+
const bits = [`action ${action.code}`];
|
|
47
|
+
if (action.id) bits.push(`id=${shortId(action.id)}`);
|
|
48
|
+
if (sandbox) bits.push(`sandbox=${shortId(sandbox)}`);
|
|
49
|
+
if (folder) bits.push(`folder=${folder}`);
|
|
50
|
+
return bits.join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resultSummary(result) {
|
|
54
|
+
if (!result || typeof result !== "object") return "";
|
|
55
|
+
/** @type {string[]} */
|
|
56
|
+
const bits = [];
|
|
57
|
+
if (result.error) bits.push(`error=${result.error}`);
|
|
58
|
+
if (result.folderPath) bits.push(`folder=${result.folderPath}`);
|
|
59
|
+
if (result.port) bits.push(`chat=${result.port}`);
|
|
60
|
+
if (result.appUrl) bits.push(`app=${result.appUrl}`);
|
|
61
|
+
if (Array.isArray(result.origins) && result.origins.length) {
|
|
62
|
+
bits.push(`origins=${result.origins.join(",")}`);
|
|
63
|
+
}
|
|
64
|
+
if (typeof result.up === "boolean") bits.push(`chatUp=${result.up}`);
|
|
65
|
+
if (typeof result.waitingForStart === "boolean") {
|
|
66
|
+
bits.push(`waitingForStart=${result.waitingForStart}`);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(result.startedHosts) && result.startedHosts.length) {
|
|
69
|
+
bits.push(`started=${result.startedHosts.join(",")}`);
|
|
70
|
+
}
|
|
71
|
+
if (result.warning) bits.push(`warning=${result.warning}`);
|
|
72
|
+
return bits.join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
function readPackageVersion() {
|
|
33
76
|
try {
|
|
34
77
|
const pkg = JSON.parse(
|
|
@@ -89,6 +132,10 @@ Flags:
|
|
|
89
132
|
--offer-folder <path> Suggest this folder in admin (repeatable via config)
|
|
90
133
|
--no-ai-server Do not open ai-server terminals for workspaces
|
|
91
134
|
--help
|
|
135
|
+
|
|
136
|
+
Logging (env):
|
|
137
|
+
LOG_LEVEL=info|debug|warn|error|silent (default: info)
|
|
138
|
+
LOG_PRETTY=0 disable pretty TTY output
|
|
92
139
|
`);
|
|
93
140
|
}
|
|
94
141
|
|
|
@@ -136,18 +183,43 @@ async function ask(question) {
|
|
|
136
183
|
});
|
|
137
184
|
}
|
|
138
185
|
|
|
186
|
+
function fetchErrorDetail(err) {
|
|
187
|
+
const cause = err && typeof err === "object" ? err.cause : null;
|
|
188
|
+
const code =
|
|
189
|
+
(cause && typeof cause === "object" && "code" in cause && cause.code) ||
|
|
190
|
+
(err && typeof err === "object" && "code" in err && err.code) ||
|
|
191
|
+
"";
|
|
192
|
+
const port =
|
|
193
|
+
(cause && typeof cause === "object" && "port" in cause && cause.port) ||
|
|
194
|
+
"";
|
|
195
|
+
return [code, port ? `port ${port}` : ""].filter(Boolean).join(" ");
|
|
196
|
+
}
|
|
197
|
+
|
|
139
198
|
async function api(baseUrl, token, method, pathname, body) {
|
|
140
|
-
const url = `${baseUrl.replace(/\/$/, "")}${pathname}`;
|
|
199
|
+
const url = `${String(baseUrl || "").replace(/\/$/, "")}${pathname}`;
|
|
200
|
+
if (!baseUrl) {
|
|
201
|
+
throw new Error("Admin URL is missing. Pair this computer again.");
|
|
202
|
+
}
|
|
141
203
|
const headers = {
|
|
142
204
|
Accept: "application/json",
|
|
143
205
|
"Content-Type": "application/json",
|
|
144
206
|
};
|
|
145
207
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
208
|
+
let res;
|
|
209
|
+
try {
|
|
210
|
+
res = await fetch(url, {
|
|
211
|
+
method,
|
|
212
|
+
headers,
|
|
213
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
214
|
+
});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const detail = fetchErrorDetail(err);
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Could not reach Maintainer Pro at ${url}${
|
|
219
|
+
detail ? ` (${detail})` : ""
|
|
220
|
+
}. Is the admin server running?`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
151
223
|
const text = await res.text();
|
|
152
224
|
let data = null;
|
|
153
225
|
try {
|
|
@@ -163,6 +235,28 @@ async function api(baseUrl, token, method, pathname, body) {
|
|
|
163
235
|
return data;
|
|
164
236
|
}
|
|
165
237
|
|
|
238
|
+
async function reportActionProgress(cfg, actionId, message) {
|
|
239
|
+
const text = String(message || "").trim();
|
|
240
|
+
if (!text) return;
|
|
241
|
+
log(text);
|
|
242
|
+
if (!cfg?.adminUrl || !cfg.token || !actionId) return;
|
|
243
|
+
try {
|
|
244
|
+
await api(
|
|
245
|
+
cfg.adminUrl,
|
|
246
|
+
cfg.token,
|
|
247
|
+
"POST",
|
|
248
|
+
`/api/v1/bridge/machine/actions/${actionId}/progress`,
|
|
249
|
+
{ message: text }
|
|
250
|
+
);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
warn(
|
|
253
|
+
`progress report failed: ${
|
|
254
|
+
err instanceof Error ? err.message : String(err)
|
|
255
|
+
}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
166
260
|
/** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
|
|
167
261
|
let aiCliModule = null;
|
|
168
262
|
|
|
@@ -183,18 +277,31 @@ async function loadAiCli() {
|
|
|
183
277
|
return aiCliModule;
|
|
184
278
|
}
|
|
185
279
|
|
|
280
|
+
/** @type {{ at: number, ids: string[] } | null} */
|
|
281
|
+
let cliProviderCache = null;
|
|
282
|
+
|
|
186
283
|
async function detectCliProviders() {
|
|
284
|
+
if (cliProviderCache && Date.now() - cliProviderCache.at < 60_000) {
|
|
285
|
+
return cliProviderCache.ids;
|
|
286
|
+
}
|
|
187
287
|
try {
|
|
188
288
|
const { resolveProvider } = await loadAiCli();
|
|
189
289
|
const provider = await resolveProvider({ preference: "auto" });
|
|
190
|
-
|
|
290
|
+
cliProviderCache = { at: Date.now(), ids: [provider.id] };
|
|
291
|
+
return cliProviderCache.ids;
|
|
191
292
|
} catch {
|
|
192
|
-
|
|
293
|
+
cliProviderCache = { at: Date.now(), ids: [] };
|
|
294
|
+
return cliProviderCache.ids;
|
|
193
295
|
}
|
|
194
296
|
}
|
|
195
297
|
|
|
196
298
|
function applyProjectInfo(ws, info, cfg) {
|
|
197
299
|
if (!ws || !info) return;
|
|
300
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
301
|
+
const fingerprint =
|
|
302
|
+
typeof info.fingerprint === "string" && info.fingerprint
|
|
303
|
+
? info.fingerprint
|
|
304
|
+
: projectFingerprint(folder);
|
|
198
305
|
ws.projectInfo = {
|
|
199
306
|
kind: info.kind,
|
|
200
307
|
name: info.name,
|
|
@@ -205,6 +312,8 @@ function applyProjectInfo(ws, info, cfg) {
|
|
|
205
312
|
fixes: info.fixes || [],
|
|
206
313
|
ready: Boolean(info.ready),
|
|
207
314
|
provider: info.provider,
|
|
315
|
+
fingerprint,
|
|
316
|
+
inspectedAt: info.inspectedAt || new Date().toISOString(),
|
|
208
317
|
};
|
|
209
318
|
if (info.kind) ws.clientKind = info.kind;
|
|
210
319
|
const uiPort = Number(info.ports?.ui || info.ports?.app);
|
|
@@ -217,9 +326,144 @@ function applyProjectInfo(ws, info, cfg) {
|
|
|
217
326
|
persistWorkspaceEntry(cfg, ws);
|
|
218
327
|
}
|
|
219
328
|
|
|
329
|
+
const PROJECT_INSPECT_CACHE = "project-inspect.json";
|
|
330
|
+
|
|
331
|
+
function projectInspectCachePath(folder) {
|
|
332
|
+
return path.join(folder, ".maintainer-pro", PROJECT_INSPECT_CACHE);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Invalidate cache when package.json / common config files change. */
|
|
336
|
+
function projectFingerprint(folder) {
|
|
337
|
+
const resolved = path.resolve(folder || "");
|
|
338
|
+
/** @type {string[]} */
|
|
339
|
+
const parts = [];
|
|
340
|
+
for (const rel of [
|
|
341
|
+
"package.json",
|
|
342
|
+
"package-lock.json",
|
|
343
|
+
"pnpm-lock.yaml",
|
|
344
|
+
"yarn.lock",
|
|
345
|
+
"vite.config.ts",
|
|
346
|
+
"vite.config.js",
|
|
347
|
+
"vite.config.mjs",
|
|
348
|
+
"next.config.js",
|
|
349
|
+
"next.config.mjs",
|
|
350
|
+
"next.config.ts",
|
|
351
|
+
]) {
|
|
352
|
+
const file = path.join(resolved, rel);
|
|
353
|
+
if (!fs.existsSync(file)) continue;
|
|
354
|
+
try {
|
|
355
|
+
const st = fs.statSync(file);
|
|
356
|
+
parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
|
357
|
+
} catch {
|
|
358
|
+
/* ignore */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const pkg = JSON.parse(
|
|
363
|
+
fs.readFileSync(path.join(resolved, "package.json"), "utf8")
|
|
364
|
+
);
|
|
365
|
+
parts.push(`name:${pkg.name || ""}`);
|
|
366
|
+
parts.push(`scripts:${JSON.stringify(pkg.scripts || {})}`);
|
|
367
|
+
} catch {
|
|
368
|
+
/* ignore */
|
|
369
|
+
}
|
|
370
|
+
return createHash("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function usableCachedProjectInfo(info) {
|
|
374
|
+
if (!info || typeof info !== "object") return false;
|
|
375
|
+
const scripts = info.scripts && typeof info.scripts === "object" ? info.scripts : {};
|
|
376
|
+
const hasScript = Boolean(scripts.ui || scripts.app || scripts.backend);
|
|
377
|
+
const hasKind = typeof info.kind === "string" && info.kind.length > 0;
|
|
378
|
+
return hasKind || hasScript || Boolean(info.summary);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function readProjectInspectCache(folder) {
|
|
382
|
+
const resolved = path.resolve(folder || "");
|
|
383
|
+
try {
|
|
384
|
+
const file = projectInspectCachePath(resolved);
|
|
385
|
+
if (!fs.existsSync(file)) return null;
|
|
386
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
387
|
+
if (!data || typeof data !== "object") return null;
|
|
388
|
+
if (!usableCachedProjectInfo(data.projectInfo)) return null;
|
|
389
|
+
const fingerprint = projectFingerprint(resolved);
|
|
390
|
+
if (data.fingerprint && data.fingerprint !== fingerprint) return null;
|
|
391
|
+
return {
|
|
392
|
+
...data.projectInfo,
|
|
393
|
+
fingerprint,
|
|
394
|
+
inspectedAt: data.inspectedAt || data.projectInfo?.inspectedAt,
|
|
395
|
+
cached: true,
|
|
396
|
+
};
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function writeProjectInspectCache(folder, info) {
|
|
403
|
+
const resolved = path.resolve(folder || "");
|
|
404
|
+
if (!info || !usableCachedProjectInfo(info)) return;
|
|
405
|
+
try {
|
|
406
|
+
const dir = path.join(resolved, ".maintainer-pro");
|
|
407
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
408
|
+
const fingerprint = projectFingerprint(resolved);
|
|
409
|
+
const payload = {
|
|
410
|
+
version: 1,
|
|
411
|
+
fingerprint,
|
|
412
|
+
inspectedAt: new Date().toISOString(),
|
|
413
|
+
projectInfo: {
|
|
414
|
+
kind: info.kind,
|
|
415
|
+
name: info.name,
|
|
416
|
+
summary: info.summary,
|
|
417
|
+
scripts: info.scripts || {},
|
|
418
|
+
ports: info.ports || {},
|
|
419
|
+
issues: info.issues || [],
|
|
420
|
+
fixes: info.fixes || [],
|
|
421
|
+
ready: Boolean(info.ready),
|
|
422
|
+
provider: info.provider,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
fs.writeFileSync(
|
|
426
|
+
projectInspectCachePath(resolved),
|
|
427
|
+
`${JSON.stringify(payload, null, 2)}\n`,
|
|
428
|
+
"utf8"
|
|
429
|
+
);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
warn(
|
|
432
|
+
`could not write project inspect cache: ${
|
|
433
|
+
err instanceof Error ? err.message : String(err)
|
|
434
|
+
}`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolve project inspect info: prefer on-disk / config cache, else ask ai-cli.
|
|
441
|
+
* Pass `force: true` to always re-run analysis (recheck / failed start retry).
|
|
442
|
+
*/
|
|
220
443
|
async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
221
444
|
const folder = path.resolve(ws.folderPath || "");
|
|
222
445
|
const label = ws.sandboxName || ws.applicationName || "this sandbox";
|
|
446
|
+
const force = Boolean(opts.force);
|
|
447
|
+
|
|
448
|
+
if (!force) {
|
|
449
|
+
const fingerprint = projectFingerprint(folder);
|
|
450
|
+
const fromDisk = readProjectInspectCache(folder);
|
|
451
|
+
const fromWs =
|
|
452
|
+
usableCachedProjectInfo(ws.projectInfo) &&
|
|
453
|
+
(!ws.projectInfo.fingerprint ||
|
|
454
|
+
ws.projectInfo.fingerprint === fingerprint)
|
|
455
|
+
? ws.projectInfo
|
|
456
|
+
: null;
|
|
457
|
+
const cached = fromDisk || fromWs;
|
|
458
|
+
if (cached) {
|
|
459
|
+
log(`using cached project inspect for ${folder}`);
|
|
460
|
+
applyProjectInfo(ws, { ...cached, fingerprint }, opts.cfg);
|
|
461
|
+
if (!fromDisk) writeProjectInspectCache(folder, cached);
|
|
462
|
+
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
463
|
+
return { ...cached, fingerprint, cached: true };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
223
467
|
try {
|
|
224
468
|
const { inspectAndRepairWorkspace } = await loadAiCli();
|
|
225
469
|
log(`asking ai-cli to inspect ${folder}`);
|
|
@@ -229,7 +473,9 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
229
473
|
problem: opts.problem,
|
|
230
474
|
extraContext: opts.extraContext,
|
|
231
475
|
});
|
|
232
|
-
|
|
476
|
+
const fingerprint = projectFingerprint(folder);
|
|
477
|
+
applyProjectInfo(ws, { ...info, fingerprint }, opts.cfg);
|
|
478
|
+
writeProjectInspectCache(folder, info);
|
|
233
479
|
if (info.issues?.length) {
|
|
234
480
|
recordProcessProblem({
|
|
235
481
|
sandboxId: ws.sandboxId,
|
|
@@ -239,17 +485,26 @@ async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
|
239
485
|
message: info.issues.join(" "),
|
|
240
486
|
resolution: info.fixes?.length
|
|
241
487
|
? info.fixes.join(" ")
|
|
242
|
-
: "Fix the project in that folder, then use Start
|
|
488
|
+
: "Fix the project in that folder, then use Start Apps.",
|
|
243
489
|
});
|
|
244
490
|
} else {
|
|
245
491
|
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
246
492
|
}
|
|
247
493
|
if (info.summary) log(`ai-cli: ${info.summary}`);
|
|
248
494
|
if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
|
|
249
|
-
return info;
|
|
495
|
+
return { ...info, fingerprint, cached: false };
|
|
250
496
|
} catch (err) {
|
|
251
497
|
const message = err instanceof Error ? err.message : String(err);
|
|
252
498
|
warn(`ai-cli inspect failed: ${message}`);
|
|
499
|
+
// Fall back to any stale cache so Start Apps can still try.
|
|
500
|
+
const fallback =
|
|
501
|
+
readProjectInspectCache(folder) ||
|
|
502
|
+
(usableCachedProjectInfo(ws.projectInfo) ? ws.projectInfo : null);
|
|
503
|
+
if (fallback) {
|
|
504
|
+
warn(`falling back to cached project inspect after ai-cli error`);
|
|
505
|
+
applyProjectInfo(ws, fallback, opts.cfg);
|
|
506
|
+
return { ...fallback, cached: true };
|
|
507
|
+
}
|
|
253
508
|
recordProcessProblem({
|
|
254
509
|
sandboxId: ws.sandboxId,
|
|
255
510
|
code: "project_issue",
|
|
@@ -287,152 +542,1326 @@ async function findFreePort(preferred = 3100, reserved = new Set()) {
|
|
|
287
542
|
reserved.add(port);
|
|
288
543
|
return port;
|
|
289
544
|
}
|
|
290
|
-
port += 1;
|
|
545
|
+
port += 1;
|
|
546
|
+
}
|
|
547
|
+
throw new Error("No free TCP port found");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function urlWithPort(url, port) {
|
|
551
|
+
try {
|
|
552
|
+
const parsed = new URL(url);
|
|
553
|
+
parsed.port = String(port);
|
|
554
|
+
return parsed.toString().replace(/\/$/, "");
|
|
555
|
+
} catch {
|
|
556
|
+
return `http://localhost:${port}`;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isLocalAppUrl(url) {
|
|
561
|
+
try {
|
|
562
|
+
const host = new URL(url).hostname;
|
|
563
|
+
return host === "localhost" || host === "127.0.0.1";
|
|
564
|
+
} catch {
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function persistWorkspaceEntry(cfg, ws) {
|
|
570
|
+
if (!cfg || !ws?.sandboxId) return;
|
|
571
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
572
|
+
const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
|
|
573
|
+
if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
|
|
574
|
+
else cfg.workspaces.push(ws);
|
|
575
|
+
saveConfig(cfg);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function probeUrl(url, timeoutMs = 2500) {
|
|
579
|
+
return new Promise((resolve) => {
|
|
580
|
+
let settled = false;
|
|
581
|
+
const done = (ok) => {
|
|
582
|
+
if (settled) return;
|
|
583
|
+
settled = true;
|
|
584
|
+
resolve(ok);
|
|
585
|
+
};
|
|
586
|
+
try {
|
|
587
|
+
const parsed = new URL(String(url));
|
|
588
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
589
|
+
const req = lib.get(
|
|
590
|
+
parsed,
|
|
591
|
+
{
|
|
592
|
+
timeout: timeoutMs,
|
|
593
|
+
rejectUnauthorized: true,
|
|
594
|
+
headers: { Accept: "*/*" },
|
|
595
|
+
},
|
|
596
|
+
(res) => {
|
|
597
|
+
res.resume();
|
|
598
|
+
done(Boolean(res.statusCode && res.statusCode < 500));
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
req.on("error", () => done(false));
|
|
602
|
+
req.on("timeout", () => {
|
|
603
|
+
req.destroy();
|
|
604
|
+
done(false);
|
|
605
|
+
});
|
|
606
|
+
} catch {
|
|
607
|
+
done(false);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function fetchText(url, timeoutMs = 8000) {
|
|
613
|
+
return new Promise((resolve) => {
|
|
614
|
+
try {
|
|
615
|
+
const parsed = new URL(String(url));
|
|
616
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
617
|
+
const req = lib.get(
|
|
618
|
+
parsed,
|
|
619
|
+
{
|
|
620
|
+
timeout: timeoutMs,
|
|
621
|
+
rejectUnauthorized: true,
|
|
622
|
+
headers: { Accept: "*/*" },
|
|
623
|
+
},
|
|
624
|
+
(res) => {
|
|
625
|
+
/** @type {Buffer[]} */
|
|
626
|
+
const chunks = [];
|
|
627
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
628
|
+
res.on("end", () => {
|
|
629
|
+
if (!res.statusCode || res.statusCode >= 500) {
|
|
630
|
+
resolve(null);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
req.on("error", () => resolve(null));
|
|
638
|
+
req.on("timeout", () => {
|
|
639
|
+
req.destroy();
|
|
640
|
+
resolve(null);
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
resolve(null);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function listDriveRoots() {
|
|
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) {
|
|
663
|
+
const raw = String(dirPath || "").trim();
|
|
664
|
+
const home = os.homedir();
|
|
665
|
+
if (!raw || raw === "roots") {
|
|
666
|
+
const roots = listDriveRoots();
|
|
667
|
+
return {
|
|
668
|
+
path: "",
|
|
669
|
+
parent: null,
|
|
670
|
+
home,
|
|
671
|
+
entries: roots.map((root) => ({
|
|
672
|
+
name: root,
|
|
673
|
+
path: root,
|
|
674
|
+
isDir: true,
|
|
675
|
+
})),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const resolved = path.resolve(raw);
|
|
680
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
681
|
+
return {
|
|
682
|
+
error: "Not a directory",
|
|
683
|
+
path: resolved,
|
|
684
|
+
parent: path.dirname(resolved),
|
|
685
|
+
home,
|
|
686
|
+
entries: [],
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
const names = fs.readdirSync(resolved);
|
|
690
|
+
const entries = [];
|
|
691
|
+
for (const name of names) {
|
|
692
|
+
if (name === "node_modules" || name === ".git") continue;
|
|
693
|
+
const full = path.join(resolved, name);
|
|
694
|
+
try {
|
|
695
|
+
const st = fs.statSync(full);
|
|
696
|
+
entries.push({
|
|
697
|
+
name,
|
|
698
|
+
path: full,
|
|
699
|
+
isDir: st.isDirectory(),
|
|
700
|
+
});
|
|
701
|
+
} catch {
|
|
702
|
+
/* skip */
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
entries.sort((a, b) => {
|
|
706
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
707
|
+
return a.name.localeCompare(b.name);
|
|
708
|
+
});
|
|
709
|
+
const parent = path.dirname(resolved);
|
|
710
|
+
return {
|
|
711
|
+
path: resolved,
|
|
712
|
+
parent: parent === resolved ? null : parent,
|
|
713
|
+
home,
|
|
714
|
+
entries: entries.slice(0, 400),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function readEnvFile(file) {
|
|
719
|
+
/** @type {Record<string, string>} */
|
|
720
|
+
const map = {};
|
|
721
|
+
if (!fs.existsSync(file)) return map;
|
|
722
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
723
|
+
const line = raw.trim();
|
|
724
|
+
if (!line || line.startsWith("#")) continue;
|
|
725
|
+
const eq = line.indexOf("=");
|
|
726
|
+
if (eq < 1) continue;
|
|
727
|
+
map[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
728
|
+
}
|
|
729
|
+
return map;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function mergeEnvFile(file, values, opts = {}) {
|
|
733
|
+
const map = readEnvFile(file);
|
|
734
|
+
for (const key of opts.remove || []) {
|
|
735
|
+
delete map[key];
|
|
736
|
+
}
|
|
737
|
+
for (const [k, v] of Object.entries(values)) {
|
|
738
|
+
map[k] = String(v);
|
|
739
|
+
}
|
|
740
|
+
const body = Object.entries(map)
|
|
741
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
742
|
+
.join("\n");
|
|
743
|
+
fs.writeFileSync(file, body + "\n", "utf8");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function writeProjectEnv(folder, values, opts = {}) {
|
|
747
|
+
const remove = opts.remove || [];
|
|
748
|
+
const next = values && typeof values === "object" ? values : {};
|
|
749
|
+
if (!folder || (!Object.keys(next).length && !remove.length)) return;
|
|
750
|
+
mergeEnvFile(path.join(folder, ".env"), next, { remove });
|
|
751
|
+
const localValues = {};
|
|
752
|
+
for (const [key, value] of Object.entries(next)) {
|
|
753
|
+
if (
|
|
754
|
+
key === "PORT" ||
|
|
755
|
+
key === "APP_URL" ||
|
|
756
|
+
key === "PUBLIC_URL" ||
|
|
757
|
+
key === "CORS_ORIGIN" ||
|
|
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
|
+
});
|
|
781
|
+
}
|
|
782
|
+
const keys = Object.entries(next)
|
|
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
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function originFromUrl(value) {
|
|
797
|
+
try {
|
|
798
|
+
return new URL(String(value || "").trim()).origin;
|
|
799
|
+
} catch {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function readProjectEnvValues(folder) {
|
|
805
|
+
/** @type {Record<string, string>} */
|
|
806
|
+
const map = {};
|
|
807
|
+
if (!folder) return map;
|
|
808
|
+
const resolved = path.resolve(folder);
|
|
809
|
+
for (const name of [".env", ".env.local"]) {
|
|
810
|
+
Object.assign(map, readEnvFile(path.join(resolved, name)));
|
|
811
|
+
}
|
|
812
|
+
return map;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function isTryCloudflareUrl(value) {
|
|
816
|
+
try {
|
|
817
|
+
return new URL(String(value || "").trim()).hostname.endsWith(
|
|
818
|
+
".trycloudflare.com"
|
|
819
|
+
);
|
|
820
|
+
} catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function normalizePublicOrigin(value) {
|
|
826
|
+
const origin = originFromUrl(value);
|
|
827
|
+
return origin || null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function readCloudflareTunnelFile(folder) {
|
|
831
|
+
if (!folder) return null;
|
|
832
|
+
const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
|
|
833
|
+
if (!fs.existsSync(file)) return null;
|
|
834
|
+
/** @type {Record<string, string>} */
|
|
835
|
+
const tunnels = {};
|
|
836
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
837
|
+
const line = raw.trim();
|
|
838
|
+
if (!line || line.startsWith("#")) continue;
|
|
839
|
+
const eq = line.indexOf("=");
|
|
840
|
+
if (eq < 1) continue;
|
|
841
|
+
const role = line.slice(0, eq).trim();
|
|
842
|
+
const url = line.slice(eq + 1).trim().replace(/\/$/, "");
|
|
843
|
+
if (role && isTryCloudflareUrl(url)) tunnels[role] = url;
|
|
844
|
+
}
|
|
845
|
+
return Object.keys(tunnels).length ? tunnels : null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function clearCloudflareTunnelFile(folder) {
|
|
849
|
+
if (!folder) return;
|
|
850
|
+
const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
|
|
851
|
+
try {
|
|
852
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
853
|
+
} catch {
|
|
854
|
+
/* ignore */
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
|
|
859
|
+
function archiveStaleCloudflareLogs(folder) {
|
|
860
|
+
if (!folder) return;
|
|
861
|
+
const logDir = path.join(path.resolve(folder), ".maintainer-pro");
|
|
862
|
+
if (!fs.existsSync(logDir)) return;
|
|
863
|
+
let names = [];
|
|
864
|
+
try {
|
|
865
|
+
names = fs.readdirSync(logDir);
|
|
866
|
+
} catch {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
for (const name of names) {
|
|
870
|
+
if (!/^cf-.*\.log$/i.test(name) || name.endsWith(".stale")) continue;
|
|
871
|
+
try {
|
|
872
|
+
fs.renameSync(
|
|
873
|
+
path.join(logDir, name),
|
|
874
|
+
path.join(logDir, `${name}.stale`)
|
|
875
|
+
);
|
|
876
|
+
} catch {
|
|
877
|
+
/* ignore */
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const CLOUDFLARE_ENV_KEYS = [
|
|
883
|
+
"APP_URL",
|
|
884
|
+
"PUBLIC_URL",
|
|
885
|
+
"CORS_ORIGIN",
|
|
886
|
+
"NEXT_PUBLIC_APP_URL",
|
|
887
|
+
"VITE_APP_URL",
|
|
888
|
+
"REACT_APP_APP_URL",
|
|
889
|
+
"AI_SERVER_URL",
|
|
890
|
+
"NEXT_PUBLIC_AI_SERVER_URL",
|
|
891
|
+
"VITE_AI_SERVER_URL",
|
|
892
|
+
"REACT_APP_AI_SERVER_URL",
|
|
893
|
+
"API_URL",
|
|
894
|
+
"API_BASE_URL",
|
|
895
|
+
"VITE_API_URL",
|
|
896
|
+
"VITE_API_BASE_URL",
|
|
897
|
+
"NEXT_PUBLIC_API_URL",
|
|
898
|
+
"NEXT_PUBLIC_API_BASE_URL",
|
|
899
|
+
"BACKEND_URL",
|
|
900
|
+
];
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Remove dead trycloudflare URLs from workspace state, tunnel file, env, and
|
|
904
|
+
* archived logs so heartbeats stop re-probing them.
|
|
905
|
+
*/
|
|
906
|
+
function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
|
|
907
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
908
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
909
|
+
const deadUrls = (opts.deadUrls || [])
|
|
910
|
+
.map((u) => String(u || "").replace(/\/$/, ""))
|
|
911
|
+
.filter((u) => isTryCloudflareUrl(u));
|
|
912
|
+
const keep = opts.keep && typeof opts.keep === "object" ? opts.keep : null;
|
|
913
|
+
|
|
914
|
+
if (deadUrls.length) {
|
|
915
|
+
log(
|
|
916
|
+
`clearing stale Cloudflare for ${label}: ${deadUrls.join(", ")}`
|
|
917
|
+
);
|
|
918
|
+
} else {
|
|
919
|
+
log(`clearing stale Cloudflare for ${label}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
ws.cloudflareUrl = null;
|
|
923
|
+
ws.cloudflare = keep && Object.keys(keep).length ? { ...keep } : null;
|
|
924
|
+
if (keep?.ui || keep?.ai) {
|
|
925
|
+
ws.cloudflareUrl = keep.ui || keep.ai;
|
|
926
|
+
ws.appUrl = keep.ui || keep.ai;
|
|
927
|
+
} else if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
|
|
928
|
+
ws.appUrl = null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (ws.sandboxId && !(keep && Object.keys(keep).length)) {
|
|
932
|
+
cloudflareTunnels.delete(ws.sandboxId);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!folder || !fs.existsSync(folder)) {
|
|
936
|
+
persistWorkspaceEntry(cfg, ws);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Always archive cf-*.log so dead trycloudflare hosts are not rediscovered.
|
|
941
|
+
archiveStaleCloudflareLogs(folder);
|
|
942
|
+
if (keep && Object.keys(keep).length) {
|
|
943
|
+
writeTunnelEnv(ws, keep);
|
|
944
|
+
} else {
|
|
945
|
+
clearCloudflareTunnelFile(folder);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const current = readProjectEnvValues(folder);
|
|
949
|
+
/** @type {string[]} */
|
|
950
|
+
const remove = [];
|
|
951
|
+
for (const key of CLOUDFLARE_ENV_KEYS) {
|
|
952
|
+
const value = current[key];
|
|
953
|
+
if (!isTryCloudflareUrl(value)) continue;
|
|
954
|
+
const normalized = String(value).replace(/\/$/, "");
|
|
955
|
+
if (keep && Object.values(keep).some((u) => String(u).replace(/\/$/, "") === normalized)) {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
remove.push(key);
|
|
959
|
+
}
|
|
960
|
+
if (remove.length) {
|
|
961
|
+
/** @type {Record<string, string>} */
|
|
962
|
+
const localFallback = {};
|
|
963
|
+
if (opts.localEnv && typeof opts.localEnv === "object") {
|
|
964
|
+
Object.assign(localFallback, opts.localEnv);
|
|
965
|
+
}
|
|
966
|
+
writeProjectEnv(folder, localFallback, { remove });
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
persistWorkspaceEntry(cfg, ws);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function parseAllTryCloudflareUrls(text) {
|
|
973
|
+
const matches = [
|
|
974
|
+
...String(text || "").matchAll(
|
|
975
|
+
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/gi
|
|
976
|
+
),
|
|
977
|
+
];
|
|
978
|
+
return matches.map((m) => m[0].replace(/\/$/, ""));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function lastTryCloudflareUrl(text) {
|
|
982
|
+
const all = parseAllTryCloudflareUrls(text);
|
|
983
|
+
return all.length ? all[all.length - 1] : null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Discover Cloudflare tunnel URLs from bridge state, tunnel file, project env,
|
|
988
|
+
* and cloudflared log files — even if this bridge process did not start them.
|
|
989
|
+
* @returns {Record<string, string> | null}
|
|
990
|
+
*/
|
|
991
|
+
function discoverCloudflareTunnels(ws) {
|
|
992
|
+
if (!ws?.folderPath) return null;
|
|
993
|
+
const folder = path.resolve(ws.folderPath);
|
|
994
|
+
/** @type {Record<string, string>} */
|
|
995
|
+
const tunnels = {};
|
|
996
|
+
const setRole = (role, value) => {
|
|
997
|
+
if (!role || tunnels[role] || !isTryCloudflareUrl(value)) return;
|
|
998
|
+
tunnels[role] = String(value).replace(/\/$/, "");
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
if (ws.cloudflare && typeof ws.cloudflare === "object") {
|
|
1002
|
+
for (const [role, value] of Object.entries(ws.cloudflare)) {
|
|
1003
|
+
setRole(role, value);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (ws.cloudflareUrl) setRole("ui", ws.cloudflareUrl);
|
|
1007
|
+
|
|
1008
|
+
const fromFile = readCloudflareTunnelFile(folder);
|
|
1009
|
+
if (fromFile) {
|
|
1010
|
+
for (const [role, value] of Object.entries(fromFile)) setRole(role, value);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const env = readProjectEnvValues(folder);
|
|
1014
|
+
const envRoles = [
|
|
1015
|
+
["ai", env.NEXT_PUBLIC_AI_SERVER_URL],
|
|
1016
|
+
["ai", env.VITE_AI_SERVER_URL],
|
|
1017
|
+
["ai", env.AI_SERVER_URL],
|
|
1018
|
+
["ai", env.REACT_APP_AI_SERVER_URL],
|
|
1019
|
+
["ui", env.NEXT_PUBLIC_APP_URL],
|
|
1020
|
+
["ui", env.VITE_APP_URL],
|
|
1021
|
+
["ui", env.APP_URL],
|
|
1022
|
+
["ui", env.PUBLIC_URL],
|
|
1023
|
+
["backend", env.NEXT_PUBLIC_API_URL],
|
|
1024
|
+
["backend", env.VITE_API_URL],
|
|
1025
|
+
["backend", env.API_URL],
|
|
1026
|
+
];
|
|
1027
|
+
for (const [role, value] of envRoles) setRole(role, value);
|
|
1028
|
+
|
|
1029
|
+
const logDir = path.join(folder, ".maintainer-pro");
|
|
1030
|
+
if (fs.existsSync(logDir)) {
|
|
1031
|
+
const sandboxPrefix = `cf-${String(ws.sandboxId || "").slice(0, 8)}-`;
|
|
1032
|
+
let names = [];
|
|
1033
|
+
try {
|
|
1034
|
+
names = fs.readdirSync(logDir);
|
|
1035
|
+
} catch {
|
|
1036
|
+
names = [];
|
|
1037
|
+
}
|
|
1038
|
+
// Prefer sandbox-scoped logs, then any cf-*-role.log in the project.
|
|
1039
|
+
const ranked = names
|
|
1040
|
+
.filter((name) => /^cf-.*\.log$/i.test(name) && !name.endsWith(".stale"))
|
|
1041
|
+
.sort((a, b) => {
|
|
1042
|
+
const aScore = a.startsWith(sandboxPrefix) ? 0 : 1;
|
|
1043
|
+
const bScore = b.startsWith(sandboxPrefix) ? 0 : 1;
|
|
1044
|
+
return aScore - bScore || a.localeCompare(b);
|
|
1045
|
+
});
|
|
1046
|
+
for (const name of ranked) {
|
|
1047
|
+
const roleMatch = name.match(
|
|
1048
|
+
/cf-(?:[a-f0-9]{6,}-)?(ai|ui|backend|app)\.log$/i
|
|
1049
|
+
);
|
|
1050
|
+
if (!roleMatch) continue;
|
|
1051
|
+
let role = roleMatch[1].toLowerCase();
|
|
1052
|
+
if (role === "app") role = "ui";
|
|
1053
|
+
if (tunnels[role]) continue;
|
|
1054
|
+
try {
|
|
1055
|
+
const text = fs.readFileSync(path.join(logDir, name), "utf8");
|
|
1056
|
+
const url = lastTryCloudflareUrl(text);
|
|
1057
|
+
if (url) setRole(role, url);
|
|
1058
|
+
} catch {
|
|
1059
|
+
/* ignore unreadable logs */
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return Object.keys(tunnels).length ? tunnels : null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
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
|
+
function workspaceHostReport(ws) {
|
|
1093
|
+
/** @type {string[]} */
|
|
1094
|
+
const origins = [];
|
|
1095
|
+
const add = (value) => {
|
|
1096
|
+
const origin = originFromUrl(value);
|
|
1097
|
+
if (origin && !origins.includes(origin)) origins.push(origin);
|
|
1098
|
+
};
|
|
1099
|
+
const env = readProjectEnvValues(ws.folderPath);
|
|
1100
|
+
const discovered = discoverCloudflareTunnels(ws);
|
|
1101
|
+
const liveCf = cloudflareLive(ws);
|
|
1102
|
+
const chatPort = Number(env.AI_SERVER_PORT || ws.port);
|
|
1103
|
+
const uiPort = Number(
|
|
1104
|
+
env.PORT || ws.projectInfo?.ports?.ui || ws.projectInfo?.ports?.app
|
|
1105
|
+
);
|
|
1106
|
+
const cf =
|
|
1107
|
+
liveCf || discovered
|
|
1108
|
+
? {
|
|
1109
|
+
...(typeof ws.cloudflare === "object" && ws.cloudflare
|
|
1110
|
+
? ws.cloudflare
|
|
1111
|
+
: {}),
|
|
1112
|
+
...(discovered || {}),
|
|
1113
|
+
}
|
|
1114
|
+
: null;
|
|
1115
|
+
|
|
1116
|
+
if (cf && Object.keys(cf).length) {
|
|
1117
|
+
add(ws.cloudflareUrl);
|
|
1118
|
+
for (const value of Object.values(cf)) add(value);
|
|
1119
|
+
add(env.NEXT_PUBLIC_AI_SERVER_URL);
|
|
1120
|
+
add(env.AI_SERVER_URL);
|
|
1121
|
+
add(env.APP_URL);
|
|
1122
|
+
add(env.NEXT_PUBLIC_APP_URL);
|
|
1123
|
+
} else {
|
|
1124
|
+
for (const value of [
|
|
1125
|
+
env.CORS_ORIGIN,
|
|
1126
|
+
env.APP_URL,
|
|
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);
|
|
1135
|
+
}
|
|
1136
|
+
if (chatPort) {
|
|
1137
|
+
add(`http://localhost:${chatPort}`);
|
|
1138
|
+
add(`http://127.0.0.1:${chatPort}`);
|
|
1139
|
+
}
|
|
1140
|
+
if (uiPort) {
|
|
1141
|
+
add(`http://localhost:${uiPort}`);
|
|
1142
|
+
add(`http://127.0.0.1:${uiPort}`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let appUrl = null;
|
|
1146
|
+
if (cf?.ui && isTryCloudflareUrl(cf.ui)) {
|
|
1147
|
+
appUrl = normalizePublicOrigin(cf.ui);
|
|
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);
|
|
1152
|
+
} else if (env.APP_URL && isLocalAppUrl(env.APP_URL)) {
|
|
1153
|
+
appUrl = originFromUrl(env.APP_URL);
|
|
1154
|
+
} else if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
|
|
1155
|
+
appUrl = originFromUrl(ws.appUrl) || ws.appUrl;
|
|
1156
|
+
} else if (uiPort) {
|
|
1157
|
+
appUrl = `http://localhost:${uiPort}`;
|
|
1158
|
+
} else if (chatPort) {
|
|
1159
|
+
appUrl = `http://localhost:${chatPort}`;
|
|
1160
|
+
}
|
|
1161
|
+
return { appUrl, origins };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
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
|
+
/**
|
|
1204
|
+
* Status-only reconcile for a workspace:
|
|
1205
|
+
* 1) probe chat / ui / backend
|
|
1206
|
+
* 2) detect reachable Cloudflare (never starts tunnels)
|
|
1207
|
+
* 3) sync base URLs in env when mode or ports changed
|
|
1208
|
+
* 4) compute host appUrl + CORS origins for Maintainer Pro
|
|
1209
|
+
*
|
|
1210
|
+
* Does not start apps or Cloudflare.
|
|
1211
|
+
*/
|
|
1212
|
+
async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
|
|
1213
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1214
|
+
const writeEnv = opts.writeEnv !== false;
|
|
1215
|
+
const timeoutMs = opts.timeoutMs || 800;
|
|
1216
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1217
|
+
|
|
1218
|
+
// 1. Local process probes
|
|
1219
|
+
const probe = await probeRunningApps(ws, timeoutMs);
|
|
1220
|
+
if (probe.chatUp && Number(ws.port) !== probe.chatPort) {
|
|
1221
|
+
ws.port = probe.chatPort;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// 2. Cloudflare detection (never starts tunnels). Purge stale URLs so we
|
|
1225
|
+
// do not keep re-probing dead trycloudflare links from env/logs every tick.
|
|
1226
|
+
const discovered = discoverCloudflareTunnels(ws);
|
|
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
|
+
}
|
|
1254
|
+
purgeUnreachableCloudflare(ws, cfg, {
|
|
1255
|
+
deadUrls: Object.values(deadCf),
|
|
1256
|
+
keep,
|
|
1257
|
+
localEnv,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const usingCloudflare = Boolean(
|
|
1262
|
+
managedLive || (reachableCf && Object.keys(reachableCf).length)
|
|
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
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// 4. Host + CORS origins for Maintainer Pro
|
|
1346
|
+
const host = workspaceHostReport(ws);
|
|
1347
|
+
if (cfTunnels?.ui && isTryCloudflareUrl(cfTunnels.ui)) {
|
|
1348
|
+
host.appUrl = normalizePublicOrigin(cfTunnels.ui);
|
|
1349
|
+
} else if (cfTunnels?.ai && isTryCloudflareUrl(cfTunnels.ai)) {
|
|
1350
|
+
host.appUrl = normalizePublicOrigin(cfTunnels.ai);
|
|
1351
|
+
} else if (probe.running) {
|
|
1352
|
+
const ui = probe.hosts.find(
|
|
1353
|
+
(h) => (h.role === "ui" || h.role === "app") && h.up
|
|
1354
|
+
);
|
|
1355
|
+
if (ui?.port) {
|
|
1356
|
+
host.appUrl = `http://localhost:${ui.port}`;
|
|
1357
|
+
} else if (probe.chatUp) {
|
|
1358
|
+
host.appUrl = `http://localhost:${probe.chatPort}`;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Ensure probed local origins are always included when processes are up.
|
|
1363
|
+
for (const h of probe.hosts) {
|
|
1364
|
+
if (!h.up || !h.port) continue;
|
|
1365
|
+
const origin = `http://localhost:${h.port}`;
|
|
1366
|
+
if (!host.origins.includes(origin)) host.origins.push(origin);
|
|
1367
|
+
const loopback = `http://127.0.0.1:${h.port}`;
|
|
1368
|
+
if (!host.origins.includes(loopback)) host.origins.push(loopback);
|
|
1369
|
+
}
|
|
1370
|
+
if (probe.chatUp) {
|
|
1371
|
+
const chatOrigin = `http://localhost:${probe.chatPort}`;
|
|
1372
|
+
if (!host.origins.includes(chatOrigin)) host.origins.push(chatOrigin);
|
|
1373
|
+
}
|
|
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
|
+
|
|
1381
|
+
if (host.appUrl) ws.appUrl = host.appUrl;
|
|
1382
|
+
|
|
1383
|
+
if (probe.running) {
|
|
1384
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1385
|
+
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
1386
|
+
clearProcessProblem(ws.sandboxId, "ai_server_down", "ai");
|
|
1387
|
+
for (const h of probe.hosts) {
|
|
1388
|
+
if (!h.up) continue;
|
|
1389
|
+
clearProcessProblem(ws.sandboxId, "host_process_launch", h.role);
|
|
1390
|
+
clearProcessProblem(ws.sandboxId, "host_process_down", h.role);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1395
|
+
|
|
1396
|
+
return {
|
|
1397
|
+
probe,
|
|
1398
|
+
usingCloudflare,
|
|
1399
|
+
cloudflare: cfTunnels,
|
|
1400
|
+
host,
|
|
1401
|
+
appsRunning: probe.running,
|
|
1402
|
+
aiServerUp: probe.chatUp,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function reconcileCloudflareState(ws, cfg) {
|
|
1407
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
1408
|
+
writeEnv: true,
|
|
1409
|
+
timeoutMs: 2500,
|
|
1410
|
+
});
|
|
1411
|
+
return status.usingCloudflare;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* If Cloudflare is already running for this folder, attach and share with MP
|
|
1416
|
+
* instead of creating new tunnels.
|
|
1417
|
+
*/
|
|
1418
|
+
async function tryAttachExistingCloudflare(ws, cfg, opts = {}) {
|
|
1419
|
+
const progress =
|
|
1420
|
+
typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
|
|
1421
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1422
|
+
await progress(
|
|
1423
|
+
"Looking for Cloudflare tunnels that are already running for this project…"
|
|
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;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
writeTunnelEnv(ws, live);
|
|
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) {
|
|
1527
|
+
const n = Number(port);
|
|
1528
|
+
if (!n) return false;
|
|
1529
|
+
for (const host of ["127.0.0.1", "localhost"]) {
|
|
1530
|
+
for (const suffix of paths) {
|
|
1531
|
+
if (await probeUrl(`http://${host}:${n}${suffix}`, timeoutMs)) return true;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async function discoverChatPort(ws, timeoutMs = 2500) {
|
|
1538
|
+
const env = readProjectEnvValues(ws.folderPath);
|
|
1539
|
+
const candidates = [
|
|
1540
|
+
Number(ws.port),
|
|
1541
|
+
Number(env.AI_SERVER_PORT),
|
|
1542
|
+
portFromText(env.AI_SERVER_URL, 0),
|
|
1543
|
+
portFromText(env.NEXT_PUBLIC_AI_SERVER_URL, 0),
|
|
1544
|
+
3100,
|
|
1545
|
+
].filter((port) => port >= 1024);
|
|
1546
|
+
const unique = [...new Set(candidates)];
|
|
1547
|
+
const chatPaths = ["/embed-config.js", "/", "/health"];
|
|
1548
|
+
for (const port of unique) {
|
|
1549
|
+
if (await probeHttpPaths(port, chatPaths, timeoutMs)) {
|
|
1550
|
+
return { port, up: true };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return { port: unique[0] || Number(ws.port) || 3100, up: false };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function probeRunningApps(ws, timeoutMs = 2500) {
|
|
1557
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1558
|
+
const env = readProjectEnvValues(ws.folderPath);
|
|
1559
|
+
const chat = await discoverChatPort(ws, timeoutMs);
|
|
1560
|
+
if (chat.up && Number(ws.port) !== chat.port) {
|
|
1561
|
+
ws.port = chat.port;
|
|
1562
|
+
}
|
|
1563
|
+
const jobs = planHostJobs(
|
|
1564
|
+
folder,
|
|
1565
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
1566
|
+
ws.projectInfo
|
|
1567
|
+
);
|
|
1568
|
+
/** @type {Array<{ role: string, port: number, up: boolean }>} */
|
|
1569
|
+
const hosts = [];
|
|
1570
|
+
for (const job of jobs) {
|
|
1571
|
+
const candidates = [
|
|
1572
|
+
Number(job.port),
|
|
1573
|
+
Number(job.preferredPort),
|
|
1574
|
+
Number(env.PORT),
|
|
1575
|
+
portFromText(env.APP_URL, 0),
|
|
1576
|
+
portFromText(ws.appUrl, 0),
|
|
1577
|
+
].filter((port) => port >= 1024);
|
|
1578
|
+
const unique = [...new Set(candidates.length ? candidates : [3000])];
|
|
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 });
|
|
291
1595
|
}
|
|
292
|
-
|
|
1596
|
+
return {
|
|
1597
|
+
chatUp: chat.up,
|
|
1598
|
+
chatPort: chat.port,
|
|
1599
|
+
hosts,
|
|
1600
|
+
running: chat.up || hosts.some((host) => host.up),
|
|
1601
|
+
};
|
|
293
1602
|
}
|
|
294
1603
|
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
1604
|
+
async function restoreHostsAfterReconnect(cfg) {
|
|
1605
|
+
for (const ws of cfg.workspaces || []) {
|
|
1606
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1607
|
+
// Status-only: probe apps + Cloudflare, sync env/origins for MP.
|
|
1608
|
+
// Never start apps or tunnels on reconnect.
|
|
1609
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
1610
|
+
writeEnv: true,
|
|
1611
|
+
timeoutMs: 2500,
|
|
1612
|
+
});
|
|
1613
|
+
const hostSummary = status.probe.hosts
|
|
1614
|
+
.map((host) => `${host.role}:${host.port}${host.up ? "(up)" : "(down)"}`)
|
|
1615
|
+
.join(" ");
|
|
1616
|
+
if (status.appsRunning) {
|
|
1617
|
+
log(
|
|
1618
|
+
`found running apps for ${label}: chat=${status.probe.chatPort}${
|
|
1619
|
+
status.probe.chatUp ? "(up)" : "(down)"
|
|
1620
|
+
}${hostSummary ? ` ${hostSummary}` : ""} cloudflare=${
|
|
1621
|
+
status.usingCloudflare ? "yes" : "no"
|
|
1622
|
+
} app=${status.host.appUrl || "(none)"} origins=${
|
|
1623
|
+
status.host.origins.join(",") || "none"
|
|
1624
|
+
}`
|
|
1625
|
+
);
|
|
1626
|
+
} else {
|
|
1627
|
+
log(
|
|
1628
|
+
`no apps running for ${label}${
|
|
1629
|
+
status.usingCloudflare ? " (cloudflare urls present)" : ""
|
|
1630
|
+
} — waiting for Start Apps from Maintainer Pro`
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
302
1633
|
}
|
|
303
1634
|
}
|
|
304
1635
|
|
|
305
|
-
function
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1636
|
+
function envForWorkspacePorts(ws, jobs) {
|
|
1637
|
+
const aiPort = Number(ws.port) || 3100;
|
|
1638
|
+
const publicAi =
|
|
1639
|
+
(typeof ws.cloudflare?.ai === "string" &&
|
|
1640
|
+
isTryCloudflareUrl(ws.cloudflare.ai) &&
|
|
1641
|
+
String(ws.cloudflare.ai).replace(/\/$/, "")) ||
|
|
1642
|
+
"";
|
|
1643
|
+
const ai = publicAi || `http://localhost:${aiPort}`;
|
|
1644
|
+
/** @type {Record<string, string>} */
|
|
1645
|
+
const env = {
|
|
1646
|
+
AI_SERVER_PORT: String(aiPort),
|
|
1647
|
+
AI_SERVER_URL: ai,
|
|
1648
|
+
NEXT_PUBLIC_AI_SERVER_URL: ai,
|
|
1649
|
+
VITE_AI_SERVER_URL: ai,
|
|
1650
|
+
REACT_APP_AI_SERVER_URL: ai,
|
|
1651
|
+
};
|
|
1652
|
+
const ui = jobs.find((job) => job.role === "ui" || job.role === "app");
|
|
1653
|
+
const publicUi =
|
|
1654
|
+
(typeof ws.cloudflare?.ui === "string" &&
|
|
1655
|
+
isTryCloudflareUrl(ws.cloudflare.ui) &&
|
|
1656
|
+
String(ws.cloudflare.ui).replace(/\/$/, "")) ||
|
|
1657
|
+
"";
|
|
1658
|
+
if (publicUi) {
|
|
1659
|
+
env.PORT = ui?.port ? String(ui.port) : env.PORT;
|
|
1660
|
+
env.APP_URL = publicUi;
|
|
1661
|
+
env.CORS_ORIGIN = publicUi;
|
|
1662
|
+
env.PUBLIC_URL = publicUi;
|
|
1663
|
+
env.NEXT_PUBLIC_APP_URL = publicUi;
|
|
1664
|
+
env.VITE_APP_URL = publicUi;
|
|
1665
|
+
} else if (ui?.port) {
|
|
1666
|
+
const app = `http://localhost:${ui.port}`;
|
|
1667
|
+
env.PORT = String(ui.port);
|
|
1668
|
+
env.APP_URL = app;
|
|
1669
|
+
env.CORS_ORIGIN = app;
|
|
1670
|
+
env.NEXT_PUBLIC_APP_URL = app;
|
|
1671
|
+
env.VITE_APP_URL = app;
|
|
311
1672
|
}
|
|
1673
|
+
const backend = jobs.find((job) => job.role === "backend");
|
|
1674
|
+
if (backend?.port) {
|
|
1675
|
+
const publicBackend =
|
|
1676
|
+
(typeof ws.cloudflare?.backend === "string" &&
|
|
1677
|
+
isTryCloudflareUrl(ws.cloudflare.backend) &&
|
|
1678
|
+
String(ws.cloudflare.backend).replace(/\/$/, "")) ||
|
|
1679
|
+
"";
|
|
1680
|
+
const api = publicBackend || `http://localhost:${backend.port}`;
|
|
1681
|
+
env.API_URL = api;
|
|
1682
|
+
env.API_PORT = String(backend.port);
|
|
1683
|
+
env.VITE_API_URL = api;
|
|
1684
|
+
env.NEXT_PUBLIC_API_URL = api;
|
|
1685
|
+
}
|
|
1686
|
+
return env;
|
|
312
1687
|
}
|
|
313
1688
|
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
req.destroy();
|
|
339
|
-
done(false);
|
|
340
|
-
});
|
|
341
|
-
} catch {
|
|
342
|
-
done(false);
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
}
|
|
1689
|
+
async function prepareWorkspaceLaunch(ws, cfg, reserved) {
|
|
1690
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1691
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
1692
|
+
if (!folder || !fs.existsSync(folder)) {
|
|
1693
|
+
const aiPort = Number(ws.port) || 3100;
|
|
1694
|
+
log(`ports skip ${label}: folder missing (${folder || "none"})`);
|
|
1695
|
+
return { aiPort, jobs: [], env: {} };
|
|
1696
|
+
}
|
|
1697
|
+
log(`ports pick ${label} in ${folder}`);
|
|
1698
|
+
const preferredAi = Number(ws.port) || 3100;
|
|
1699
|
+
const aiUp = await probeUrl(
|
|
1700
|
+
`http://127.0.0.1:${preferredAi}/embed-config.js`
|
|
1701
|
+
);
|
|
1702
|
+
const aiPort = aiUp
|
|
1703
|
+
? (reserved.add(preferredAi), preferredAi)
|
|
1704
|
+
: await findFreePort(preferredAi, reserved);
|
|
1705
|
+
log(
|
|
1706
|
+
aiUp
|
|
1707
|
+
? `ports chat ${preferredAi} already up`
|
|
1708
|
+
: aiPort === preferredAi
|
|
1709
|
+
? `ports chat ${aiPort} free`
|
|
1710
|
+
: `ports chat ${preferredAi} busy; using ${aiPort}`
|
|
1711
|
+
);
|
|
1712
|
+
ws.port = aiPort;
|
|
346
1713
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
1714
|
+
const jobs = planHostJobs(
|
|
1715
|
+
folder,
|
|
1716
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
1717
|
+
ws.projectInfo
|
|
1718
|
+
);
|
|
1719
|
+
log(
|
|
1720
|
+
jobs.length
|
|
1721
|
+
? `ports jobs ${jobs.map((job) => `${job.role}:${job.script}:${job.preferredPort}`).join(" ")}`
|
|
1722
|
+
: `ports jobs none`
|
|
1723
|
+
);
|
|
1724
|
+
const planned = [];
|
|
1725
|
+
for (const job of jobs) {
|
|
1726
|
+
const preferred = Number(job.preferredPort) || 3000;
|
|
1727
|
+
const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
|
|
1728
|
+
"localhost",
|
|
1729
|
+
"127.0.0.1"
|
|
1730
|
+
);
|
|
1731
|
+
const up = await probeUrl(probe);
|
|
1732
|
+
const port = up
|
|
1733
|
+
? (reserved.add(portFromText(probe, preferred)), portFromText(probe, preferred))
|
|
1734
|
+
: await findFreePort(preferred, reserved);
|
|
1735
|
+
log(
|
|
1736
|
+
up
|
|
1737
|
+
? `ports ${job.role} ${port} already up (${probe})`
|
|
1738
|
+
: port === preferred
|
|
1739
|
+
? `ports ${job.role} ${port} free for ${job.script}`
|
|
1740
|
+
: `ports ${job.role} ${preferred} busy; using ${port} for ${job.script}`
|
|
1741
|
+
);
|
|
1742
|
+
planned.push({ ...job, port, up });
|
|
1743
|
+
if (
|
|
1744
|
+
(job.role === "ui" || job.role === "app") &&
|
|
1745
|
+
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
1746
|
+
) {
|
|
1747
|
+
ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
|
|
356
1748
|
}
|
|
357
1749
|
}
|
|
358
|
-
return roots.length ? roots : ["C:\\"];
|
|
359
|
-
}
|
|
360
1750
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
entries: roots.map((root) => ({
|
|
371
|
-
name: root,
|
|
372
|
-
path: root,
|
|
373
|
-
isDir: true,
|
|
374
|
-
})),
|
|
1751
|
+
const env = envForWorkspacePorts(ws, planned);
|
|
1752
|
+
writeProjectEnv(folder, env);
|
|
1753
|
+
if (planned.length) {
|
|
1754
|
+
ws.projectInfo = {
|
|
1755
|
+
...(ws.projectInfo || {}),
|
|
1756
|
+
ports: {
|
|
1757
|
+
...(ws.projectInfo?.ports || {}),
|
|
1758
|
+
...Object.fromEntries(planned.map((job) => [job.role, job.port])),
|
|
1759
|
+
},
|
|
375
1760
|
};
|
|
376
1761
|
}
|
|
1762
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1763
|
+
log(
|
|
1764
|
+
`ports ready ${label}: chat=${aiPort}${planned
|
|
1765
|
+
.map((job) => ` ${job.role}=${job.port}${job.up ? "(up)" : ""}`)
|
|
1766
|
+
.join("")}`
|
|
1767
|
+
);
|
|
1768
|
+
return { aiPort, jobs: planned, env };
|
|
1769
|
+
}
|
|
377
1770
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
} catch {
|
|
401
|
-
/* skip */
|
|
402
|
-
}
|
|
1771
|
+
const ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
|
|
1772
|
+
const ACCESS_IGNORE_END = "# maintainer-pro:access-end";
|
|
1773
|
+
const DEFAULT_AI_IGNORE_PATHS = [
|
|
1774
|
+
".env",
|
|
1775
|
+
".env.*",
|
|
1776
|
+
"**/.env",
|
|
1777
|
+
"**/.env.*",
|
|
1778
|
+
".maintainer-pro/",
|
|
1779
|
+
];
|
|
1780
|
+
|
|
1781
|
+
function normalizeIgnorePaths(paths) {
|
|
1782
|
+
/** @type {string[]} */
|
|
1783
|
+
const out = [];
|
|
1784
|
+
const seen = new Set();
|
|
1785
|
+
for (const raw of paths || []) {
|
|
1786
|
+
const p = String(raw || "")
|
|
1787
|
+
.trim()
|
|
1788
|
+
.replace(/\\/g, "/");
|
|
1789
|
+
if (!p || p.startsWith("/") || p.includes("..")) continue;
|
|
1790
|
+
if (seen.has(p)) continue;
|
|
1791
|
+
seen.add(p);
|
|
1792
|
+
out.push(p);
|
|
403
1793
|
}
|
|
404
|
-
|
|
405
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
406
|
-
return a.name.localeCompare(b.name);
|
|
407
|
-
});
|
|
408
|
-
const parent = path.dirname(resolved);
|
|
409
|
-
return {
|
|
410
|
-
path: resolved,
|
|
411
|
-
parent: parent === resolved ? null : parent,
|
|
412
|
-
home,
|
|
413
|
-
entries: entries.slice(0, 400),
|
|
414
|
-
};
|
|
1794
|
+
return out;
|
|
415
1795
|
}
|
|
416
1796
|
|
|
417
|
-
function
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1797
|
+
function resolveIgnorePaths(partnerPaths) {
|
|
1798
|
+
return normalizeIgnorePaths([...DEFAULT_AI_IGNORE_PATHS, ...(partnerPaths || [])]);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
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`;
|
|
428
1817
|
}
|
|
429
|
-
|
|
430
|
-
|
|
1818
|
+
const trimmed = existing.replace(/\s+$/, "");
|
|
1819
|
+
return trimmed ? `${trimmed}\n\n${block}` : block;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Restrict AI CLI to this project folder and write partner ignore paths.
|
|
1824
|
+
* @param {string} folder
|
|
1825
|
+
* @param {string[]} partnerIgnorePaths
|
|
1826
|
+
*/
|
|
1827
|
+
function applyAccessPolicy(folder, partnerIgnorePaths) {
|
|
1828
|
+
const resolved = path.resolve(folder);
|
|
1829
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
1830
|
+
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
|
+
|
|
1839
|
+
const mpDir = path.join(resolved, ".maintainer-pro");
|
|
1840
|
+
fs.mkdirSync(mpDir, { recursive: true });
|
|
1841
|
+
fs.writeFileSync(
|
|
1842
|
+
path.join(mpDir, "access.json"),
|
|
1843
|
+
JSON.stringify(
|
|
1844
|
+
{
|
|
1845
|
+
workspace: ".",
|
|
1846
|
+
ignorePaths,
|
|
1847
|
+
partnerIgnorePaths: normalizeIgnorePaths(partnerIgnorePaths || []),
|
|
1848
|
+
updatedAt: new Date().toISOString(),
|
|
1849
|
+
},
|
|
1850
|
+
null,
|
|
1851
|
+
2
|
|
1852
|
+
) + "\n",
|
|
1853
|
+
"utf8"
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
for (const name of [".cursorignore"]) {
|
|
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");
|
|
431
1862
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
.join("\n");
|
|
435
|
-
fs.writeFileSync(file, body + "\n", "utf8");
|
|
1863
|
+
|
|
1864
|
+
return { ignorePaths };
|
|
436
1865
|
}
|
|
437
1866
|
|
|
438
1867
|
const IGNORE_NAMES = new Set([
|
|
@@ -846,6 +2275,8 @@ function collectOfferedFolders(cfg) {
|
|
|
846
2275
|
|
|
847
2276
|
/** Prevents opening a new window on every heartbeat while a process is starting. */
|
|
848
2277
|
const launchedAt = new Map();
|
|
2278
|
+
/** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
|
|
2279
|
+
const cloudflareTunnels = new Map();
|
|
849
2280
|
|
|
850
2281
|
/** Last process problems to send on heartbeat. Key: sandboxId::code::role */
|
|
851
2282
|
const processProblems = new Map();
|
|
@@ -1081,12 +2512,67 @@ function planHostJobs(dir, appUrl, hints = null) {
|
|
|
1081
2512
|
|
|
1082
2513
|
function friendlyLaunchError(raw, title) {
|
|
1083
2514
|
const text = String(raw || "").trim();
|
|
1084
|
-
if (/
|
|
2515
|
+
if (/^spawn\b.*\bENOENT\b/i.test(text)) {
|
|
1085
2516
|
return `Could not find a program to open a terminal for "${title}". ${text}`;
|
|
1086
2517
|
}
|
|
1087
2518
|
return text || `Could not open a terminal for "${title}".`;
|
|
1088
2519
|
}
|
|
1089
2520
|
|
|
2521
|
+
function writeWinLaunchScript(folder, title, command, env) {
|
|
2522
|
+
const dir = path.join(folder, ".maintainer-pro");
|
|
2523
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2524
|
+
const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
2525
|
+
const file = path.join(dir, `launch-${safe}.cmd`);
|
|
2526
|
+
const folderArg = String(folder).replace(/"/g, "");
|
|
2527
|
+
const safeTitle = String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ");
|
|
2528
|
+
const lines = [
|
|
2529
|
+
"@echo off",
|
|
2530
|
+
`title ${safeTitle}`,
|
|
2531
|
+
`cd /d "${folderArg}"`,
|
|
2532
|
+
"if errorlevel 1 (",
|
|
2533
|
+
" echo Could not open the project folder.",
|
|
2534
|
+
" pause",
|
|
2535
|
+
" exit /b 1",
|
|
2536
|
+
")",
|
|
2537
|
+
...Object.entries(env).map(
|
|
2538
|
+
([key, value]) => `set "${key}=${String(value).replace(/"/g, "")}"`
|
|
2539
|
+
),
|
|
2540
|
+
"echo %CD%",
|
|
2541
|
+
`echo ${command}`,
|
|
2542
|
+
command,
|
|
2543
|
+
"if errorlevel 1 pause",
|
|
2544
|
+
];
|
|
2545
|
+
fs.writeFileSync(file, `${lines.join("\r\n")}\r\n`, "utf8");
|
|
2546
|
+
return file;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function openWindowsConsole(scriptPath, folder) {
|
|
2550
|
+
return new Promise((resolve) => {
|
|
2551
|
+
let child;
|
|
2552
|
+
try {
|
|
2553
|
+
child = spawn(process.env.ComSpec || "cmd.exe", ["/k", scriptPath], {
|
|
2554
|
+
cwd: folder,
|
|
2555
|
+
detached: true,
|
|
2556
|
+
stdio: "ignore",
|
|
2557
|
+
windowsHide: false,
|
|
2558
|
+
});
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
resolve({
|
|
2561
|
+
ok: false,
|
|
2562
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2563
|
+
});
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
child.once("error", (err) => {
|
|
2567
|
+
resolve({ ok: false, error: err.message });
|
|
2568
|
+
});
|
|
2569
|
+
child.once("spawn", () => {
|
|
2570
|
+
child.unref();
|
|
2571
|
+
resolve({ ok: true });
|
|
2572
|
+
});
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
|
|
1090
2576
|
function runLauncher(command, args, extra = {}) {
|
|
1091
2577
|
return new Promise((resolve) => {
|
|
1092
2578
|
let settled = false;
|
|
@@ -1136,6 +2622,7 @@ async function openInNewTerminal(opts) {
|
|
|
1136
2622
|
const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
|
|
1137
2623
|
if (launchKey) {
|
|
1138
2624
|
if (!opts.force && recentlyLaunched(launchKey)) {
|
|
2625
|
+
log(`terminal skip [${title}]: launched recently`);
|
|
1139
2626
|
return { ok: true, skipped: true };
|
|
1140
2627
|
}
|
|
1141
2628
|
launchedAt.set(launchKey, Date.now());
|
|
@@ -1153,9 +2640,6 @@ async function openInNewTerminal(opts) {
|
|
|
1153
2640
|
title
|
|
1154
2641
|
);
|
|
1155
2642
|
|
|
1156
|
-
const envWin = Object.entries(env)
|
|
1157
|
-
.map(([key, value]) => `set ${key}=${value}`)
|
|
1158
|
-
.join("&& ");
|
|
1159
2643
|
const envUnix = Object.entries(env)
|
|
1160
2644
|
.map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
|
|
1161
2645
|
.join(" && ");
|
|
@@ -1167,17 +2651,14 @@ async function openInNewTerminal(opts) {
|
|
|
1167
2651
|
const safeTitle =
|
|
1168
2652
|
String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
|
|
1169
2653
|
"Maintainer Pro";
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
const opened = await runLauncher(
|
|
1174
|
-
process.env.ComSpec || "cmd.exe",
|
|
1175
|
-
["/d", "/s", "/c", `start "${safeTitle}" /D "${folder}" cmd.exe /k ${body}`],
|
|
1176
|
-
{ windowsVerbatimArguments: true }
|
|
1177
|
-
);
|
|
2654
|
+
const script = writeWinLaunchScript(folder, safeTitle, command, env);
|
|
2655
|
+
log(`terminal script [${title}] ${script}`);
|
|
2656
|
+
const opened = await openWindowsConsole(script, folder);
|
|
1178
2657
|
if (!opened.ok) {
|
|
2658
|
+
warn(`terminal failed [${title}]: ${opened.error || "unknown"}`);
|
|
1179
2659
|
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1180
2660
|
}
|
|
2661
|
+
log(`terminal opened [${title}]`);
|
|
1181
2662
|
return { ok: true };
|
|
1182
2663
|
}
|
|
1183
2664
|
|
|
@@ -1250,23 +2731,22 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1250
2731
|
if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
|
|
1251
2732
|
reserved.add(preferred);
|
|
1252
2733
|
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2734
|
+
log(`start chat skip ${label}: already up on ${preferred}`);
|
|
1253
2735
|
return { port: preferred, up: true, launched: false };
|
|
1254
2736
|
}
|
|
1255
2737
|
|
|
1256
2738
|
if (recentlyLaunched(launchKey)) {
|
|
1257
2739
|
reserved.add(preferred);
|
|
2740
|
+
log(`start chat skip ${label}: already launching on ${preferred}`);
|
|
1258
2741
|
return { port: preferred, up: false, launched: false, starting: true };
|
|
1259
2742
|
}
|
|
1260
2743
|
|
|
1261
|
-
let port = preferred;
|
|
2744
|
+
let port = Number(opts.port) || preferred;
|
|
1262
2745
|
try {
|
|
1263
|
-
if (
|
|
1264
|
-
if (!(await isPortFree(preferred))) {
|
|
1265
|
-
reserved.delete(preferred);
|
|
1266
|
-
port = await findFreePort(preferred, reserved);
|
|
1267
|
-
}
|
|
1268
|
-
} else {
|
|
2746
|
+
if (!opts.port) {
|
|
1269
2747
|
port = await findFreePort(preferred, reserved);
|
|
2748
|
+
} else {
|
|
2749
|
+
reserved.add(port);
|
|
1270
2750
|
}
|
|
1271
2751
|
} catch (err) {
|
|
1272
2752
|
recordProcessProblem({
|
|
@@ -1277,23 +2757,32 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1277
2757
|
message: `No free port found (tried from ${preferred}). ${
|
|
1278
2758
|
err instanceof Error ? err.message : String(err)
|
|
1279
2759
|
}`,
|
|
1280
|
-
resolution: "Close other local servers, then use Start
|
|
2760
|
+
resolution: "Close other local servers, then use Start Apps.",
|
|
1281
2761
|
});
|
|
1282
2762
|
return { port: preferred, up: false, launched: false };
|
|
1283
2763
|
}
|
|
1284
2764
|
if (port !== preferred) {
|
|
1285
|
-
log(`port ${preferred} busy; using ${port} for
|
|
1286
|
-
ws.port = port;
|
|
1287
|
-
const envPath = path.join(folder, ".env");
|
|
1288
|
-
if (fs.existsSync(envPath)) {
|
|
1289
|
-
mergeEnvFile(envPath, {
|
|
1290
|
-
PORT: String(port),
|
|
1291
|
-
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1292
|
-
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
|
-
persistWorkspaceEntry(cfg, ws);
|
|
2765
|
+
log(`port ${preferred} busy; using ${port} for chat script`);
|
|
1296
2766
|
}
|
|
2767
|
+
ws.port = port;
|
|
2768
|
+
const localAi = `http://localhost:${port}`;
|
|
2769
|
+
const overrideEnv =
|
|
2770
|
+
opts.env && typeof opts.env === "object" ? opts.env : {};
|
|
2771
|
+
const publicAi =
|
|
2772
|
+
(typeof overrideEnv.AI_SERVER_URL === "string" &&
|
|
2773
|
+
overrideEnv.AI_SERVER_URL.trim()) ||
|
|
2774
|
+
(typeof overrideEnv.NEXT_PUBLIC_AI_SERVER_URL === "string" &&
|
|
2775
|
+
overrideEnv.NEXT_PUBLIC_AI_SERVER_URL.trim()) ||
|
|
2776
|
+
(typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
|
|
2777
|
+
"";
|
|
2778
|
+
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
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1297
2786
|
|
|
1298
2787
|
const localCli = path.resolve(
|
|
1299
2788
|
__dirname,
|
|
@@ -1315,9 +2804,11 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1315
2804
|
folder,
|
|
1316
2805
|
command: run,
|
|
1317
2806
|
env: {
|
|
1318
|
-
|
|
1319
|
-
AI_SERVER_URL:
|
|
1320
|
-
NEXT_PUBLIC_AI_SERVER_URL:
|
|
2807
|
+
AI_SERVER_PORT: String(port),
|
|
2808
|
+
AI_SERVER_URL: aiUrl,
|
|
2809
|
+
NEXT_PUBLIC_AI_SERVER_URL: aiUrl,
|
|
2810
|
+
VITE_AI_SERVER_URL: aiUrl,
|
|
2811
|
+
...overrideEnv,
|
|
1321
2812
|
},
|
|
1322
2813
|
launchKey,
|
|
1323
2814
|
sandboxId: ws.sandboxId,
|
|
@@ -1344,18 +2835,12 @@ function sleep(ms) {
|
|
|
1344
2835
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1345
2836
|
}
|
|
1346
2837
|
|
|
1347
|
-
/** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
|
|
1348
|
-
const cloudflareTunnels = new Map();
|
|
1349
|
-
|
|
1350
2838
|
function stopAllCloudflare() {
|
|
1351
2839
|
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
1352
2840
|
}
|
|
1353
2841
|
|
|
1354
2842
|
function parseTryCloudflareUrl(text) {
|
|
1355
|
-
|
|
1356
|
-
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
1357
|
-
);
|
|
1358
|
-
return match ? match[0].replace(/\/$/, "") : null;
|
|
2843
|
+
return lastTryCloudflareUrl(text);
|
|
1359
2844
|
}
|
|
1360
2845
|
|
|
1361
2846
|
function killProcessesByCommand(fragment) {
|
|
@@ -1456,22 +2941,38 @@ async function stopWorkspaceApps(ws) {
|
|
|
1456
2941
|
await sleep(400);
|
|
1457
2942
|
}
|
|
1458
2943
|
|
|
1459
|
-
async function waitUntilReachable(url, timeoutMs, label) {
|
|
2944
|
+
async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
1460
2945
|
const start = Date.now();
|
|
2946
|
+
let last = 0;
|
|
1461
2947
|
while (Date.now() - start < timeoutMs) {
|
|
1462
2948
|
if (await probeUrl(url)) return true;
|
|
2949
|
+
const elapsed = Date.now() - start;
|
|
2950
|
+
if (onWait && elapsed - last >= 8_000) {
|
|
2951
|
+
last = elapsed;
|
|
2952
|
+
await onWait(
|
|
2953
|
+
`Still waiting for ${label} (${Math.round(elapsed / 1000)}s)…`
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
1463
2956
|
await sleep(600);
|
|
1464
2957
|
}
|
|
1465
2958
|
throw new Error(`${label} did not become reachable at ${url}`);
|
|
1466
2959
|
}
|
|
1467
2960
|
|
|
1468
|
-
async function waitForUrlInFile(file, timeoutMs = 90_000) {
|
|
2961
|
+
async function waitForUrlInFile(file, timeoutMs = 90_000, onWait) {
|
|
1469
2962
|
const start = Date.now();
|
|
2963
|
+
let last = 0;
|
|
1470
2964
|
while (Date.now() - start < timeoutMs) {
|
|
1471
2965
|
if (fs.existsSync(file)) {
|
|
1472
2966
|
const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
|
|
1473
2967
|
if (url) return url;
|
|
1474
2968
|
}
|
|
2969
|
+
const elapsed = Date.now() - start;
|
|
2970
|
+
if (onWait && elapsed - last >= 8_000) {
|
|
2971
|
+
last = elapsed;
|
|
2972
|
+
await onWait(
|
|
2973
|
+
`Still waiting for a Cloudflare URL (${Math.round(elapsed / 1000)}s)…`
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
1475
2976
|
await sleep(500);
|
|
1476
2977
|
}
|
|
1477
2978
|
throw new Error(
|
|
@@ -1486,7 +2987,7 @@ function cloudflaredCommand(localUrl, logFile) {
|
|
|
1486
2987
|
return `${run} 2>&1 | tee ${logArg}`;
|
|
1487
2988
|
}
|
|
1488
2989
|
|
|
1489
|
-
async function startCloudflareTerminal(ws, role, localUrl) {
|
|
2990
|
+
async function startCloudflareTerminal(ws, role, localUrl, onWait) {
|
|
1490
2991
|
const folder = path.resolve(ws.folderPath);
|
|
1491
2992
|
const logDir = path.join(folder, ".maintainer-pro");
|
|
1492
2993
|
fs.mkdirSync(logDir, { recursive: true });
|
|
@@ -1510,7 +3011,7 @@ async function startCloudflareTerminal(ws, role, localUrl) {
|
|
|
1510
3011
|
if (!opened.ok) {
|
|
1511
3012
|
throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
|
|
1512
3013
|
}
|
|
1513
|
-
const publicUrl = await waitForUrlInFile(logFile);
|
|
3014
|
+
const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
|
|
1514
3015
|
return { role, localUrl, publicUrl, logFile };
|
|
1515
3016
|
}
|
|
1516
3017
|
|
|
@@ -1518,53 +3019,180 @@ function uiPublicEnv(tunnels) {
|
|
|
1518
3019
|
/** @type {Record<string, string>} */
|
|
1519
3020
|
const env = {};
|
|
1520
3021
|
if (tunnels.ai) {
|
|
1521
|
-
|
|
1522
|
-
env.
|
|
1523
|
-
env.
|
|
3022
|
+
const ai = String(tunnels.ai).replace(/\/$/, "");
|
|
3023
|
+
env.AI_SERVER_URL = ai;
|
|
3024
|
+
env.NEXT_PUBLIC_AI_SERVER_URL = ai;
|
|
3025
|
+
env.VITE_AI_SERVER_URL = ai;
|
|
3026
|
+
env.REACT_APP_AI_SERVER_URL = ai;
|
|
1524
3027
|
}
|
|
1525
3028
|
if (tunnels.backend) {
|
|
1526
|
-
|
|
1527
|
-
env.
|
|
1528
|
-
env.
|
|
1529
|
-
env.
|
|
1530
|
-
env.
|
|
1531
|
-
env.
|
|
1532
|
-
env.
|
|
3029
|
+
const backend = String(tunnels.backend).replace(/\/$/, "");
|
|
3030
|
+
env.API_URL = backend;
|
|
3031
|
+
env.API_BASE_URL = backend;
|
|
3032
|
+
env.VITE_API_URL = backend;
|
|
3033
|
+
env.VITE_API_BASE_URL = backend;
|
|
3034
|
+
env.NEXT_PUBLIC_API_URL = backend;
|
|
3035
|
+
env.NEXT_PUBLIC_API_BASE_URL = backend;
|
|
3036
|
+
env.BACKEND_URL = backend;
|
|
1533
3037
|
}
|
|
1534
3038
|
if (tunnels.ui) {
|
|
1535
|
-
|
|
1536
|
-
env.
|
|
1537
|
-
env.
|
|
3039
|
+
const ui = String(tunnels.ui).replace(/\/$/, "");
|
|
3040
|
+
env.APP_URL = ui;
|
|
3041
|
+
env.PUBLIC_URL = ui;
|
|
3042
|
+
env.CORS_ORIGIN = ui;
|
|
3043
|
+
env.NEXT_PUBLIC_APP_URL = ui;
|
|
3044
|
+
env.VITE_APP_URL = ui;
|
|
3045
|
+
env.REACT_APP_APP_URL = ui;
|
|
1538
3046
|
} else if (tunnels.ai) {
|
|
1539
|
-
|
|
3047
|
+
const ai = String(tunnels.ai).replace(/\/$/, "");
|
|
3048
|
+
env.CORS_ORIGIN = ai;
|
|
3049
|
+
env.APP_URL = ai;
|
|
3050
|
+
env.PUBLIC_URL = ai;
|
|
3051
|
+
env.NEXT_PUBLIC_APP_URL = ai;
|
|
3052
|
+
env.VITE_APP_URL = ai;
|
|
1540
3053
|
}
|
|
1541
3054
|
return env;
|
|
1542
3055
|
}
|
|
1543
3056
|
|
|
1544
3057
|
function writeTunnelEnv(ws, tunnels) {
|
|
1545
3058
|
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
1546
|
-
if (!folder || !fs.existsSync(folder)) return;
|
|
3059
|
+
if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
|
|
1547
3060
|
const env = uiPublicEnv(tunnels);
|
|
1548
3061
|
const lines = Object.entries(tunnels)
|
|
1549
3062
|
.filter(([, url]) => url)
|
|
1550
|
-
.map(([role, url]) => `${role}=${url}`);
|
|
3063
|
+
.map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
|
|
1551
3064
|
fs.writeFileSync(
|
|
1552
3065
|
path.join(folder, ".cloudflare-tunnel-url"),
|
|
1553
3066
|
`${lines.join("\n")}\n`,
|
|
1554
3067
|
"utf8"
|
|
1555
3068
|
);
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
3069
|
+
if (Object.keys(env).length) writeProjectEnv(folder, env);
|
|
3070
|
+
return { ok: true, env };
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
/**
|
|
3074
|
+
* Validate tunnels + frontend env before sharing URLs with Maintainer Pro.
|
|
3075
|
+
* @returns {Promise<{ ok: boolean, checks: Array<{ id: string, ok: boolean, detail: string }> }>}
|
|
3076
|
+
*/
|
|
3077
|
+
async function validateCloudflareGoLive(ws, tunnels, opts = {}) {
|
|
3078
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
3079
|
+
const progress =
|
|
3080
|
+
typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
|
|
3081
|
+
/** @type {Array<{ id: string, ok: boolean, detail: string }>} */
|
|
3082
|
+
const checks = [];
|
|
3083
|
+
const add = (id, ok, detail) => {
|
|
3084
|
+
checks.push({ id, ok, detail });
|
|
3085
|
+
log(`go-live ${ok ? "ok" : "FAIL"} ${id}: ${detail}`);
|
|
3086
|
+
};
|
|
3087
|
+
|
|
3088
|
+
await progress(
|
|
3089
|
+
"Validating Cloudflare setup before sharing with Maintainer Pro…"
|
|
3090
|
+
);
|
|
3091
|
+
|
|
3092
|
+
writeTunnelEnv(ws, tunnels);
|
|
3093
|
+
const env = readProjectEnvValues(folder);
|
|
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}`
|
|
3105
|
+
);
|
|
3106
|
+
if (reachable) {
|
|
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
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
if (tunnels.ui) {
|
|
3123
|
+
const ui = String(tunnels.ui).replace(/\/$/, "");
|
|
3124
|
+
const reachable = await probeUrl(ui, 10_000);
|
|
3125
|
+
add(
|
|
3126
|
+
"ui_tunnel",
|
|
3127
|
+
reachable,
|
|
3128
|
+
reachable ? `Reachable ${ui}` : `Unreachable ${ui}`
|
|
3129
|
+
);
|
|
3130
|
+
} else {
|
|
3131
|
+
add(
|
|
3132
|
+
"ui_tunnel",
|
|
3133
|
+
true,
|
|
3134
|
+
"No separate UI tunnel (AI-only / same-origin share)"
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
const ai = String(tunnels.ai || "").replace(/\/$/, "");
|
|
3139
|
+
if (ai) {
|
|
3140
|
+
const envAi = [
|
|
3141
|
+
env.NEXT_PUBLIC_AI_SERVER_URL,
|
|
3142
|
+
env.VITE_AI_SERVER_URL,
|
|
3143
|
+
env.AI_SERVER_URL,
|
|
3144
|
+
env.REACT_APP_AI_SERVER_URL,
|
|
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
|
+
})`
|
|
3159
|
+
);
|
|
1561
3160
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
3161
|
+
|
|
3162
|
+
if (tunnels.ui) {
|
|
3163
|
+
const ui = String(tunnels.ui).replace(/\/$/, "");
|
|
3164
|
+
const envApp = [
|
|
3165
|
+
env.APP_URL,
|
|
3166
|
+
env.NEXT_PUBLIC_APP_URL,
|
|
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
|
+
})`
|
|
3182
|
+
);
|
|
1564
3183
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
3184
|
+
|
|
3185
|
+
const ok = checks.every((check) => check.ok);
|
|
3186
|
+
if (ok) {
|
|
3187
|
+
await progress("Validation passed — sharing public URLs with Maintainer Pro.");
|
|
3188
|
+
} else {
|
|
3189
|
+
const failed = checks
|
|
3190
|
+
.filter((check) => !check.ok)
|
|
3191
|
+
.map((check) => `${check.id}: ${check.detail}`)
|
|
3192
|
+
.join(" | ");
|
|
3193
|
+
await progress(`Validation failed — not sharing yet. ${failed}`);
|
|
1567
3194
|
}
|
|
3195
|
+
return { ok, checks };
|
|
1568
3196
|
}
|
|
1569
3197
|
|
|
1570
3198
|
function reservedPortsFor(cfg, sandboxId) {
|
|
@@ -1581,17 +3209,39 @@ function appsWanted(ws) {
|
|
|
1581
3209
|
return Boolean(ws?.appsRequested);
|
|
1582
3210
|
}
|
|
1583
3211
|
|
|
1584
|
-
async function configureCloudflareForWorkspace(ws, cfg) {
|
|
3212
|
+
async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
|
|
1585
3213
|
const sandboxId = ws.sandboxId;
|
|
1586
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
|
+
}
|
|
1587
3231
|
|
|
1588
|
-
|
|
3232
|
+
await progress(
|
|
3233
|
+
`Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
|
|
3234
|
+
);
|
|
1589
3235
|
await stopCloudflare(sandboxId);
|
|
3236
|
+
await progress("Stopping local app terminals and freeing their ports…");
|
|
1590
3237
|
await stopWorkspaceApps(ws);
|
|
1591
3238
|
await forgetLaunch(sandboxId);
|
|
1592
3239
|
ws.cloudflarePending = true;
|
|
1593
3240
|
ws.appsRequested = false;
|
|
1594
3241
|
persistWorkspaceEntry(cfg, ws);
|
|
3242
|
+
await progress(
|
|
3243
|
+
"Cloudflare is queued. Use Start Apps to create the public URLs."
|
|
3244
|
+
);
|
|
1595
3245
|
|
|
1596
3246
|
return {
|
|
1597
3247
|
sandboxId,
|
|
@@ -1601,43 +3251,61 @@ async function configureCloudflareForWorkspace(ws, cfg) {
|
|
|
1601
3251
|
cloudflarePending: true,
|
|
1602
3252
|
waitingForStart: true,
|
|
1603
3253
|
warning:
|
|
1604
|
-
"Cloudflare is ready in Maintainer Pro. Use Start
|
|
3254
|
+
"Cloudflare is ready in Maintainer Pro. Use Start Apps when you want to launch the apps and create the public URLs.",
|
|
1605
3255
|
};
|
|
1606
3256
|
}
|
|
1607
3257
|
|
|
1608
|
-
async function launchCloudflareTunnels(ws, cfg) {
|
|
3258
|
+
async function launchCloudflareTunnels(ws, cfg, opts = {}) {
|
|
1609
3259
|
const sandboxId = ws.sandboxId;
|
|
1610
3260
|
const label = ws.sandboxName || "this sandbox";
|
|
1611
3261
|
const folder = path.resolve(ws.folderPath || "");
|
|
1612
3262
|
const reserved = reservedPortsFor(cfg, sandboxId);
|
|
3263
|
+
const progress = (message) =>
|
|
3264
|
+
reportActionProgress(cfg, opts.actionId, message);
|
|
1613
3265
|
|
|
1614
|
-
|
|
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
|
+
);
|
|
1615
3274
|
try {
|
|
3275
|
+
const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
|
|
1616
3276
|
if (!cfg.noAiServer) {
|
|
1617
|
-
await
|
|
3277
|
+
await progress(`Starting the chat script on port ${plan.aiPort}…`);
|
|
3278
|
+
await startAiServerForWorkspace(ws, {
|
|
3279
|
+
reserved,
|
|
3280
|
+
cfg,
|
|
3281
|
+
port: plan.aiPort,
|
|
3282
|
+
});
|
|
1618
3283
|
await waitUntilReachable(
|
|
1619
3284
|
`http://127.0.0.1:${ws.port}/embed-config.js`,
|
|
1620
3285
|
45_000,
|
|
1621
|
-
"
|
|
3286
|
+
"the chat script",
|
|
3287
|
+
progress
|
|
1622
3288
|
);
|
|
1623
3289
|
}
|
|
1624
3290
|
|
|
1625
|
-
const
|
|
1626
|
-
const
|
|
1627
|
-
const uiJob = jobs.find((job) => job.role === "ui" || job.role === "app");
|
|
3291
|
+
const backendJob = plan.jobs.find((job) => job.role === "backend");
|
|
3292
|
+
const uiJob = plan.jobs.find((job) => job.role === "ui" || job.role === "app");
|
|
1628
3293
|
|
|
1629
3294
|
if (backendJob) {
|
|
3295
|
+
await progress(`Starting the backend on port ${backendJob.port}…`);
|
|
1630
3296
|
await ensureHostProcesses(ws, {
|
|
1631
3297
|
reserved,
|
|
1632
3298
|
cfg,
|
|
1633
3299
|
onlyRoles: ["backend"],
|
|
1634
3300
|
force: true,
|
|
3301
|
+
plannedJobs: plan.jobs,
|
|
1635
3302
|
});
|
|
1636
|
-
const backendPort = Number(backendJob.
|
|
3303
|
+
const backendPort = Number(backendJob.port) || 4100;
|
|
1637
3304
|
await waitUntilReachable(
|
|
1638
3305
|
`http://127.0.0.1:${backendPort}`,
|
|
1639
3306
|
45_000,
|
|
1640
|
-
"
|
|
3307
|
+
"the backend",
|
|
3308
|
+
progress
|
|
1641
3309
|
);
|
|
1642
3310
|
}
|
|
1643
3311
|
|
|
@@ -1647,45 +3315,125 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1647
3315
|
const started = [];
|
|
1648
3316
|
|
|
1649
3317
|
const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
|
|
1650
|
-
|
|
3318
|
+
await progress(`Opening a Cloudflare tunnel for the chat script (${aiLocal})…`);
|
|
3319
|
+
const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal, progress);
|
|
1651
3320
|
tunnels.ai = aiTunnel.publicUrl;
|
|
1652
3321
|
started.push(aiTunnel);
|
|
1653
|
-
|
|
3322
|
+
await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
|
|
1654
3323
|
|
|
1655
3324
|
if (backendJob) {
|
|
1656
|
-
const backendLocal = `http://127.0.0.1:${Number(backendJob.
|
|
1657
|
-
|
|
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
|
+
);
|
|
1658
3333
|
tunnels.backend = backendTunnel.publicUrl;
|
|
1659
3334
|
started.push(backendTunnel);
|
|
1660
|
-
|
|
3335
|
+
await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
|
|
1661
3336
|
}
|
|
1662
3337
|
|
|
1663
3338
|
writeTunnelEnv(ws, tunnels);
|
|
1664
|
-
|
|
3339
|
+
let uiEnv = uiPublicEnv(tunnels);
|
|
3340
|
+
const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
|
|
1665
3341
|
|
|
1666
3342
|
if (uiJob) {
|
|
3343
|
+
await progress(`Starting the app UI on port ${uiPort}…`);
|
|
1667
3344
|
await ensureHostProcesses(ws, {
|
|
1668
3345
|
reserved,
|
|
1669
3346
|
cfg,
|
|
1670
3347
|
onlyRoles: ["ui", "app"],
|
|
1671
3348
|
extraEnv: uiEnv,
|
|
1672
3349
|
force: true,
|
|
3350
|
+
plannedJobs: plan.jobs,
|
|
1673
3351
|
});
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
+
);
|
|
1676
3361
|
const uiTunnel = await startCloudflareTerminal(
|
|
1677
3362
|
ws,
|
|
1678
3363
|
"ui",
|
|
1679
|
-
`http://127.0.0.1:${uiPort}
|
|
3364
|
+
`http://127.0.0.1:${uiPort}`,
|
|
3365
|
+
progress
|
|
1680
3366
|
);
|
|
1681
3367
|
tunnels.ui = uiTunnel.publicUrl;
|
|
1682
3368
|
started.push(uiTunnel);
|
|
1683
|
-
|
|
3369
|
+
await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
|
|
1684
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…");
|
|
1685
3403
|
launchedAt.delete(`${sandboxId}:${folder}:ai`);
|
|
1686
3404
|
await killPort(ws.port);
|
|
1687
3405
|
await sleep(1500);
|
|
1688
|
-
await startAiServerForWorkspace(ws, {
|
|
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
|
+
);
|
|
1689
3437
|
}
|
|
1690
3438
|
|
|
1691
3439
|
const appUrl = tunnels.ui || tunnels.ai;
|
|
@@ -1695,16 +3443,22 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1695
3443
|
ws.cloudflarePending = false;
|
|
1696
3444
|
ws.appsRequested = true;
|
|
1697
3445
|
persistWorkspaceEntry(cfg, ws);
|
|
3446
|
+
rememberCloudflareTunnels(sandboxId, tunnels);
|
|
1698
3447
|
cloudflareTunnels.set(sandboxId, { tunnels: started });
|
|
1699
3448
|
clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
|
|
3449
|
+
const host = workspaceHostReport(ws);
|
|
3450
|
+
await progress(`Cloudflare is ready: ${host.appUrl || appUrl}`);
|
|
1700
3451
|
|
|
1701
3452
|
return {
|
|
1702
3453
|
sandboxId,
|
|
1703
3454
|
folderPath: ws.folderPath,
|
|
1704
3455
|
port: ws.port,
|
|
1705
|
-
appUrl,
|
|
1706
|
-
origins:
|
|
3456
|
+
appUrl: host.appUrl || appUrl,
|
|
3457
|
+
origins: host.origins.length
|
|
3458
|
+
? host.origins
|
|
3459
|
+
: Object.values(tunnels).filter(Boolean),
|
|
1707
3460
|
tunnels,
|
|
3461
|
+
validation,
|
|
1708
3462
|
cloudflare: true,
|
|
1709
3463
|
reused: false,
|
|
1710
3464
|
};
|
|
@@ -1717,47 +3471,120 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1717
3471
|
title: `Could not start Cloudflare (${label})`,
|
|
1718
3472
|
message,
|
|
1719
3473
|
resolution:
|
|
1720
|
-
"Install cloudflared or allow npx to download it, then use Start
|
|
3474
|
+
"Install cloudflared or allow npx to download it, then use Start Apps again.",
|
|
1721
3475
|
actionCode: "start_ai_server",
|
|
1722
3476
|
});
|
|
1723
3477
|
throw err;
|
|
1724
3478
|
}
|
|
1725
3479
|
}
|
|
1726
3480
|
|
|
1727
|
-
async function startAppsForWorkspace(ws, cfg) {
|
|
3481
|
+
async function startAppsForWorkspace(ws, cfg, opts = {}) {
|
|
3482
|
+
const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
|
|
3483
|
+
await reportActionProgress(
|
|
3484
|
+
cfg,
|
|
3485
|
+
opts.actionId,
|
|
3486
|
+
`Checking running apps and Cloudflare for ${label}…`
|
|
3487
|
+
);
|
|
3488
|
+
|
|
3489
|
+
// Status + env sync first (never starts Cloudflare here).
|
|
3490
|
+
let status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3491
|
+
writeEnv: true,
|
|
3492
|
+
timeoutMs: 2500,
|
|
3493
|
+
});
|
|
3494
|
+
log(
|
|
3495
|
+
`start apps ${label}: chat=${status.probe.chatUp ? "up" : "down"}@${
|
|
3496
|
+
status.probe.chatPort
|
|
3497
|
+
} hosts=${
|
|
3498
|
+
status.probe.hosts
|
|
3499
|
+
.map((h) => `${h.role}:${h.up ? "up" : "down"}`)
|
|
3500
|
+
.join(",") || "none"
|
|
3501
|
+
} cloudflare=${status.usingCloudflare ? "yes" : "no"}`
|
|
3502
|
+
);
|
|
3503
|
+
|
|
3504
|
+
// Cloudflare tunnels only when Maintainer Pro left a pending Share signal.
|
|
1728
3505
|
if (ws.cloudflarePending) {
|
|
1729
|
-
|
|
3506
|
+
log(`start apps ${label}: Cloudflare pending, launching tunnels`);
|
|
3507
|
+
return launchCloudflareTunnels(ws, cfg, opts);
|
|
1730
3508
|
}
|
|
3509
|
+
|
|
3510
|
+
await reportActionProgress(
|
|
3511
|
+
cfg,
|
|
3512
|
+
opts.actionId,
|
|
3513
|
+
`Starting local apps for ${label}…`
|
|
3514
|
+
);
|
|
1731
3515
|
ws.appsRequested = true;
|
|
1732
|
-
persistWorkspaceEntry(cfg, ws);
|
|
1733
3516
|
const reserved = reservedPortsFor(cfg, ws.sandboxId);
|
|
3517
|
+
const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
|
|
3518
|
+
|
|
3519
|
+
// Re-sync env after port assignment (local or cloudflare urls).
|
|
3520
|
+
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3521
|
+
writeEnv: true,
|
|
3522
|
+
timeoutMs: 800,
|
|
3523
|
+
});
|
|
3524
|
+
|
|
3525
|
+
const cfEnv =
|
|
3526
|
+
status.usingCloudflare && status.cloudflare
|
|
3527
|
+
? uiPublicEnv(status.cloudflare)
|
|
3528
|
+
: {};
|
|
3529
|
+
|
|
1734
3530
|
if (!cfg.noAiServer) {
|
|
1735
|
-
|
|
1736
|
-
|
|
3531
|
+
if (status.probe.chatUp) {
|
|
3532
|
+
log(`chat already running on ${status.probe.chatPort} — not restarting`);
|
|
3533
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
3534
|
+
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
3535
|
+
ws.port = status.probe.chatPort;
|
|
3536
|
+
} else {
|
|
3537
|
+
await startAiServerForWorkspace(ws, {
|
|
3538
|
+
reserved,
|
|
3539
|
+
cfg,
|
|
3540
|
+
port: plan.aiPort,
|
|
3541
|
+
env: cfEnv,
|
|
3542
|
+
});
|
|
3543
|
+
await sleep(1500);
|
|
3544
|
+
}
|
|
1737
3545
|
}
|
|
3546
|
+
|
|
3547
|
+
// ensureHostProcesses skips roles that are already answering HTTP.
|
|
1738
3548
|
const startedHosts = await ensureHostProcesses(ws, {
|
|
1739
3549
|
reserved,
|
|
1740
3550
|
cfg,
|
|
1741
3551
|
force: true,
|
|
3552
|
+
plannedJobs: plan.jobs.map((job) => {
|
|
3553
|
+
const probed = status.probe.hosts.find((h) => h.role === job.role);
|
|
3554
|
+
return probed?.up ? { ...job, up: true, port: probed.port } : job;
|
|
3555
|
+
}),
|
|
3556
|
+
extraEnv: cfEnv,
|
|
1742
3557
|
});
|
|
1743
3558
|
await sleep(800);
|
|
3559
|
+
|
|
3560
|
+
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3561
|
+
writeEnv: true,
|
|
3562
|
+
timeoutMs: 2500,
|
|
3563
|
+
});
|
|
1744
3564
|
await inspectHostJobs(ws);
|
|
1745
|
-
|
|
1746
|
-
if (up) {
|
|
1747
|
-
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1748
|
-
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
1749
|
-
}
|
|
3565
|
+
|
|
1750
3566
|
const processIssues = issuesForSandbox(ws.sandboxId).map(
|
|
1751
3567
|
({ role: _role, ...issue }) => issue
|
|
1752
3568
|
);
|
|
1753
3569
|
const warning = processIssues[0]?.message || null;
|
|
3570
|
+
log(
|
|
3571
|
+
`start apps done ${label}: chat=${ws.port} chatUp=${
|
|
3572
|
+
status.aiServerUp
|
|
3573
|
+
} hosts=${startedHosts.join(",") || "none"} cloudflare=${
|
|
3574
|
+
status.usingCloudflare ? "yes" : "no"
|
|
3575
|
+
}${status.host.appUrl ? ` app=${status.host.appUrl}` : ""} origins=${
|
|
3576
|
+
status.host.origins.join(",") || "none"
|
|
3577
|
+
}${warning ? ` warning=${warning}` : ""}`
|
|
3578
|
+
);
|
|
1754
3579
|
return {
|
|
1755
|
-
up,
|
|
3580
|
+
up: status.aiServerUp,
|
|
1756
3581
|
startedHosts,
|
|
1757
3582
|
sandboxId: ws.sandboxId,
|
|
1758
3583
|
folderPath: ws.folderPath,
|
|
1759
3584
|
port: ws.port,
|
|
1760
|
-
appUrl: ws.appUrl,
|
|
3585
|
+
appUrl: status.host.appUrl || ws.appUrl,
|
|
3586
|
+
origins: status.host.origins,
|
|
3587
|
+
cloudflare: status.usingCloudflare,
|
|
1761
3588
|
processIssues,
|
|
1762
3589
|
warning,
|
|
1763
3590
|
};
|
|
@@ -1768,11 +3595,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1768
3595
|
const cfg = opts.cfg || null;
|
|
1769
3596
|
const folder = path.resolve(ws.folderPath);
|
|
1770
3597
|
const scripts = readPackageJson(folder)?.scripts || {};
|
|
1771
|
-
const jobs =
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
3598
|
+
const jobs = Array.isArray(opts.plannedJobs)
|
|
3599
|
+
? opts.plannedJobs
|
|
3600
|
+
: planHostJobs(
|
|
3601
|
+
folder,
|
|
3602
|
+
ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
|
|
3603
|
+
ws.projectInfo
|
|
3604
|
+
);
|
|
1776
3605
|
const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
|
|
1777
3606
|
const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
|
|
1778
3607
|
const started = [];
|
|
@@ -1791,14 +3620,26 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1791
3620
|
return started;
|
|
1792
3621
|
}
|
|
1793
3622
|
|
|
3623
|
+
log(
|
|
3624
|
+
`start hosts ${label}: ${
|
|
3625
|
+
jobs.length
|
|
3626
|
+
? jobs
|
|
3627
|
+
.filter((job) => !onlyRoles || onlyRoles.has(job.role))
|
|
3628
|
+
.map((job) => `${job.role}:${job.script}:${job.port || job.preferredPort}`)
|
|
3629
|
+
.join(" ")
|
|
3630
|
+
: "none"
|
|
3631
|
+
}`
|
|
3632
|
+
);
|
|
3633
|
+
|
|
1794
3634
|
for (const job of jobs) {
|
|
1795
3635
|
if (onlyRoles && !onlyRoles.has(job.role)) continue;
|
|
1796
|
-
const preferred = Number(job.preferredPort) || 3000;
|
|
1797
|
-
const probe = (
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
3636
|
+
const preferred = Number(job.port || job.preferredPort) || 3000;
|
|
3637
|
+
const probe = (
|
|
3638
|
+
job.port
|
|
3639
|
+
? `http://127.0.0.1:${job.port}`
|
|
3640
|
+
: job.probeUrl || `http://127.0.0.1:${preferred}`
|
|
3641
|
+
).replace("localhost", "127.0.0.1");
|
|
3642
|
+
if (job.up || (await probeUrl(probe))) {
|
|
1802
3643
|
reserved.add(portFromText(probe, preferred));
|
|
1803
3644
|
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1804
3645
|
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
@@ -1808,12 +3649,17 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1808
3649
|
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1809
3650
|
if (!opts.force && recentlyLaunched(launchKey)) {
|
|
1810
3651
|
reserved.add(preferred);
|
|
3652
|
+
log(`start ${job.role} skip ${label}: launched recently`);
|
|
1811
3653
|
continue;
|
|
1812
3654
|
}
|
|
1813
3655
|
|
|
1814
|
-
let port = preferred;
|
|
3656
|
+
let port = Number(job.port) || preferred;
|
|
1815
3657
|
try {
|
|
1816
|
-
|
|
3658
|
+
if (!job.port) {
|
|
3659
|
+
port = await findFreePort(preferred, reserved);
|
|
3660
|
+
} else {
|
|
3661
|
+
reserved.add(port);
|
|
3662
|
+
}
|
|
1817
3663
|
} catch (err) {
|
|
1818
3664
|
recordProcessProblem({
|
|
1819
3665
|
sandboxId: ws.sandboxId,
|
|
@@ -1823,7 +3669,7 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1823
3669
|
message: `No free port found for "${job.script}" (tried from ${preferred}). ${
|
|
1824
3670
|
err instanceof Error ? err.message : String(err)
|
|
1825
3671
|
}`,
|
|
1826
|
-
resolution: "Close other local servers, then use Start
|
|
3672
|
+
resolution: "Close other local servers, then use Start Apps.",
|
|
1827
3673
|
});
|
|
1828
3674
|
continue;
|
|
1829
3675
|
}
|
|
@@ -1845,7 +3691,10 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1845
3691
|
sandboxId: ws.sandboxId,
|
|
1846
3692
|
force: Boolean(opts.force),
|
|
1847
3693
|
});
|
|
1848
|
-
if (opened.skipped)
|
|
3694
|
+
if (opened.skipped) {
|
|
3695
|
+
log(`start ${job.role} skip ${label}: terminal already opening`);
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
1849
3698
|
if (!opened.ok) {
|
|
1850
3699
|
recordProcessProblem({
|
|
1851
3700
|
sandboxId: ws.sandboxId,
|
|
@@ -1859,6 +3708,7 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1859
3708
|
continue;
|
|
1860
3709
|
}
|
|
1861
3710
|
started.push(job.role);
|
|
3711
|
+
log(`start ${job.role} launched ${label} on ${port}: ${command}`);
|
|
1862
3712
|
if (
|
|
1863
3713
|
(job.role === "ui" || job.role === "app") &&
|
|
1864
3714
|
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
@@ -1918,7 +3768,7 @@ async function inspectHostJobs(ws) {
|
|
|
1918
3768
|
? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
|
|
1919
3769
|
: `Nothing is running at ${probe} for "${job.script}".`,
|
|
1920
3770
|
resolution:
|
|
1921
|
-
"Fix the error in that terminal, then use Start
|
|
3771
|
+
"Fix the error in that terminal, then use Start Apps to try again.",
|
|
1922
3772
|
});
|
|
1923
3773
|
}
|
|
1924
3774
|
return hosts;
|
|
@@ -1929,6 +3779,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
1929
3779
|
const sandboxId = String(
|
|
1930
3780
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
1931
3781
|
);
|
|
3782
|
+
log(`setup begin sandbox=${shortId(sandboxId)} folder=${folderPath || "(none)"}`);
|
|
1932
3783
|
const requestedPort = Number(action.payload?.port) || 3100;
|
|
1933
3784
|
const reserved = new Set();
|
|
1934
3785
|
for (const other of cfg.workspaces || []) {
|
|
@@ -1939,10 +3790,13 @@ async function setupWorkspace(cfg, action) {
|
|
|
1939
3790
|
let port = requestedPort;
|
|
1940
3791
|
if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
|
|
1941
3792
|
reserved.add(requestedPort);
|
|
3793
|
+
log(`setup chat port ${requestedPort} already up`);
|
|
1942
3794
|
} else {
|
|
1943
3795
|
port = await findFreePort(requestedPort, reserved);
|
|
1944
3796
|
if (port !== requestedPort) {
|
|
1945
|
-
log(`port ${requestedPort} busy; using ${port}
|
|
3797
|
+
log(`setup chat port ${requestedPort} busy; using ${port}`);
|
|
3798
|
+
} else {
|
|
3799
|
+
log(`setup chat port ${port} free`);
|
|
1946
3800
|
}
|
|
1947
3801
|
}
|
|
1948
3802
|
const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
|
|
@@ -1983,18 +3837,29 @@ async function setupWorkspace(cfg, action) {
|
|
|
1983
3837
|
const appUrl = client.appUrl || corsOrigin;
|
|
1984
3838
|
|
|
1985
3839
|
const envPath = path.join(resolved, ".env");
|
|
3840
|
+
const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
|
|
3841
|
+
? config.aiIgnorePaths
|
|
3842
|
+
: [];
|
|
1986
3843
|
const envValues = {
|
|
1987
3844
|
...config.env,
|
|
1988
3845
|
AI_CLI_WORKSPACE: ".",
|
|
3846
|
+
AI_CLI_IGNORE_PATHS: JSON.stringify(
|
|
3847
|
+
normalizeIgnorePaths(partnerIgnorePaths)
|
|
3848
|
+
),
|
|
1989
3849
|
AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
|
|
1990
|
-
|
|
3850
|
+
AI_SERVER_PORT: String(port),
|
|
1991
3851
|
AI_SERVER_URL: aiOrigin,
|
|
1992
3852
|
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
1993
3853
|
CORS_ORIGIN: corsOrigin,
|
|
1994
3854
|
APP_URL: appUrl,
|
|
1995
3855
|
AI_SERVER_PRODUCT_DESCRIPTION: appName,
|
|
1996
3856
|
};
|
|
1997
|
-
mergeEnvFile(envPath, envValues);
|
|
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);
|
|
1998
3863
|
|
|
1999
3864
|
cfg.workspaces = cfg.workspaces || [];
|
|
2000
3865
|
const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
|
|
@@ -2028,6 +3893,12 @@ async function setupWorkspace(cfg, action) {
|
|
|
2028
3893
|
});
|
|
2029
3894
|
|
|
2030
3895
|
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));
|
|
2031
3902
|
|
|
2032
3903
|
const openUrl = client.sameOrigin
|
|
2033
3904
|
? `http://localhost:${entry.port}`
|
|
@@ -2043,22 +3914,25 @@ async function setupWorkspace(cfg, action) {
|
|
|
2043
3914
|
);
|
|
2044
3915
|
const waitingForStart = !aiServerUp;
|
|
2045
3916
|
const warning = waitingForStart
|
|
2046
|
-
? "Folder is attached in Maintainer Pro. Use Start
|
|
3917
|
+
? "Folder is attached in Maintainer Pro. Use Start Apps when you want to launch the apps."
|
|
2047
3918
|
: processIssues[0]?.message || null;
|
|
2048
3919
|
|
|
2049
|
-
for (const note of client.notes) log(note);
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
3920
|
+
for (const note of client.notes) log(`setup note ${note}`);
|
|
3921
|
+
log(
|
|
3922
|
+
waitingForStart
|
|
3923
|
+
? `setup done — waiting for Start (${openUrl}) kind=${client.kind}`
|
|
3924
|
+
: `setup done — chat already up (${openUrl}) kind=${client.kind}`
|
|
3925
|
+
);
|
|
2055
3926
|
|
|
3927
|
+
const host = workspaceHostReport(entry);
|
|
2056
3928
|
return {
|
|
2057
3929
|
sandboxId,
|
|
2058
3930
|
folderPath: resolved,
|
|
2059
3931
|
port: entry.port,
|
|
2060
|
-
appUrl: entry.appUrl || appUrl,
|
|
2061
|
-
origins:
|
|
3932
|
+
appUrl: host.appUrl || entry.appUrl || appUrl,
|
|
3933
|
+
origins: host.origins.length
|
|
3934
|
+
? host.origins
|
|
3935
|
+
: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
|
|
2062
3936
|
wroteEnv: true,
|
|
2063
3937
|
clientKind: client.kind,
|
|
2064
3938
|
clientFiles: client.filesWritten,
|
|
@@ -2070,26 +3944,64 @@ async function setupWorkspace(cfg, action) {
|
|
|
2070
3944
|
warning,
|
|
2071
3945
|
waitingForStart,
|
|
2072
3946
|
projectInfo,
|
|
3947
|
+
ignorePaths: access.ignorePaths,
|
|
2073
3948
|
};
|
|
2074
3949
|
}
|
|
2075
3950
|
|
|
2076
3951
|
async function runActions(cfg, actions) {
|
|
3952
|
+
if (!actions.length) return;
|
|
3953
|
+
log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
|
|
2077
3954
|
for (const action of actions) {
|
|
2078
|
-
|
|
3955
|
+
const startedAt = Date.now();
|
|
3956
|
+
const label = actionLabel(action);
|
|
3957
|
+
log(`${label} start`);
|
|
2079
3958
|
let ok = true;
|
|
2080
3959
|
/** @type {Record<string, unknown>} */
|
|
2081
3960
|
let result = {};
|
|
2082
3961
|
try {
|
|
2083
3962
|
if (action.code === "browse") {
|
|
2084
3963
|
const p = String(action.payload?.path || process.cwd());
|
|
3964
|
+
log(`${label} browse ${p}`);
|
|
2085
3965
|
result = listDirEntries(p);
|
|
3966
|
+
log(`${label} browse ${result.entries?.length ?? 0} entries`);
|
|
2086
3967
|
} else if (action.code === "setup_workspace") {
|
|
2087
3968
|
result = await setupWorkspace(cfg, action);
|
|
3969
|
+
} else if (action.code === "sync_access_policy") {
|
|
3970
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
3971
|
+
const ws =
|
|
3972
|
+
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
3973
|
+
(action.payload?.folderPath
|
|
3974
|
+
? {
|
|
3975
|
+
sandboxId,
|
|
3976
|
+
folderPath: String(action.payload.folderPath),
|
|
3977
|
+
}
|
|
3978
|
+
: null);
|
|
3979
|
+
if (!ws?.folderPath) {
|
|
3980
|
+
ok = false;
|
|
3981
|
+
result = { error: "No workspace folder for access policy sync" };
|
|
3982
|
+
warn(`${label} skipped: ${result.error}`);
|
|
3983
|
+
} else {
|
|
3984
|
+
const partnerIgnorePaths = Array.isArray(action.payload?.aiIgnorePaths)
|
|
3985
|
+
? action.payload.aiIgnorePaths
|
|
3986
|
+
: [];
|
|
3987
|
+
const access = applyAccessPolicy(ws.folderPath, partnerIgnorePaths);
|
|
3988
|
+
log(
|
|
3989
|
+
`access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
|
|
3990
|
+
);
|
|
3991
|
+
result = {
|
|
3992
|
+
folderPath: path.resolve(ws.folderPath),
|
|
3993
|
+
ignorePaths: access.ignorePaths,
|
|
3994
|
+
syncedAt: new Date().toISOString(),
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
2088
3997
|
} else if (action.code === "recheck") {
|
|
2089
3998
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2090
3999
|
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4000
|
+
if (!ws) {
|
|
4001
|
+
log(`${label} recheck: no local workspace`);
|
|
4002
|
+
}
|
|
2091
4003
|
const projectInfo = ws
|
|
2092
|
-
? await inspectProjectWithAiCli(ws, { cfg })
|
|
4004
|
+
? await inspectProjectWithAiCli(ws, { cfg, force: true })
|
|
2093
4005
|
: null;
|
|
2094
4006
|
result = {
|
|
2095
4007
|
recheckedAt: new Date().toISOString(),
|
|
@@ -2109,18 +4021,57 @@ async function runActions(cfg, actions) {
|
|
|
2109
4021
|
if (!ws || cfg.noAiServer) {
|
|
2110
4022
|
ok = false;
|
|
2111
4023
|
result = { error: "No workspace or --no-ai-server" };
|
|
4024
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2112
4025
|
} else {
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
problem ||
|
|
2120
|
-
"Local processes are not running or the project setup looks incomplete.",
|
|
4026
|
+
// Prefer cached project inspect; only call ai-cli when missing or
|
|
4027
|
+
// after a failed start that used the cache.
|
|
4028
|
+
log(`${label} start ${ws.folderPath}`);
|
|
4029
|
+
let projectInfo = await inspectProjectWithAiCli(ws, { cfg });
|
|
4030
|
+
let started = await startAppsForWorkspace(ws, cfg, {
|
|
4031
|
+
actionId: action.id,
|
|
2121
4032
|
});
|
|
4033
|
+
const launchFailed =
|
|
4034
|
+
Array.isArray(started.processIssues) &&
|
|
4035
|
+
started.processIssues.some(
|
|
4036
|
+
(issue) =>
|
|
4037
|
+
issue.code === "ai_server_launch" ||
|
|
4038
|
+
issue.code === "host_process_launch" ||
|
|
4039
|
+
issue.code === "host_process_down" ||
|
|
4040
|
+
issue.code === "project_issue"
|
|
4041
|
+
);
|
|
4042
|
+
const nothingUp =
|
|
4043
|
+
!started.up &&
|
|
4044
|
+
!(
|
|
4045
|
+
Array.isArray(started.startedHosts) &&
|
|
4046
|
+
started.startedHosts.length
|
|
4047
|
+
);
|
|
4048
|
+
if ((launchFailed || nothingUp) && projectInfo?.cached) {
|
|
4049
|
+
const problem = [
|
|
4050
|
+
...issuesForSandbox(ws.sandboxId).map((issue) => issue.message),
|
|
4051
|
+
started.warning,
|
|
4052
|
+
"Local processes did not come up using the cached project setup.",
|
|
4053
|
+
]
|
|
4054
|
+
.filter(Boolean)
|
|
4055
|
+
.join("\n");
|
|
4056
|
+
log(
|
|
4057
|
+
`${label} start incomplete — re-inspecting project with ai-cli`
|
|
4058
|
+
);
|
|
4059
|
+
await reportActionProgress(
|
|
4060
|
+
cfg,
|
|
4061
|
+
action.id,
|
|
4062
|
+
"Start did not fully succeed — re-analyzing the project…"
|
|
4063
|
+
);
|
|
4064
|
+
projectInfo = await inspectProjectWithAiCli(ws, {
|
|
4065
|
+
cfg,
|
|
4066
|
+
force: true,
|
|
4067
|
+
problem,
|
|
4068
|
+
});
|
|
4069
|
+
started = await startAppsForWorkspace(ws, cfg, {
|
|
4070
|
+
actionId: action.id,
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
2122
4073
|
result = {
|
|
2123
|
-
...
|
|
4074
|
+
...started,
|
|
2124
4075
|
projectInfo,
|
|
2125
4076
|
};
|
|
2126
4077
|
if (
|
|
@@ -2134,14 +4085,23 @@ async function runActions(cfg, actions) {
|
|
|
2134
4085
|
} else if (action.code === "refresh_public_url") {
|
|
2135
4086
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2136
4087
|
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4088
|
+
const host = ws ? workspaceHostReport(ws) : { appUrl: null, origins: [] };
|
|
2137
4089
|
result = {
|
|
4090
|
+
sandboxId: sandboxId || ws?.sandboxId || null,
|
|
2138
4091
|
appUrl:
|
|
4092
|
+
host.appUrl ||
|
|
2139
4093
|
ws?.cloudflareUrl ||
|
|
2140
4094
|
ws?.appUrl ||
|
|
2141
4095
|
process.env.APP_URL ||
|
|
2142
4096
|
process.env.PUBLIC_URL ||
|
|
2143
4097
|
null,
|
|
4098
|
+
origins: host.origins,
|
|
2144
4099
|
};
|
|
4100
|
+
log(
|
|
4101
|
+
`${label} public url ${result.appUrl || "(none)"} origins=${
|
|
4102
|
+
host.origins.join(",") || "none"
|
|
4103
|
+
}`
|
|
4104
|
+
);
|
|
2145
4105
|
} else if (action.code === "configure_cloudflare") {
|
|
2146
4106
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2147
4107
|
const ws =
|
|
@@ -2161,8 +4121,11 @@ async function runActions(cfg, actions) {
|
|
|
2161
4121
|
if (!ws) {
|
|
2162
4122
|
ok = false;
|
|
2163
4123
|
result = { error: "No folder is attached for this sandbox" };
|
|
4124
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2164
4125
|
} else {
|
|
2165
|
-
result = await configureCloudflareForWorkspace(ws, cfg
|
|
4126
|
+
result = await configureCloudflareForWorkspace(ws, cfg, {
|
|
4127
|
+
actionId: action.id,
|
|
4128
|
+
});
|
|
2166
4129
|
}
|
|
2167
4130
|
} else if (action.code === "remove_workspace") {
|
|
2168
4131
|
const sandboxId = String(
|
|
@@ -2174,15 +4137,26 @@ async function runActions(cfg, actions) {
|
|
|
2174
4137
|
);
|
|
2175
4138
|
saveConfig(cfg);
|
|
2176
4139
|
result = { removedSandboxId: sandboxId };
|
|
4140
|
+
log(`${label} removed workspace`);
|
|
2177
4141
|
} else {
|
|
2178
4142
|
ok = false;
|
|
2179
4143
|
result = { error: `Unknown action ${action.code}` };
|
|
4144
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2180
4145
|
}
|
|
2181
4146
|
} catch (err) {
|
|
2182
4147
|
ok = false;
|
|
2183
4148
|
result = { error: err instanceof Error ? err.message : String(err) };
|
|
4149
|
+
warn(`${label} threw: ${result.error}`);
|
|
2184
4150
|
}
|
|
2185
4151
|
|
|
4152
|
+
const elapsed = Date.now() - startedAt;
|
|
4153
|
+
const summary = resultSummary(result);
|
|
4154
|
+
log(
|
|
4155
|
+
`${label} ${ok ? "ok" : "failed"} ${elapsed}ms${
|
|
4156
|
+
summary ? ` ${summary}` : ""
|
|
4157
|
+
}`
|
|
4158
|
+
);
|
|
4159
|
+
|
|
2186
4160
|
try {
|
|
2187
4161
|
await api(
|
|
2188
4162
|
cfg.adminUrl,
|
|
@@ -2191,9 +4165,10 @@ async function runActions(cfg, actions) {
|
|
|
2191
4165
|
`/api/v1/bridge/machine/actions/${action.id}/complete`,
|
|
2192
4166
|
{ ok, result }
|
|
2193
4167
|
);
|
|
4168
|
+
log(`${label} reported to admin`);
|
|
2194
4169
|
} catch (err) {
|
|
2195
4170
|
warn(
|
|
2196
|
-
|
|
4171
|
+
`${label} report failed: ${
|
|
2197
4172
|
err instanceof Error ? err.message : String(err)
|
|
2198
4173
|
}`
|
|
2199
4174
|
);
|
|
@@ -2202,50 +4177,155 @@ async function runActions(cfg, actions) {
|
|
|
2202
4177
|
}
|
|
2203
4178
|
|
|
2204
4179
|
async function collectWorkspaceStates(cfg) {
|
|
2205
|
-
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
|
|
4180
|
+
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null,origins?:string[]}>} */
|
|
2206
4181
|
const localStates = [];
|
|
2207
4182
|
for (const ws of cfg.workspaces || []) {
|
|
2208
4183
|
const folder = path.resolve(ws.folderPath || "");
|
|
2209
|
-
const
|
|
2210
|
-
|
|
2211
|
-
|
|
4184
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
4185
|
+
writeEnv: true,
|
|
4186
|
+
timeoutMs: 800,
|
|
4187
|
+
});
|
|
2212
4188
|
localStates.push({
|
|
2213
4189
|
sandboxId: ws.sandboxId,
|
|
2214
4190
|
sandboxName: ws.sandboxName,
|
|
2215
|
-
port: ws.port,
|
|
4191
|
+
port: status.probe.chatPort || ws.port,
|
|
2216
4192
|
folderPath: ws.folderPath,
|
|
2217
|
-
aiServerUp:
|
|
4193
|
+
aiServerUp: status.aiServerUp,
|
|
4194
|
+
appsRunning: status.appsRunning,
|
|
2218
4195
|
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2219
|
-
appUrl:
|
|
2220
|
-
|
|
4196
|
+
appUrl:
|
|
4197
|
+
status.appsRunning || status.usingCloudflare
|
|
4198
|
+
? status.host.appUrl || ws.appUrl || null
|
|
4199
|
+
: null,
|
|
4200
|
+
origins:
|
|
4201
|
+
status.appsRunning || status.usingCloudflare
|
|
4202
|
+
? status.host.origins
|
|
4203
|
+
: [],
|
|
4204
|
+
appsRequested: appsWanted(ws) || status.appsRunning,
|
|
2221
4205
|
});
|
|
2222
4206
|
}
|
|
2223
4207
|
return localStates;
|
|
2224
4208
|
}
|
|
2225
4209
|
|
|
4210
|
+
/** @type {Map<string, string>} */
|
|
4211
|
+
const lastHostReports = new Map();
|
|
4212
|
+
|
|
4213
|
+
function syncAssignedWorkspaces(cfg, remotes) {
|
|
4214
|
+
if (!Array.isArray(remotes)) return;
|
|
4215
|
+
for (const remote of remotes) {
|
|
4216
|
+
const local = (cfg.workspaces || []).find(
|
|
4217
|
+
(w) => w.sandboxId === remote.sandboxId
|
|
4218
|
+
);
|
|
4219
|
+
if (!local) {
|
|
4220
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
4221
|
+
cfg.workspaces.push({
|
|
4222
|
+
sandboxId: remote.sandboxId,
|
|
4223
|
+
folderPath: remote.folderPath,
|
|
4224
|
+
port: remote.port,
|
|
4225
|
+
sandboxName: remote.sandboxName,
|
|
4226
|
+
applicationName: remote.applicationName,
|
|
4227
|
+
});
|
|
4228
|
+
saveConfig(cfg);
|
|
4229
|
+
} else if (local.folderPath !== remote.folderPath) {
|
|
4230
|
+
local.folderPath = remote.folderPath;
|
|
4231
|
+
local.port = remote.port;
|
|
4232
|
+
saveConfig(cfg);
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
|
|
2226
4237
|
async function sendHeartbeat(cfg, folders, localStates) {
|
|
4238
|
+
for (const st of localStates) {
|
|
4239
|
+
if (!st.appsRunning) continue;
|
|
4240
|
+
const key = `${st.appUrl || ""}|${(st.origins || []).join(",")}`;
|
|
4241
|
+
if (lastHostReports.get(st.sandboxId) === key) continue;
|
|
4242
|
+
lastHostReports.set(st.sandboxId, key);
|
|
4243
|
+
log(
|
|
4244
|
+
`host report sandbox=${shortId(st.sandboxId)} app=${
|
|
4245
|
+
st.appUrl || "(none)"
|
|
4246
|
+
} origins=${(st.origins || []).join(",") || "none"}`
|
|
4247
|
+
);
|
|
4248
|
+
}
|
|
4249
|
+
const body = {
|
|
4250
|
+
hostname: os.hostname(),
|
|
4251
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
4252
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
4253
|
+
folders,
|
|
4254
|
+
issues: await buildIssues(cfg, localStates),
|
|
4255
|
+
workspaces: localStates.map((st) => ({
|
|
4256
|
+
sandboxId: st.sandboxId,
|
|
4257
|
+
aiServerUp: st.aiServerUp,
|
|
4258
|
+
appsRunning: Boolean(st.appsRunning),
|
|
4259
|
+
port: st.port,
|
|
4260
|
+
appUrl: st.appsRunning && st.appUrl ? st.appUrl : undefined,
|
|
4261
|
+
origins:
|
|
4262
|
+
st.appsRunning && Array.isArray(st.origins) && st.origins.length
|
|
4263
|
+
? st.origins
|
|
4264
|
+
: undefined,
|
|
4265
|
+
appsRequested: Boolean(st.appsRequested),
|
|
4266
|
+
})),
|
|
4267
|
+
};
|
|
2227
4268
|
return api(
|
|
2228
4269
|
cfg.adminUrl,
|
|
2229
4270
|
cfg.token,
|
|
2230
4271
|
"POST",
|
|
2231
4272
|
"/api/v1/bridge/machine/heartbeat",
|
|
2232
|
-
|
|
2233
|
-
hostname: os.hostname(),
|
|
2234
|
-
platform: `${os.platform()}-${os.arch()}`,
|
|
2235
|
-
bridgeVersion: PACKAGE_VERSION,
|
|
2236
|
-
folders,
|
|
2237
|
-
issues: await buildIssues(cfg, localStates),
|
|
2238
|
-
workspaces: localStates.map((st) => ({
|
|
2239
|
-
sandboxId: st.sandboxId,
|
|
2240
|
-
aiServerUp: st.aiServerUp,
|
|
2241
|
-
port: st.port,
|
|
2242
|
-
appUrl: st.appUrl || undefined,
|
|
2243
|
-
appsRequested: Boolean(st.appsRequested),
|
|
2244
|
-
})),
|
|
2245
|
-
}
|
|
4273
|
+
body
|
|
2246
4274
|
);
|
|
2247
4275
|
}
|
|
2248
4276
|
|
|
4277
|
+
function buildLightHeartbeatPayload(cfg, folders) {
|
|
4278
|
+
return {
|
|
4279
|
+
type: "heartbeat",
|
|
4280
|
+
hostname: os.hostname(),
|
|
4281
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
4282
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
4283
|
+
folders,
|
|
4284
|
+
};
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
async function buildHeartbeatPayload(cfg, folders, localStates) {
|
|
4288
|
+
for (const st of localStates) {
|
|
4289
|
+
if (!st.appsRunning) continue;
|
|
4290
|
+
const key = `${st.appUrl || ""}|${(st.origins || []).join(",")}`;
|
|
4291
|
+
if (lastHostReports.get(st.sandboxId) === key) continue;
|
|
4292
|
+
lastHostReports.set(st.sandboxId, key);
|
|
4293
|
+
log(
|
|
4294
|
+
`host report sandbox=${shortId(st.sandboxId)} app=${
|
|
4295
|
+
st.appUrl || "(none)"
|
|
4296
|
+
} origins=${(st.origins || []).join(",") || "none"}`
|
|
4297
|
+
);
|
|
4298
|
+
}
|
|
4299
|
+
return {
|
|
4300
|
+
type: "heartbeat",
|
|
4301
|
+
hostname: os.hostname(),
|
|
4302
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
4303
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
4304
|
+
folders,
|
|
4305
|
+
issues: await buildIssues(cfg, localStates),
|
|
4306
|
+
workspaces: localStates.map((st) => ({
|
|
4307
|
+
sandboxId: st.sandboxId,
|
|
4308
|
+
aiServerUp: st.aiServerUp,
|
|
4309
|
+
appsRunning: Boolean(st.appsRunning),
|
|
4310
|
+
port: st.port,
|
|
4311
|
+
appUrl: st.appsRunning && st.appUrl ? st.appUrl : undefined,
|
|
4312
|
+
origins:
|
|
4313
|
+
st.appsRunning && Array.isArray(st.origins) && st.origins.length
|
|
4314
|
+
? st.origins
|
|
4315
|
+
: undefined,
|
|
4316
|
+
appsRequested: Boolean(st.appsRequested),
|
|
4317
|
+
})),
|
|
4318
|
+
};
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
function adminWsUrl(adminUrl, token) {
|
|
4322
|
+
const base = String(adminUrl || "").replace(/\/$/, "");
|
|
4323
|
+
const u = new URL(`${base}/api/v1/ws`);
|
|
4324
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
4325
|
+
u.searchParams.set("token", token);
|
|
4326
|
+
return u.toString();
|
|
4327
|
+
}
|
|
4328
|
+
|
|
2249
4329
|
async function buildIssues(cfg, workspaceStates) {
|
|
2250
4330
|
/** @type {Array<Record<string, unknown>>} */
|
|
2251
4331
|
const issues = [];
|
|
@@ -2263,15 +4343,15 @@ async function buildIssues(cfg, workspaceStates) {
|
|
|
2263
4343
|
});
|
|
2264
4344
|
}
|
|
2265
4345
|
for (const st of workspaceStates) {
|
|
2266
|
-
if (st.aiServerUp || st.startingAi) continue;
|
|
4346
|
+
if (st.aiServerUp || st.appsRunning || st.startingAi) continue;
|
|
2267
4347
|
if (!st.appsRequested) {
|
|
2268
4348
|
issues.push({
|
|
2269
4349
|
code: "apps_not_started",
|
|
2270
4350
|
severity: "info",
|
|
2271
4351
|
title: `Apps are not running (${st.sandboxName || "sandbox"})`,
|
|
2272
4352
|
message:
|
|
2273
|
-
"This folder is attached in Maintainer Pro. Use Start
|
|
2274
|
-
resolution: "Use Start
|
|
4353
|
+
"This folder is attached in Maintainer Pro. Use Start Apps when you want to launch the local apps.",
|
|
4354
|
+
resolution: "Use Start Apps.",
|
|
2275
4355
|
actionCode: "start_ai_server",
|
|
2276
4356
|
sandboxId: st.sandboxId,
|
|
2277
4357
|
});
|
|
@@ -2292,7 +4372,7 @@ async function buildIssues(cfg, workspaceStates) {
|
|
|
2292
4372
|
`Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
|
|
2293
4373
|
resolution:
|
|
2294
4374
|
launch?.resolution ||
|
|
2295
|
-
"Read the error in that terminal, then use Start
|
|
4375
|
+
"Read the error in that terminal, then use Start Apps.",
|
|
2296
4376
|
actionCode: "start_ai_server",
|
|
2297
4377
|
sandboxId: st.sandboxId,
|
|
2298
4378
|
});
|
|
@@ -2369,12 +4449,22 @@ async function pairFlow(args) {
|
|
|
2369
4449
|
}
|
|
2370
4450
|
|
|
2371
4451
|
async function main() {
|
|
4452
|
+
if (!process.env.NODE_ENV?.trim()) {
|
|
4453
|
+
process.env.NODE_ENV = "development";
|
|
4454
|
+
}
|
|
4455
|
+
logger = createLogger("ai-bridge");
|
|
4456
|
+
|
|
2372
4457
|
const args = parseArgs(process.argv.slice(2));
|
|
2373
4458
|
if (args.help) {
|
|
2374
4459
|
printHelp();
|
|
2375
4460
|
process.exit(0);
|
|
2376
4461
|
}
|
|
2377
4462
|
|
|
4463
|
+
logger.debug(
|
|
4464
|
+
{ logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
|
|
4465
|
+
"bridge starting"
|
|
4466
|
+
);
|
|
4467
|
+
|
|
2378
4468
|
let cfg = loadConfig() || {};
|
|
2379
4469
|
ensureMachineId(cfg);
|
|
2380
4470
|
|
|
@@ -2398,113 +4488,307 @@ async function main() {
|
|
|
2398
4488
|
log(`machine ${cfg.machineId}`);
|
|
2399
4489
|
log(`admin ${cfg.adminUrl}`);
|
|
2400
4490
|
log(`config ${configPath()}`);
|
|
4491
|
+
log(
|
|
4492
|
+
`online via websocket (presence every ${HEARTBEAT_MS / 1000}s, ping every ${WS_PING_MS / 1000}s)`
|
|
4493
|
+
);
|
|
4494
|
+
await restoreHostsAfterReconnect(cfg);
|
|
2401
4495
|
|
|
2402
|
-
|
|
2403
|
-
|
|
4496
|
+
/** @type {unknown[]} */
|
|
4497
|
+
const claimedActions = [];
|
|
4498
|
+
let workBusy = false;
|
|
4499
|
+
|
|
4500
|
+
const runWork = async () => {
|
|
4501
|
+
if (workBusy) return;
|
|
4502
|
+
workBusy = true;
|
|
2404
4503
|
try {
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
const localStates = [];
|
|
2409
|
-
|
|
2410
|
-
const reserved = new Set();
|
|
2411
|
-
for (const ws of cfg.workspaces || []) {
|
|
2412
|
-
const folder = path.resolve(ws.folderPath || "");
|
|
2413
|
-
const up = await probeUrl(
|
|
2414
|
-
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
2415
|
-
);
|
|
2416
|
-
if (appsWanted(ws) && !cfg.noAiServer && !up) {
|
|
2417
|
-
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
2418
|
-
} else if (ws.port) {
|
|
2419
|
-
reserved.add(Number(ws.port));
|
|
2420
|
-
if (up) {
|
|
2421
|
-
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2422
|
-
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
if (appsWanted(ws)) {
|
|
2426
|
-
await ensureHostProcesses(ws, { reserved, cfg });
|
|
2427
|
-
}
|
|
2428
|
-
await inspectHostJobs(ws);
|
|
2429
|
-
localStates.push({
|
|
2430
|
-
sandboxId: ws.sandboxId,
|
|
2431
|
-
sandboxName: ws.sandboxName,
|
|
2432
|
-
port: ws.port,
|
|
2433
|
-
folderPath: ws.folderPath,
|
|
2434
|
-
aiServerUp: up,
|
|
2435
|
-
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2436
|
-
appUrl: ws.appUrl || null,
|
|
2437
|
-
appsRequested: appsWanted(ws),
|
|
2438
|
-
});
|
|
4504
|
+
while (claimedActions.length) {
|
|
4505
|
+
const batch = claimedActions.splice(0, claimedActions.length);
|
|
4506
|
+
await runActions(cfg, batch);
|
|
2439
4507
|
}
|
|
4508
|
+
} catch (err) {
|
|
4509
|
+
warn(err instanceof Error ? err.message : String(err));
|
|
4510
|
+
} finally {
|
|
4511
|
+
workBusy = false;
|
|
4512
|
+
if (claimedActions.length) void runWork();
|
|
4513
|
+
}
|
|
4514
|
+
};
|
|
2440
4515
|
|
|
2441
|
-
|
|
4516
|
+
const queueActions = (actions) => {
|
|
4517
|
+
if (!Array.isArray(actions) || actions.length === 0) return;
|
|
4518
|
+
claimedActions.push(...actions);
|
|
4519
|
+
void runWork();
|
|
4520
|
+
};
|
|
2442
4521
|
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
local.port = remote.port;
|
|
2463
|
-
saveConfig(cfg);
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
4522
|
+
/** @type {import('ws').WebSocket | null} */
|
|
4523
|
+
let socket = null;
|
|
4524
|
+
let heartbeatTimer = null;
|
|
4525
|
+
let pingTimer = null;
|
|
4526
|
+
let reconnectTimer = null;
|
|
4527
|
+
let wsGeneration = 0;
|
|
4528
|
+
let reconnectAttempt = 0;
|
|
4529
|
+
let stopped = false;
|
|
4530
|
+
let presenceBusy = false;
|
|
4531
|
+
|
|
4532
|
+
const sendJson = (payload) => {
|
|
4533
|
+
if (!socket || socket.readyState !== 1) return false;
|
|
4534
|
+
try {
|
|
4535
|
+
socket.send(JSON.stringify(payload));
|
|
4536
|
+
return true;
|
|
4537
|
+
} catch {
|
|
4538
|
+
return false;
|
|
4539
|
+
}
|
|
4540
|
+
};
|
|
2467
4541
|
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
4542
|
+
const clearHeartbeatTimer = () => {
|
|
4543
|
+
if (heartbeatTimer) {
|
|
4544
|
+
clearInterval(heartbeatTimer);
|
|
4545
|
+
heartbeatTimer = null;
|
|
4546
|
+
}
|
|
4547
|
+
};
|
|
4548
|
+
|
|
4549
|
+
const clearPingTimer = () => {
|
|
4550
|
+
if (pingTimer) {
|
|
4551
|
+
clearInterval(pingTimer);
|
|
4552
|
+
pingTimer = null;
|
|
4553
|
+
}
|
|
4554
|
+
};
|
|
4555
|
+
|
|
4556
|
+
const clearReconnectTimer = () => {
|
|
4557
|
+
if (reconnectTimer) {
|
|
4558
|
+
clearTimeout(reconnectTimer);
|
|
4559
|
+
reconnectTimer = null;
|
|
4560
|
+
}
|
|
4561
|
+
};
|
|
4562
|
+
|
|
4563
|
+
const sendPing = () => {
|
|
4564
|
+
sendJson({ type: "ping" });
|
|
4565
|
+
sendLightPresence();
|
|
4566
|
+
};
|
|
4567
|
+
|
|
4568
|
+
const sendLightPresence = () => {
|
|
4569
|
+
const folders = collectOfferedFolders(cfg);
|
|
4570
|
+
sendJson(buildLightHeartbeatPayload(cfg, folders));
|
|
4571
|
+
};
|
|
4572
|
+
|
|
4573
|
+
const sendPresenceOverWs = async () => {
|
|
4574
|
+
if (!socket || socket.readyState !== 1 || presenceBusy) return;
|
|
4575
|
+
presenceBusy = true;
|
|
4576
|
+
try {
|
|
4577
|
+
const folders = collectOfferedFolders(cfg);
|
|
4578
|
+
const localStates = await collectWorkspaceStates(cfg);
|
|
4579
|
+
const payload = await buildHeartbeatPayload(cfg, folders, localStates);
|
|
4580
|
+
sendJson(payload);
|
|
2472
4581
|
} catch (err) {
|
|
2473
4582
|
warn(err instanceof Error ? err.message : String(err));
|
|
4583
|
+
} finally {
|
|
4584
|
+
presenceBusy = false;
|
|
2474
4585
|
}
|
|
2475
4586
|
};
|
|
2476
4587
|
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
4588
|
+
const handleChatRun = async (msg) => {
|
|
4589
|
+
const sandboxId = typeof msg.sandboxId === "string" ? msg.sandboxId : "";
|
|
4590
|
+
const conversationId =
|
|
4591
|
+
typeof msg.conversationId === "string" ? msg.conversationId : "";
|
|
4592
|
+
const userMessageId =
|
|
4593
|
+
typeof msg.userMessageId === "string" ? msg.userMessageId : "";
|
|
4594
|
+
const content = typeof msg.content === "string" ? msg.content : "";
|
|
4595
|
+
const messages = Array.isArray(msg.messages)
|
|
4596
|
+
? msg.messages.filter(
|
|
4597
|
+
(row) =>
|
|
4598
|
+
row &&
|
|
4599
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
4600
|
+
typeof row.content === "string"
|
|
4601
|
+
)
|
|
4602
|
+
: [];
|
|
4603
|
+
const payloadMessages = messages.length
|
|
4604
|
+
? messages
|
|
4605
|
+
: content
|
|
4606
|
+
? [{ role: "user", content }]
|
|
4607
|
+
: [];
|
|
4608
|
+
const fail = (error) => {
|
|
4609
|
+
warn(`chat.run: ${error}`);
|
|
4610
|
+
sendJson({
|
|
4611
|
+
type: "chat.run.result",
|
|
4612
|
+
ok: false,
|
|
4613
|
+
sandboxId,
|
|
4614
|
+
conversationId,
|
|
4615
|
+
userMessageId,
|
|
4616
|
+
error,
|
|
4617
|
+
});
|
|
4618
|
+
};
|
|
4619
|
+
if (!conversationId || payloadMessages.length === 0) {
|
|
4620
|
+
fail("missing conversation or messages");
|
|
4621
|
+
return;
|
|
4622
|
+
}
|
|
4623
|
+
const ws = (cfg.workspaces || []).find(
|
|
4624
|
+
(row) => row.sandboxId === sandboxId
|
|
4625
|
+
);
|
|
4626
|
+
if (!ws) {
|
|
4627
|
+
fail(`no local workspace for sandbox ${sandboxId || "?"}`);
|
|
4628
|
+
return;
|
|
4629
|
+
}
|
|
4630
|
+
const chat = await discoverChatPort(ws, 2500);
|
|
4631
|
+
if (chat.up) ws.port = chat.port;
|
|
4632
|
+
const port = Number(chat.port || ws.port) || 3100;
|
|
4633
|
+
const url = `http://127.0.0.1:${port}/api/chat`;
|
|
4634
|
+
log(`chat.run → ${url} (${conversationId})`);
|
|
2481
4635
|
try {
|
|
2482
|
-
await
|
|
2483
|
-
|
|
2484
|
-
|
|
4636
|
+
const res = await fetch(url, {
|
|
4637
|
+
method: "POST",
|
|
4638
|
+
headers: {
|
|
4639
|
+
"content-type": "application/json",
|
|
4640
|
+
accept: "application/json",
|
|
4641
|
+
},
|
|
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
|
+
}),
|
|
4652
|
+
});
|
|
4653
|
+
const text = await res.text();
|
|
4654
|
+
let data = null;
|
|
4655
|
+
try {
|
|
4656
|
+
data = text ? JSON.parse(text) : null;
|
|
4657
|
+
} catch {
|
|
4658
|
+
data = { raw: text };
|
|
4659
|
+
}
|
|
4660
|
+
if (!res.ok) {
|
|
4661
|
+
fail(data?.error || `AI server ${res.status}`);
|
|
4662
|
+
return;
|
|
4663
|
+
}
|
|
4664
|
+
log(`chat.run ok (${conversationId})`);
|
|
4665
|
+
sendJson({
|
|
4666
|
+
type: "chat.run.result",
|
|
4667
|
+
ok: true,
|
|
4668
|
+
sandboxId,
|
|
4669
|
+
conversationId,
|
|
4670
|
+
userMessageId,
|
|
4671
|
+
});
|
|
4672
|
+
} catch (err) {
|
|
4673
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
2485
4674
|
}
|
|
2486
4675
|
};
|
|
2487
4676
|
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
4677
|
+
const handleWsMessage = (raw) => {
|
|
4678
|
+
let msg;
|
|
4679
|
+
try {
|
|
4680
|
+
msg = JSON.parse(String(raw));
|
|
4681
|
+
} catch {
|
|
4682
|
+
return;
|
|
4683
|
+
}
|
|
4684
|
+
if (!msg || typeof msg !== "object") return;
|
|
4685
|
+
if (msg.type === "pong" || msg.type === "hello") return;
|
|
4686
|
+
if (msg.type === "actions") {
|
|
4687
|
+
queueActions(msg.actions);
|
|
4688
|
+
return;
|
|
4689
|
+
}
|
|
4690
|
+
if (msg.type === "heartbeat.ok") {
|
|
4691
|
+
syncAssignedWorkspaces(cfg, msg.workspaces);
|
|
4692
|
+
queueActions(msg.actions);
|
|
4693
|
+
return;
|
|
4694
|
+
}
|
|
4695
|
+
if (msg.type === "chat.run") {
|
|
4696
|
+
void handleChatRun(msg);
|
|
4697
|
+
return;
|
|
4698
|
+
}
|
|
4699
|
+
if (msg.type === "error") {
|
|
4700
|
+
warn(`ws: ${msg.error || "error"}`);
|
|
4701
|
+
}
|
|
4702
|
+
};
|
|
4703
|
+
|
|
4704
|
+
const scheduleReconnect = (code, reason) => {
|
|
4705
|
+
if (stopped || reconnectTimer) return;
|
|
4706
|
+
const delay = Math.min(
|
|
4707
|
+
WS_RECONNECT_MAX_MS,
|
|
4708
|
+
WS_RECONNECT_MIN_MS * 2 ** Math.min(reconnectAttempt, 4)
|
|
4709
|
+
);
|
|
4710
|
+
reconnectAttempt += 1;
|
|
4711
|
+
const detail = reason ? ` ${reason}` : "";
|
|
4712
|
+
const wait =
|
|
4713
|
+
delay < 1000 ? `${delay}ms` : `${Math.round(delay / 1000)}s`;
|
|
4714
|
+
warn(
|
|
4715
|
+
`websocket closed (${code || "?"}${detail}); reconnecting in ${wait}`
|
|
4716
|
+
);
|
|
4717
|
+
reconnectTimer = setTimeout(() => {
|
|
4718
|
+
reconnectTimer = null;
|
|
4719
|
+
connectWs();
|
|
4720
|
+
}, delay);
|
|
4721
|
+
};
|
|
4722
|
+
|
|
4723
|
+
const connectWs = () => {
|
|
4724
|
+
if (stopped) return;
|
|
4725
|
+
clearReconnectTimer();
|
|
4726
|
+
const generation = ++wsGeneration;
|
|
4727
|
+
if (socket) {
|
|
4728
|
+
try {
|
|
4729
|
+
socket.close();
|
|
4730
|
+
} catch {
|
|
4731
|
+
/* ignore */
|
|
2502
4732
|
}
|
|
4733
|
+
socket = null;
|
|
4734
|
+
}
|
|
4735
|
+
const url = adminWsUrl(cfg.adminUrl, cfg.token);
|
|
4736
|
+
log(
|
|
4737
|
+
reconnectAttempt
|
|
4738
|
+
? `websocket connecting (attempt ${reconnectAttempt + 1})…`
|
|
4739
|
+
: "websocket connecting…"
|
|
4740
|
+
);
|
|
4741
|
+
/** @type {WebSocket} */
|
|
4742
|
+
const ws = new WebSocket(url);
|
|
4743
|
+
socket = ws;
|
|
4744
|
+
|
|
4745
|
+
ws.addEventListener("open", () => {
|
|
4746
|
+
if (generation !== wsGeneration) return;
|
|
4747
|
+
reconnectAttempt = 0;
|
|
4748
|
+
log("websocket connected");
|
|
4749
|
+
clearHeartbeatTimer();
|
|
4750
|
+
clearPingTimer();
|
|
4751
|
+
sendPing();
|
|
4752
|
+
sendLightPresence();
|
|
4753
|
+
void sendPresenceOverWs();
|
|
4754
|
+
pingTimer = setInterval(sendPing, WS_PING_MS);
|
|
4755
|
+
heartbeatTimer = setInterval(() => {
|
|
4756
|
+
void sendPresenceOverWs();
|
|
4757
|
+
}, HEARTBEAT_MS);
|
|
4758
|
+
});
|
|
4759
|
+
|
|
4760
|
+
ws.addEventListener("message", (event) => {
|
|
4761
|
+
if (generation !== wsGeneration) return;
|
|
4762
|
+
handleWsMessage(event.data);
|
|
4763
|
+
});
|
|
4764
|
+
|
|
4765
|
+
ws.addEventListener("close", (event) => {
|
|
4766
|
+
if (generation !== wsGeneration) return;
|
|
4767
|
+
clearHeartbeatTimer();
|
|
4768
|
+
clearPingTimer();
|
|
4769
|
+
if (socket === ws) socket = null;
|
|
4770
|
+
scheduleReconnect(event.code, event.reason);
|
|
2503
4771
|
});
|
|
2504
|
-
|
|
4772
|
+
|
|
4773
|
+
ws.addEventListener("error", () => {
|
|
4774
|
+
// close handler drives reconnect
|
|
4775
|
+
});
|
|
4776
|
+
};
|
|
4777
|
+
|
|
4778
|
+
connectWs();
|
|
2505
4779
|
|
|
2506
4780
|
const shutdown = () => {
|
|
4781
|
+
stopped = true;
|
|
4782
|
+
wsGeneration += 1;
|
|
4783
|
+
clearHeartbeatTimer();
|
|
4784
|
+
clearPingTimer();
|
|
4785
|
+
clearReconnectTimer();
|
|
2507
4786
|
stopAllCloudflare();
|
|
4787
|
+
try {
|
|
4788
|
+
socket?.close();
|
|
4789
|
+
} catch {
|
|
4790
|
+
/* ignore */
|
|
4791
|
+
}
|
|
2508
4792
|
log("shutting down (other terminals stay open)");
|
|
2509
4793
|
process.exit(0);
|
|
2510
4794
|
};
|