@maintainer-pro/ai-bridge 0.1.6 → 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 +2667 -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();
|
|
@@ -1093,13 +2524,21 @@ function writeWinLaunchScript(folder, title, command, env) {
|
|
|
1093
2524
|
const safe = String(title || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
1094
2525
|
const file = path.join(dir, `launch-${safe}.cmd`);
|
|
1095
2526
|
const folderArg = String(folder).replace(/"/g, "");
|
|
2527
|
+
const safeTitle = String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ");
|
|
1096
2528
|
const lines = [
|
|
1097
2529
|
"@echo off",
|
|
2530
|
+
`title ${safeTitle}`,
|
|
1098
2531
|
`cd /d "${folderArg}"`,
|
|
2532
|
+
"if errorlevel 1 (",
|
|
2533
|
+
" echo Could not open the project folder.",
|
|
2534
|
+
" pause",
|
|
2535
|
+
" exit /b 1",
|
|
2536
|
+
")",
|
|
1099
2537
|
...Object.entries(env).map(
|
|
1100
2538
|
([key, value]) => `set "${key}=${String(value).replace(/"/g, "")}"`
|
|
1101
2539
|
),
|
|
1102
|
-
|
|
2540
|
+
"echo %CD%",
|
|
2541
|
+
`echo ${command}`,
|
|
1103
2542
|
command,
|
|
1104
2543
|
"if errorlevel 1 pause",
|
|
1105
2544
|
];
|
|
@@ -1107,6 +2546,33 @@ function writeWinLaunchScript(folder, title, command, env) {
|
|
|
1107
2546
|
return file;
|
|
1108
2547
|
}
|
|
1109
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
|
+
|
|
1110
2576
|
function runLauncher(command, args, extra = {}) {
|
|
1111
2577
|
return new Promise((resolve) => {
|
|
1112
2578
|
let settled = false;
|
|
@@ -1156,6 +2622,7 @@ async function openInNewTerminal(opts) {
|
|
|
1156
2622
|
const { title, folder, command, env = {}, launchKey, sandboxId } = opts;
|
|
1157
2623
|
if (launchKey) {
|
|
1158
2624
|
if (!opts.force && recentlyLaunched(launchKey)) {
|
|
2625
|
+
log(`terminal skip [${title}]: launched recently`);
|
|
1159
2626
|
return { ok: true, skipped: true };
|
|
1160
2627
|
}
|
|
1161
2628
|
launchedAt.set(launchKey, Date.now());
|
|
@@ -1185,20 +2652,13 @@ async function openInNewTerminal(opts) {
|
|
|
1185
2652
|
String(title || "Maintainer Pro").replace(/["&<>|^]/g, " ").trim() ||
|
|
1186
2653
|
"Maintainer Pro";
|
|
1187
2654
|
const script = writeWinLaunchScript(folder, safeTitle, command, env);
|
|
1188
|
-
|
|
1189
|
-
const opened = await
|
|
1190
|
-
process.env.ComSpec || "cmd.exe",
|
|
1191
|
-
[
|
|
1192
|
-
"/d",
|
|
1193
|
-
"/s",
|
|
1194
|
-
"/c",
|
|
1195
|
-
`start "${safeTitle}" /D "${folder}" cmd.exe /k ".maintainer-pro\\${scriptName}"`,
|
|
1196
|
-
],
|
|
1197
|
-
{ windowsVerbatimArguments: true }
|
|
1198
|
-
);
|
|
2655
|
+
log(`terminal script [${title}] ${script}`);
|
|
2656
|
+
const opened = await openWindowsConsole(script, folder);
|
|
1199
2657
|
if (!opened.ok) {
|
|
2658
|
+
warn(`terminal failed [${title}]: ${opened.error || "unknown"}`);
|
|
1200
2659
|
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1201
2660
|
}
|
|
2661
|
+
log(`terminal opened [${title}]`);
|
|
1202
2662
|
return { ok: true };
|
|
1203
2663
|
}
|
|
1204
2664
|
|
|
@@ -1271,23 +2731,22 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1271
2731
|
if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
|
|
1272
2732
|
reserved.add(preferred);
|
|
1273
2733
|
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2734
|
+
log(`start chat skip ${label}: already up on ${preferred}`);
|
|
1274
2735
|
return { port: preferred, up: true, launched: false };
|
|
1275
2736
|
}
|
|
1276
2737
|
|
|
1277
2738
|
if (recentlyLaunched(launchKey)) {
|
|
1278
2739
|
reserved.add(preferred);
|
|
2740
|
+
log(`start chat skip ${label}: already launching on ${preferred}`);
|
|
1279
2741
|
return { port: preferred, up: false, launched: false, starting: true };
|
|
1280
2742
|
}
|
|
1281
2743
|
|
|
1282
|
-
let port = preferred;
|
|
2744
|
+
let port = Number(opts.port) || preferred;
|
|
1283
2745
|
try {
|
|
1284
|
-
if (
|
|
1285
|
-
if (!(await isPortFree(preferred))) {
|
|
1286
|
-
reserved.delete(preferred);
|
|
1287
|
-
port = await findFreePort(preferred, reserved);
|
|
1288
|
-
}
|
|
1289
|
-
} else {
|
|
2746
|
+
if (!opts.port) {
|
|
1290
2747
|
port = await findFreePort(preferred, reserved);
|
|
2748
|
+
} else {
|
|
2749
|
+
reserved.add(port);
|
|
1291
2750
|
}
|
|
1292
2751
|
} catch (err) {
|
|
1293
2752
|
recordProcessProblem({
|
|
@@ -1298,23 +2757,32 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1298
2757
|
message: `No free port found (tried from ${preferred}). ${
|
|
1299
2758
|
err instanceof Error ? err.message : String(err)
|
|
1300
2759
|
}`,
|
|
1301
|
-
resolution: "Close other local servers, then use Start
|
|
2760
|
+
resolution: "Close other local servers, then use Start Apps.",
|
|
1302
2761
|
});
|
|
1303
2762
|
return { port: preferred, up: false, launched: false };
|
|
1304
2763
|
}
|
|
1305
2764
|
if (port !== preferred) {
|
|
1306
|
-
log(`port ${preferred} busy; using ${port} for
|
|
1307
|
-
ws.port = port;
|
|
1308
|
-
const envPath = path.join(folder, ".env");
|
|
1309
|
-
if (fs.existsSync(envPath)) {
|
|
1310
|
-
mergeEnvFile(envPath, {
|
|
1311
|
-
PORT: String(port),
|
|
1312
|
-
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1313
|
-
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
persistWorkspaceEntry(cfg, ws);
|
|
2765
|
+
log(`port ${preferred} busy; using ${port} for chat script`);
|
|
1317
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);
|
|
1318
2786
|
|
|
1319
2787
|
const localCli = path.resolve(
|
|
1320
2788
|
__dirname,
|
|
@@ -1336,9 +2804,11 @@ async function startAiServerForWorkspace(ws, opts = {}) {
|
|
|
1336
2804
|
folder,
|
|
1337
2805
|
command: run,
|
|
1338
2806
|
env: {
|
|
1339
|
-
|
|
1340
|
-
AI_SERVER_URL:
|
|
1341
|
-
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,
|
|
1342
2812
|
},
|
|
1343
2813
|
launchKey,
|
|
1344
2814
|
sandboxId: ws.sandboxId,
|
|
@@ -1365,18 +2835,12 @@ function sleep(ms) {
|
|
|
1365
2835
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1366
2836
|
}
|
|
1367
2837
|
|
|
1368
|
-
/** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
|
|
1369
|
-
const cloudflareTunnels = new Map();
|
|
1370
|
-
|
|
1371
2838
|
function stopAllCloudflare() {
|
|
1372
2839
|
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
1373
2840
|
}
|
|
1374
2841
|
|
|
1375
2842
|
function parseTryCloudflareUrl(text) {
|
|
1376
|
-
|
|
1377
|
-
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
1378
|
-
);
|
|
1379
|
-
return match ? match[0].replace(/\/$/, "") : null;
|
|
2843
|
+
return lastTryCloudflareUrl(text);
|
|
1380
2844
|
}
|
|
1381
2845
|
|
|
1382
2846
|
function killProcessesByCommand(fragment) {
|
|
@@ -1477,22 +2941,38 @@ async function stopWorkspaceApps(ws) {
|
|
|
1477
2941
|
await sleep(400);
|
|
1478
2942
|
}
|
|
1479
2943
|
|
|
1480
|
-
async function waitUntilReachable(url, timeoutMs, label) {
|
|
2944
|
+
async function waitUntilReachable(url, timeoutMs, label, onWait) {
|
|
1481
2945
|
const start = Date.now();
|
|
2946
|
+
let last = 0;
|
|
1482
2947
|
while (Date.now() - start < timeoutMs) {
|
|
1483
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
|
+
}
|
|
1484
2956
|
await sleep(600);
|
|
1485
2957
|
}
|
|
1486
2958
|
throw new Error(`${label} did not become reachable at ${url}`);
|
|
1487
2959
|
}
|
|
1488
2960
|
|
|
1489
|
-
async function waitForUrlInFile(file, timeoutMs = 90_000) {
|
|
2961
|
+
async function waitForUrlInFile(file, timeoutMs = 90_000, onWait) {
|
|
1490
2962
|
const start = Date.now();
|
|
2963
|
+
let last = 0;
|
|
1491
2964
|
while (Date.now() - start < timeoutMs) {
|
|
1492
2965
|
if (fs.existsSync(file)) {
|
|
1493
2966
|
const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
|
|
1494
2967
|
if (url) return url;
|
|
1495
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
|
+
}
|
|
1496
2976
|
await sleep(500);
|
|
1497
2977
|
}
|
|
1498
2978
|
throw new Error(
|
|
@@ -1507,7 +2987,7 @@ function cloudflaredCommand(localUrl, logFile) {
|
|
|
1507
2987
|
return `${run} 2>&1 | tee ${logArg}`;
|
|
1508
2988
|
}
|
|
1509
2989
|
|
|
1510
|
-
async function startCloudflareTerminal(ws, role, localUrl) {
|
|
2990
|
+
async function startCloudflareTerminal(ws, role, localUrl, onWait) {
|
|
1511
2991
|
const folder = path.resolve(ws.folderPath);
|
|
1512
2992
|
const logDir = path.join(folder, ".maintainer-pro");
|
|
1513
2993
|
fs.mkdirSync(logDir, { recursive: true });
|
|
@@ -1531,7 +3011,7 @@ async function startCloudflareTerminal(ws, role, localUrl) {
|
|
|
1531
3011
|
if (!opened.ok) {
|
|
1532
3012
|
throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
|
|
1533
3013
|
}
|
|
1534
|
-
const publicUrl = await waitForUrlInFile(logFile);
|
|
3014
|
+
const publicUrl = await waitForUrlInFile(logFile, 90_000, onWait);
|
|
1535
3015
|
return { role, localUrl, publicUrl, logFile };
|
|
1536
3016
|
}
|
|
1537
3017
|
|
|
@@ -1539,53 +3019,180 @@ function uiPublicEnv(tunnels) {
|
|
|
1539
3019
|
/** @type {Record<string, string>} */
|
|
1540
3020
|
const env = {};
|
|
1541
3021
|
if (tunnels.ai) {
|
|
1542
|
-
|
|
1543
|
-
env.
|
|
1544
|
-
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;
|
|
1545
3027
|
}
|
|
1546
3028
|
if (tunnels.backend) {
|
|
1547
|
-
|
|
1548
|
-
env.
|
|
1549
|
-
env.
|
|
1550
|
-
env.
|
|
1551
|
-
env.
|
|
1552
|
-
env.
|
|
1553
|
-
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;
|
|
1554
3037
|
}
|
|
1555
3038
|
if (tunnels.ui) {
|
|
1556
|
-
|
|
1557
|
-
env.
|
|
1558
|
-
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;
|
|
1559
3046
|
} else if (tunnels.ai) {
|
|
1560
|
-
|
|
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;
|
|
1561
3053
|
}
|
|
1562
3054
|
return env;
|
|
1563
3055
|
}
|
|
1564
3056
|
|
|
1565
3057
|
function writeTunnelEnv(ws, tunnels) {
|
|
1566
3058
|
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
1567
|
-
if (!folder || !fs.existsSync(folder)) return;
|
|
3059
|
+
if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
|
|
1568
3060
|
const env = uiPublicEnv(tunnels);
|
|
1569
3061
|
const lines = Object.entries(tunnels)
|
|
1570
3062
|
.filter(([, url]) => url)
|
|
1571
|
-
.map(([role, url]) => `${role}=${url}`);
|
|
3063
|
+
.map(([role, url]) => `${role}=${String(url).replace(/\/$/, "")}`);
|
|
1572
3064
|
fs.writeFileSync(
|
|
1573
3065
|
path.join(folder, ".cloudflare-tunnel-url"),
|
|
1574
3066
|
`${lines.join("\n")}\n`,
|
|
1575
3067
|
"utf8"
|
|
1576
3068
|
);
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
+
);
|
|
1582
3160
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
+
);
|
|
1585
3183
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
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}`);
|
|
1588
3194
|
}
|
|
3195
|
+
return { ok, checks };
|
|
1589
3196
|
}
|
|
1590
3197
|
|
|
1591
3198
|
function reservedPortsFor(cfg, sandboxId) {
|
|
@@ -1602,17 +3209,39 @@ function appsWanted(ws) {
|
|
|
1602
3209
|
return Boolean(ws?.appsRequested);
|
|
1603
3210
|
}
|
|
1604
3211
|
|
|
1605
|
-
async function configureCloudflareForWorkspace(ws, cfg) {
|
|
3212
|
+
async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
|
|
1606
3213
|
const sandboxId = ws.sandboxId;
|
|
1607
3214
|
const label = ws.sandboxName || "this sandbox";
|
|
3215
|
+
const progress = (message) =>
|
|
3216
|
+
reportActionProgress(cfg, opts.actionId, message);
|
|
1608
3217
|
|
|
1609
|
-
|
|
3218
|
+
// If tunnels are already up, attach instead of tearing them down.
|
|
3219
|
+
const attached = await tryAttachExistingCloudflare(ws, cfg, {
|
|
3220
|
+
onProgress: progress,
|
|
3221
|
+
});
|
|
3222
|
+
if (attached) {
|
|
3223
|
+
return {
|
|
3224
|
+
...attached,
|
|
3225
|
+
pending: false,
|
|
3226
|
+
cloudflarePending: false,
|
|
3227
|
+
waitingForStart: false,
|
|
3228
|
+
warning: null,
|
|
3229
|
+
};
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
await progress(
|
|
3233
|
+
`Preparing Cloudflare for ${label}. Stopping local apps first — this can take a minute.`
|
|
3234
|
+
);
|
|
1610
3235
|
await stopCloudflare(sandboxId);
|
|
3236
|
+
await progress("Stopping local app terminals and freeing their ports…");
|
|
1611
3237
|
await stopWorkspaceApps(ws);
|
|
1612
3238
|
await forgetLaunch(sandboxId);
|
|
1613
3239
|
ws.cloudflarePending = true;
|
|
1614
3240
|
ws.appsRequested = false;
|
|
1615
3241
|
persistWorkspaceEntry(cfg, ws);
|
|
3242
|
+
await progress(
|
|
3243
|
+
"Cloudflare is queued. Use Start Apps to create the public URLs."
|
|
3244
|
+
);
|
|
1616
3245
|
|
|
1617
3246
|
return {
|
|
1618
3247
|
sandboxId,
|
|
@@ -1622,43 +3251,61 @@ async function configureCloudflareForWorkspace(ws, cfg) {
|
|
|
1622
3251
|
cloudflarePending: true,
|
|
1623
3252
|
waitingForStart: true,
|
|
1624
3253
|
warning:
|
|
1625
|
-
"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.",
|
|
1626
3255
|
};
|
|
1627
3256
|
}
|
|
1628
3257
|
|
|
1629
|
-
async function launchCloudflareTunnels(ws, cfg) {
|
|
3258
|
+
async function launchCloudflareTunnels(ws, cfg, opts = {}) {
|
|
1630
3259
|
const sandboxId = ws.sandboxId;
|
|
1631
3260
|
const label = ws.sandboxName || "this sandbox";
|
|
1632
3261
|
const folder = path.resolve(ws.folderPath || "");
|
|
1633
3262
|
const reserved = reservedPortsFor(cfg, sandboxId);
|
|
3263
|
+
const progress = (message) =>
|
|
3264
|
+
reportActionProgress(cfg, opts.actionId, message);
|
|
3265
|
+
|
|
3266
|
+
const attached = await tryAttachExistingCloudflare(ws, cfg, {
|
|
3267
|
+
onProgress: progress,
|
|
3268
|
+
});
|
|
3269
|
+
if (attached) return attached;
|
|
1634
3270
|
|
|
1635
|
-
|
|
3271
|
+
await progress(
|
|
3272
|
+
`Creating Cloudflare tunnels for ${label}. This usually takes 1–2 minutes.`
|
|
3273
|
+
);
|
|
1636
3274
|
try {
|
|
3275
|
+
const plan = await prepareWorkspaceLaunch(ws, cfg, reserved);
|
|
1637
3276
|
if (!cfg.noAiServer) {
|
|
1638
|
-
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
|
+
});
|
|
1639
3283
|
await waitUntilReachable(
|
|
1640
3284
|
`http://127.0.0.1:${ws.port}/embed-config.js`,
|
|
1641
3285
|
45_000,
|
|
1642
|
-
"
|
|
3286
|
+
"the chat script",
|
|
3287
|
+
progress
|
|
1643
3288
|
);
|
|
1644
3289
|
}
|
|
1645
3290
|
|
|
1646
|
-
const
|
|
1647
|
-
const
|
|
1648
|
-
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");
|
|
1649
3293
|
|
|
1650
3294
|
if (backendJob) {
|
|
3295
|
+
await progress(`Starting the backend on port ${backendJob.port}…`);
|
|
1651
3296
|
await ensureHostProcesses(ws, {
|
|
1652
3297
|
reserved,
|
|
1653
3298
|
cfg,
|
|
1654
3299
|
onlyRoles: ["backend"],
|
|
1655
3300
|
force: true,
|
|
3301
|
+
plannedJobs: plan.jobs,
|
|
1656
3302
|
});
|
|
1657
|
-
const backendPort = Number(backendJob.
|
|
3303
|
+
const backendPort = Number(backendJob.port) || 4100;
|
|
1658
3304
|
await waitUntilReachable(
|
|
1659
3305
|
`http://127.0.0.1:${backendPort}`,
|
|
1660
3306
|
45_000,
|
|
1661
|
-
"
|
|
3307
|
+
"the backend",
|
|
3308
|
+
progress
|
|
1662
3309
|
);
|
|
1663
3310
|
}
|
|
1664
3311
|
|
|
@@ -1668,45 +3315,125 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1668
3315
|
const started = [];
|
|
1669
3316
|
|
|
1670
3317
|
const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
|
|
1671
|
-
|
|
3318
|
+
await progress(`Opening a Cloudflare tunnel for the chat script (${aiLocal})…`);
|
|
3319
|
+
const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal, progress);
|
|
1672
3320
|
tunnels.ai = aiTunnel.publicUrl;
|
|
1673
3321
|
started.push(aiTunnel);
|
|
1674
|
-
|
|
3322
|
+
await progress(`Chat script public URL: ${aiTunnel.publicUrl}`);
|
|
1675
3323
|
|
|
1676
3324
|
if (backendJob) {
|
|
1677
|
-
const backendLocal = `http://127.0.0.1:${Number(backendJob.
|
|
1678
|
-
|
|
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
|
+
);
|
|
1679
3333
|
tunnels.backend = backendTunnel.publicUrl;
|
|
1680
3334
|
started.push(backendTunnel);
|
|
1681
|
-
|
|
3335
|
+
await progress(`Backend public URL: ${backendTunnel.publicUrl}`);
|
|
1682
3336
|
}
|
|
1683
3337
|
|
|
1684
3338
|
writeTunnelEnv(ws, tunnels);
|
|
1685
|
-
|
|
3339
|
+
let uiEnv = uiPublicEnv(tunnels);
|
|
3340
|
+
const uiPort = uiJob ? Number(uiJob.port) || 5173 : null;
|
|
1686
3341
|
|
|
1687
3342
|
if (uiJob) {
|
|
3343
|
+
await progress(`Starting the app UI on port ${uiPort}…`);
|
|
1688
3344
|
await ensureHostProcesses(ws, {
|
|
1689
3345
|
reserved,
|
|
1690
3346
|
cfg,
|
|
1691
3347
|
onlyRoles: ["ui", "app"],
|
|
1692
3348
|
extraEnv: uiEnv,
|
|
1693
3349
|
force: true,
|
|
3350
|
+
plannedJobs: plan.jobs,
|
|
1694
3351
|
});
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
+
);
|
|
1697
3361
|
const uiTunnel = await startCloudflareTerminal(
|
|
1698
3362
|
ws,
|
|
1699
3363
|
"ui",
|
|
1700
|
-
`http://127.0.0.1:${uiPort}
|
|
3364
|
+
`http://127.0.0.1:${uiPort}`,
|
|
3365
|
+
progress
|
|
1701
3366
|
);
|
|
1702
3367
|
tunnels.ui = uiTunnel.publicUrl;
|
|
1703
3368
|
started.push(uiTunnel);
|
|
1704
|
-
|
|
3369
|
+
await progress(`App UI public URL: ${uiTunnel.publicUrl}`);
|
|
1705
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…");
|
|
1706
3403
|
launchedAt.delete(`${sandboxId}:${folder}:ai`);
|
|
1707
3404
|
await killPort(ws.port);
|
|
1708
3405
|
await sleep(1500);
|
|
1709
|
-
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
|
+
);
|
|
1710
3437
|
}
|
|
1711
3438
|
|
|
1712
3439
|
const appUrl = tunnels.ui || tunnels.ai;
|
|
@@ -1716,16 +3443,22 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1716
3443
|
ws.cloudflarePending = false;
|
|
1717
3444
|
ws.appsRequested = true;
|
|
1718
3445
|
persistWorkspaceEntry(cfg, ws);
|
|
3446
|
+
rememberCloudflareTunnels(sandboxId, tunnels);
|
|
1719
3447
|
cloudflareTunnels.set(sandboxId, { tunnels: started });
|
|
1720
3448
|
clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
|
|
3449
|
+
const host = workspaceHostReport(ws);
|
|
3450
|
+
await progress(`Cloudflare is ready: ${host.appUrl || appUrl}`);
|
|
1721
3451
|
|
|
1722
3452
|
return {
|
|
1723
3453
|
sandboxId,
|
|
1724
3454
|
folderPath: ws.folderPath,
|
|
1725
3455
|
port: ws.port,
|
|
1726
|
-
appUrl,
|
|
1727
|
-
origins:
|
|
3456
|
+
appUrl: host.appUrl || appUrl,
|
|
3457
|
+
origins: host.origins.length
|
|
3458
|
+
? host.origins
|
|
3459
|
+
: Object.values(tunnels).filter(Boolean),
|
|
1728
3460
|
tunnels,
|
|
3461
|
+
validation,
|
|
1729
3462
|
cloudflare: true,
|
|
1730
3463
|
reused: false,
|
|
1731
3464
|
};
|
|
@@ -1738,47 +3471,120 @@ async function launchCloudflareTunnels(ws, cfg) {
|
|
|
1738
3471
|
title: `Could not start Cloudflare (${label})`,
|
|
1739
3472
|
message,
|
|
1740
3473
|
resolution:
|
|
1741
|
-
"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.",
|
|
1742
3475
|
actionCode: "start_ai_server",
|
|
1743
3476
|
});
|
|
1744
3477
|
throw err;
|
|
1745
3478
|
}
|
|
1746
3479
|
}
|
|
1747
3480
|
|
|
1748
|
-
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.
|
|
1749
3505
|
if (ws.cloudflarePending) {
|
|
1750
|
-
|
|
3506
|
+
log(`start apps ${label}: Cloudflare pending, launching tunnels`);
|
|
3507
|
+
return launchCloudflareTunnels(ws, cfg, opts);
|
|
1751
3508
|
}
|
|
3509
|
+
|
|
3510
|
+
await reportActionProgress(
|
|
3511
|
+
cfg,
|
|
3512
|
+
opts.actionId,
|
|
3513
|
+
`Starting local apps for ${label}…`
|
|
3514
|
+
);
|
|
1752
3515
|
ws.appsRequested = true;
|
|
1753
|
-
persistWorkspaceEntry(cfg, ws);
|
|
1754
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
|
+
|
|
1755
3530
|
if (!cfg.noAiServer) {
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
+
}
|
|
1758
3545
|
}
|
|
3546
|
+
|
|
3547
|
+
// ensureHostProcesses skips roles that are already answering HTTP.
|
|
1759
3548
|
const startedHosts = await ensureHostProcesses(ws, {
|
|
1760
3549
|
reserved,
|
|
1761
3550
|
cfg,
|
|
1762
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,
|
|
1763
3557
|
});
|
|
1764
3558
|
await sleep(800);
|
|
3559
|
+
|
|
3560
|
+
status = await reconcileWorkspacePresence(ws, cfg, {
|
|
3561
|
+
writeEnv: true,
|
|
3562
|
+
timeoutMs: 2500,
|
|
3563
|
+
});
|
|
1765
3564
|
await inspectHostJobs(ws);
|
|
1766
|
-
|
|
1767
|
-
if (up) {
|
|
1768
|
-
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1769
|
-
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
1770
|
-
}
|
|
3565
|
+
|
|
1771
3566
|
const processIssues = issuesForSandbox(ws.sandboxId).map(
|
|
1772
3567
|
({ role: _role, ...issue }) => issue
|
|
1773
3568
|
);
|
|
1774
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
|
+
);
|
|
1775
3579
|
return {
|
|
1776
|
-
up,
|
|
3580
|
+
up: status.aiServerUp,
|
|
1777
3581
|
startedHosts,
|
|
1778
3582
|
sandboxId: ws.sandboxId,
|
|
1779
3583
|
folderPath: ws.folderPath,
|
|
1780
3584
|
port: ws.port,
|
|
1781
|
-
appUrl: ws.appUrl,
|
|
3585
|
+
appUrl: status.host.appUrl || ws.appUrl,
|
|
3586
|
+
origins: status.host.origins,
|
|
3587
|
+
cloudflare: status.usingCloudflare,
|
|
1782
3588
|
processIssues,
|
|
1783
3589
|
warning,
|
|
1784
3590
|
};
|
|
@@ -1789,11 +3595,13 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1789
3595
|
const cfg = opts.cfg || null;
|
|
1790
3596
|
const folder = path.resolve(ws.folderPath);
|
|
1791
3597
|
const scripts = readPackageJson(folder)?.scripts || {};
|
|
1792
|
-
const jobs =
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
+
);
|
|
1797
3605
|
const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
|
|
1798
3606
|
const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
|
|
1799
3607
|
const started = [];
|
|
@@ -1812,14 +3620,26 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1812
3620
|
return started;
|
|
1813
3621
|
}
|
|
1814
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
|
+
|
|
1815
3634
|
for (const job of jobs) {
|
|
1816
3635
|
if (onlyRoles && !onlyRoles.has(job.role)) continue;
|
|
1817
|
-
const preferred = Number(job.preferredPort) || 3000;
|
|
1818
|
-
const probe = (
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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))) {
|
|
1823
3643
|
reserved.add(portFromText(probe, preferred));
|
|
1824
3644
|
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1825
3645
|
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
@@ -1829,12 +3649,17 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1829
3649
|
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1830
3650
|
if (!opts.force && recentlyLaunched(launchKey)) {
|
|
1831
3651
|
reserved.add(preferred);
|
|
3652
|
+
log(`start ${job.role} skip ${label}: launched recently`);
|
|
1832
3653
|
continue;
|
|
1833
3654
|
}
|
|
1834
3655
|
|
|
1835
|
-
let port = preferred;
|
|
3656
|
+
let port = Number(job.port) || preferred;
|
|
1836
3657
|
try {
|
|
1837
|
-
|
|
3658
|
+
if (!job.port) {
|
|
3659
|
+
port = await findFreePort(preferred, reserved);
|
|
3660
|
+
} else {
|
|
3661
|
+
reserved.add(port);
|
|
3662
|
+
}
|
|
1838
3663
|
} catch (err) {
|
|
1839
3664
|
recordProcessProblem({
|
|
1840
3665
|
sandboxId: ws.sandboxId,
|
|
@@ -1844,7 +3669,7 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1844
3669
|
message: `No free port found for "${job.script}" (tried from ${preferred}). ${
|
|
1845
3670
|
err instanceof Error ? err.message : String(err)
|
|
1846
3671
|
}`,
|
|
1847
|
-
resolution: "Close other local servers, then use Start
|
|
3672
|
+
resolution: "Close other local servers, then use Start Apps.",
|
|
1848
3673
|
});
|
|
1849
3674
|
continue;
|
|
1850
3675
|
}
|
|
@@ -1866,7 +3691,10 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1866
3691
|
sandboxId: ws.sandboxId,
|
|
1867
3692
|
force: Boolean(opts.force),
|
|
1868
3693
|
});
|
|
1869
|
-
if (opened.skipped)
|
|
3694
|
+
if (opened.skipped) {
|
|
3695
|
+
log(`start ${job.role} skip ${label}: terminal already opening`);
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
1870
3698
|
if (!opened.ok) {
|
|
1871
3699
|
recordProcessProblem({
|
|
1872
3700
|
sandboxId: ws.sandboxId,
|
|
@@ -1880,6 +3708,7 @@ async function ensureHostProcesses(ws, opts = {}) {
|
|
|
1880
3708
|
continue;
|
|
1881
3709
|
}
|
|
1882
3710
|
started.push(job.role);
|
|
3711
|
+
log(`start ${job.role} launched ${label} on ${port}: ${command}`);
|
|
1883
3712
|
if (
|
|
1884
3713
|
(job.role === "ui" || job.role === "app") &&
|
|
1885
3714
|
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
@@ -1939,7 +3768,7 @@ async function inspectHostJobs(ws) {
|
|
|
1939
3768
|
? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
|
|
1940
3769
|
: `Nothing is running at ${probe} for "${job.script}".`,
|
|
1941
3770
|
resolution:
|
|
1942
|
-
"Fix the error in that terminal, then use Start
|
|
3771
|
+
"Fix the error in that terminal, then use Start Apps to try again.",
|
|
1943
3772
|
});
|
|
1944
3773
|
}
|
|
1945
3774
|
return hosts;
|
|
@@ -1950,6 +3779,7 @@ async function setupWorkspace(cfg, action) {
|
|
|
1950
3779
|
const sandboxId = String(
|
|
1951
3780
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
1952
3781
|
);
|
|
3782
|
+
log(`setup begin sandbox=${shortId(sandboxId)} folder=${folderPath || "(none)"}`);
|
|
1953
3783
|
const requestedPort = Number(action.payload?.port) || 3100;
|
|
1954
3784
|
const reserved = new Set();
|
|
1955
3785
|
for (const other of cfg.workspaces || []) {
|
|
@@ -1960,10 +3790,13 @@ async function setupWorkspace(cfg, action) {
|
|
|
1960
3790
|
let port = requestedPort;
|
|
1961
3791
|
if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
|
|
1962
3792
|
reserved.add(requestedPort);
|
|
3793
|
+
log(`setup chat port ${requestedPort} already up`);
|
|
1963
3794
|
} else {
|
|
1964
3795
|
port = await findFreePort(requestedPort, reserved);
|
|
1965
3796
|
if (port !== requestedPort) {
|
|
1966
|
-
log(`port ${requestedPort} busy; using ${port}
|
|
3797
|
+
log(`setup chat port ${requestedPort} busy; using ${port}`);
|
|
3798
|
+
} else {
|
|
3799
|
+
log(`setup chat port ${port} free`);
|
|
1967
3800
|
}
|
|
1968
3801
|
}
|
|
1969
3802
|
const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
|
|
@@ -2004,18 +3837,29 @@ async function setupWorkspace(cfg, action) {
|
|
|
2004
3837
|
const appUrl = client.appUrl || corsOrigin;
|
|
2005
3838
|
|
|
2006
3839
|
const envPath = path.join(resolved, ".env");
|
|
3840
|
+
const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
|
|
3841
|
+
? config.aiIgnorePaths
|
|
3842
|
+
: [];
|
|
2007
3843
|
const envValues = {
|
|
2008
3844
|
...config.env,
|
|
2009
3845
|
AI_CLI_WORKSPACE: ".",
|
|
3846
|
+
AI_CLI_IGNORE_PATHS: JSON.stringify(
|
|
3847
|
+
normalizeIgnorePaths(partnerIgnorePaths)
|
|
3848
|
+
),
|
|
2010
3849
|
AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
|
|
2011
|
-
|
|
3850
|
+
AI_SERVER_PORT: String(port),
|
|
2012
3851
|
AI_SERVER_URL: aiOrigin,
|
|
2013
3852
|
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
2014
3853
|
CORS_ORIGIN: corsOrigin,
|
|
2015
3854
|
APP_URL: appUrl,
|
|
2016
3855
|
AI_SERVER_PRODUCT_DESCRIPTION: appName,
|
|
2017
3856
|
};
|
|
2018
|
-
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);
|
|
2019
3863
|
|
|
2020
3864
|
cfg.workspaces = cfg.workspaces || [];
|
|
2021
3865
|
const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
|
|
@@ -2049,6 +3893,12 @@ async function setupWorkspace(cfg, action) {
|
|
|
2049
3893
|
});
|
|
2050
3894
|
|
|
2051
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));
|
|
2052
3902
|
|
|
2053
3903
|
const openUrl = client.sameOrigin
|
|
2054
3904
|
? `http://localhost:${entry.port}`
|
|
@@ -2064,22 +3914,25 @@ async function setupWorkspace(cfg, action) {
|
|
|
2064
3914
|
);
|
|
2065
3915
|
const waitingForStart = !aiServerUp;
|
|
2066
3916
|
const warning = waitingForStart
|
|
2067
|
-
? "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."
|
|
2068
3918
|
: processIssues[0]?.message || null;
|
|
2069
3919
|
|
|
2070
|
-
for (const note of client.notes) log(note);
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
+
);
|
|
2076
3926
|
|
|
3927
|
+
const host = workspaceHostReport(entry);
|
|
2077
3928
|
return {
|
|
2078
3929
|
sandboxId,
|
|
2079
3930
|
folderPath: resolved,
|
|
2080
3931
|
port: entry.port,
|
|
2081
|
-
appUrl: entry.appUrl || appUrl,
|
|
2082
|
-
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))],
|
|
2083
3936
|
wroteEnv: true,
|
|
2084
3937
|
clientKind: client.kind,
|
|
2085
3938
|
clientFiles: client.filesWritten,
|
|
@@ -2091,26 +3944,64 @@ async function setupWorkspace(cfg, action) {
|
|
|
2091
3944
|
warning,
|
|
2092
3945
|
waitingForStart,
|
|
2093
3946
|
projectInfo,
|
|
3947
|
+
ignorePaths: access.ignorePaths,
|
|
2094
3948
|
};
|
|
2095
3949
|
}
|
|
2096
3950
|
|
|
2097
3951
|
async function runActions(cfg, actions) {
|
|
3952
|
+
if (!actions.length) return;
|
|
3953
|
+
log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
|
|
2098
3954
|
for (const action of actions) {
|
|
2099
|
-
|
|
3955
|
+
const startedAt = Date.now();
|
|
3956
|
+
const label = actionLabel(action);
|
|
3957
|
+
log(`${label} start`);
|
|
2100
3958
|
let ok = true;
|
|
2101
3959
|
/** @type {Record<string, unknown>} */
|
|
2102
3960
|
let result = {};
|
|
2103
3961
|
try {
|
|
2104
3962
|
if (action.code === "browse") {
|
|
2105
3963
|
const p = String(action.payload?.path || process.cwd());
|
|
3964
|
+
log(`${label} browse ${p}`);
|
|
2106
3965
|
result = listDirEntries(p);
|
|
3966
|
+
log(`${label} browse ${result.entries?.length ?? 0} entries`);
|
|
2107
3967
|
} else if (action.code === "setup_workspace") {
|
|
2108
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
|
+
}
|
|
2109
3997
|
} else if (action.code === "recheck") {
|
|
2110
3998
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2111
3999
|
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4000
|
+
if (!ws) {
|
|
4001
|
+
log(`${label} recheck: no local workspace`);
|
|
4002
|
+
}
|
|
2112
4003
|
const projectInfo = ws
|
|
2113
|
-
? await inspectProjectWithAiCli(ws, { cfg })
|
|
4004
|
+
? await inspectProjectWithAiCli(ws, { cfg, force: true })
|
|
2114
4005
|
: null;
|
|
2115
4006
|
result = {
|
|
2116
4007
|
recheckedAt: new Date().toISOString(),
|
|
@@ -2130,18 +4021,57 @@ async function runActions(cfg, actions) {
|
|
|
2130
4021
|
if (!ws || cfg.noAiServer) {
|
|
2131
4022
|
ok = false;
|
|
2132
4023
|
result = { error: "No workspace or --no-ai-server" };
|
|
4024
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2133
4025
|
} else {
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
problem ||
|
|
2141
|
-
"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,
|
|
2142
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
|
+
}
|
|
2143
4073
|
result = {
|
|
2144
|
-
...
|
|
4074
|
+
...started,
|
|
2145
4075
|
projectInfo,
|
|
2146
4076
|
};
|
|
2147
4077
|
if (
|
|
@@ -2155,14 +4085,23 @@ async function runActions(cfg, actions) {
|
|
|
2155
4085
|
} else if (action.code === "refresh_public_url") {
|
|
2156
4086
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2157
4087
|
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
4088
|
+
const host = ws ? workspaceHostReport(ws) : { appUrl: null, origins: [] };
|
|
2158
4089
|
result = {
|
|
4090
|
+
sandboxId: sandboxId || ws?.sandboxId || null,
|
|
2159
4091
|
appUrl:
|
|
4092
|
+
host.appUrl ||
|
|
2160
4093
|
ws?.cloudflareUrl ||
|
|
2161
4094
|
ws?.appUrl ||
|
|
2162
4095
|
process.env.APP_URL ||
|
|
2163
4096
|
process.env.PUBLIC_URL ||
|
|
2164
4097
|
null,
|
|
4098
|
+
origins: host.origins,
|
|
2165
4099
|
};
|
|
4100
|
+
log(
|
|
4101
|
+
`${label} public url ${result.appUrl || "(none)"} origins=${
|
|
4102
|
+
host.origins.join(",") || "none"
|
|
4103
|
+
}`
|
|
4104
|
+
);
|
|
2166
4105
|
} else if (action.code === "configure_cloudflare") {
|
|
2167
4106
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
2168
4107
|
const ws =
|
|
@@ -2182,8 +4121,11 @@ async function runActions(cfg, actions) {
|
|
|
2182
4121
|
if (!ws) {
|
|
2183
4122
|
ok = false;
|
|
2184
4123
|
result = { error: "No folder is attached for this sandbox" };
|
|
4124
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2185
4125
|
} else {
|
|
2186
|
-
result = await configureCloudflareForWorkspace(ws, cfg
|
|
4126
|
+
result = await configureCloudflareForWorkspace(ws, cfg, {
|
|
4127
|
+
actionId: action.id,
|
|
4128
|
+
});
|
|
2187
4129
|
}
|
|
2188
4130
|
} else if (action.code === "remove_workspace") {
|
|
2189
4131
|
const sandboxId = String(
|
|
@@ -2195,15 +4137,26 @@ async function runActions(cfg, actions) {
|
|
|
2195
4137
|
);
|
|
2196
4138
|
saveConfig(cfg);
|
|
2197
4139
|
result = { removedSandboxId: sandboxId };
|
|
4140
|
+
log(`${label} removed workspace`);
|
|
2198
4141
|
} else {
|
|
2199
4142
|
ok = false;
|
|
2200
4143
|
result = { error: `Unknown action ${action.code}` };
|
|
4144
|
+
warn(`${label} skipped: ${result.error}`);
|
|
2201
4145
|
}
|
|
2202
4146
|
} catch (err) {
|
|
2203
4147
|
ok = false;
|
|
2204
4148
|
result = { error: err instanceof Error ? err.message : String(err) };
|
|
4149
|
+
warn(`${label} threw: ${result.error}`);
|
|
2205
4150
|
}
|
|
2206
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
|
+
|
|
2207
4160
|
try {
|
|
2208
4161
|
await api(
|
|
2209
4162
|
cfg.adminUrl,
|
|
@@ -2212,9 +4165,10 @@ async function runActions(cfg, actions) {
|
|
|
2212
4165
|
`/api/v1/bridge/machine/actions/${action.id}/complete`,
|
|
2213
4166
|
{ ok, result }
|
|
2214
4167
|
);
|
|
4168
|
+
log(`${label} reported to admin`);
|
|
2215
4169
|
} catch (err) {
|
|
2216
4170
|
warn(
|
|
2217
|
-
|
|
4171
|
+
`${label} report failed: ${
|
|
2218
4172
|
err instanceof Error ? err.message : String(err)
|
|
2219
4173
|
}`
|
|
2220
4174
|
);
|
|
@@ -2223,50 +4177,155 @@ async function runActions(cfg, actions) {
|
|
|
2223
4177
|
}
|
|
2224
4178
|
|
|
2225
4179
|
async function collectWorkspaceStates(cfg) {
|
|
2226
|
-
/** @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[]}>} */
|
|
2227
4181
|
const localStates = [];
|
|
2228
4182
|
for (const ws of cfg.workspaces || []) {
|
|
2229
4183
|
const folder = path.resolve(ws.folderPath || "");
|
|
2230
|
-
const
|
|
2231
|
-
|
|
2232
|
-
|
|
4184
|
+
const status = await reconcileWorkspacePresence(ws, cfg, {
|
|
4185
|
+
writeEnv: true,
|
|
4186
|
+
timeoutMs: 800,
|
|
4187
|
+
});
|
|
2233
4188
|
localStates.push({
|
|
2234
4189
|
sandboxId: ws.sandboxId,
|
|
2235
4190
|
sandboxName: ws.sandboxName,
|
|
2236
|
-
port: ws.port,
|
|
4191
|
+
port: status.probe.chatPort || ws.port,
|
|
2237
4192
|
folderPath: ws.folderPath,
|
|
2238
|
-
aiServerUp:
|
|
4193
|
+
aiServerUp: status.aiServerUp,
|
|
4194
|
+
appsRunning: status.appsRunning,
|
|
2239
4195
|
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2240
|
-
appUrl:
|
|
2241
|
-
|
|
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,
|
|
2242
4205
|
});
|
|
2243
4206
|
}
|
|
2244
4207
|
return localStates;
|
|
2245
4208
|
}
|
|
2246
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
|
+
|
|
2247
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
|
+
};
|
|
2248
4268
|
return api(
|
|
2249
4269
|
cfg.adminUrl,
|
|
2250
4270
|
cfg.token,
|
|
2251
4271
|
"POST",
|
|
2252
4272
|
"/api/v1/bridge/machine/heartbeat",
|
|
2253
|
-
|
|
2254
|
-
hostname: os.hostname(),
|
|
2255
|
-
platform: `${os.platform()}-${os.arch()}`,
|
|
2256
|
-
bridgeVersion: PACKAGE_VERSION,
|
|
2257
|
-
folders,
|
|
2258
|
-
issues: await buildIssues(cfg, localStates),
|
|
2259
|
-
workspaces: localStates.map((st) => ({
|
|
2260
|
-
sandboxId: st.sandboxId,
|
|
2261
|
-
aiServerUp: st.aiServerUp,
|
|
2262
|
-
port: st.port,
|
|
2263
|
-
appUrl: st.appUrl || undefined,
|
|
2264
|
-
appsRequested: Boolean(st.appsRequested),
|
|
2265
|
-
})),
|
|
2266
|
-
}
|
|
4273
|
+
body
|
|
2267
4274
|
);
|
|
2268
4275
|
}
|
|
2269
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
|
+
|
|
2270
4329
|
async function buildIssues(cfg, workspaceStates) {
|
|
2271
4330
|
/** @type {Array<Record<string, unknown>>} */
|
|
2272
4331
|
const issues = [];
|
|
@@ -2284,15 +4343,15 @@ async function buildIssues(cfg, workspaceStates) {
|
|
|
2284
4343
|
});
|
|
2285
4344
|
}
|
|
2286
4345
|
for (const st of workspaceStates) {
|
|
2287
|
-
if (st.aiServerUp || st.startingAi) continue;
|
|
4346
|
+
if (st.aiServerUp || st.appsRunning || st.startingAi) continue;
|
|
2288
4347
|
if (!st.appsRequested) {
|
|
2289
4348
|
issues.push({
|
|
2290
4349
|
code: "apps_not_started",
|
|
2291
4350
|
severity: "info",
|
|
2292
4351
|
title: `Apps are not running (${st.sandboxName || "sandbox"})`,
|
|
2293
4352
|
message:
|
|
2294
|
-
"This folder is attached in Maintainer Pro. Use Start
|
|
2295
|
-
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.",
|
|
2296
4355
|
actionCode: "start_ai_server",
|
|
2297
4356
|
sandboxId: st.sandboxId,
|
|
2298
4357
|
});
|
|
@@ -2313,7 +4372,7 @@ async function buildIssues(cfg, workspaceStates) {
|
|
|
2313
4372
|
`Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
|
|
2314
4373
|
resolution:
|
|
2315
4374
|
launch?.resolution ||
|
|
2316
|
-
"Read the error in that terminal, then use Start
|
|
4375
|
+
"Read the error in that terminal, then use Start Apps.",
|
|
2317
4376
|
actionCode: "start_ai_server",
|
|
2318
4377
|
sandboxId: st.sandboxId,
|
|
2319
4378
|
});
|
|
@@ -2390,12 +4449,22 @@ async function pairFlow(args) {
|
|
|
2390
4449
|
}
|
|
2391
4450
|
|
|
2392
4451
|
async function main() {
|
|
4452
|
+
if (!process.env.NODE_ENV?.trim()) {
|
|
4453
|
+
process.env.NODE_ENV = "development";
|
|
4454
|
+
}
|
|
4455
|
+
logger = createLogger("ai-bridge");
|
|
4456
|
+
|
|
2393
4457
|
const args = parseArgs(process.argv.slice(2));
|
|
2394
4458
|
if (args.help) {
|
|
2395
4459
|
printHelp();
|
|
2396
4460
|
process.exit(0);
|
|
2397
4461
|
}
|
|
2398
4462
|
|
|
4463
|
+
logger.debug(
|
|
4464
|
+
{ logLevel: logger.level, nodeEnv: process.env.NODE_ENV },
|
|
4465
|
+
"bridge starting"
|
|
4466
|
+
);
|
|
4467
|
+
|
|
2399
4468
|
let cfg = loadConfig() || {};
|
|
2400
4469
|
ensureMachineId(cfg);
|
|
2401
4470
|
|
|
@@ -2419,113 +4488,307 @@ async function main() {
|
|
|
2419
4488
|
log(`machine ${cfg.machineId}`);
|
|
2420
4489
|
log(`admin ${cfg.adminUrl}`);
|
|
2421
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);
|
|
2422
4495
|
|
|
2423
|
-
|
|
2424
|
-
|
|
4496
|
+
/** @type {unknown[]} */
|
|
4497
|
+
const claimedActions = [];
|
|
4498
|
+
let workBusy = false;
|
|
4499
|
+
|
|
4500
|
+
const runWork = async () => {
|
|
4501
|
+
if (workBusy) return;
|
|
4502
|
+
workBusy = true;
|
|
2425
4503
|
try {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
const localStates = [];
|
|
2430
|
-
|
|
2431
|
-
const reserved = new Set();
|
|
2432
|
-
for (const ws of cfg.workspaces || []) {
|
|
2433
|
-
const folder = path.resolve(ws.folderPath || "");
|
|
2434
|
-
const up = await probeUrl(
|
|
2435
|
-
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
2436
|
-
);
|
|
2437
|
-
if (appsWanted(ws) && !cfg.noAiServer && !up) {
|
|
2438
|
-
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
2439
|
-
} else if (ws.port) {
|
|
2440
|
-
reserved.add(Number(ws.port));
|
|
2441
|
-
if (up) {
|
|
2442
|
-
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2443
|
-
clearProcessProblem(ws.sandboxId, "apps_not_started");
|
|
2444
|
-
}
|
|
2445
|
-
}
|
|
2446
|
-
if (appsWanted(ws)) {
|
|
2447
|
-
await ensureHostProcesses(ws, { reserved, cfg });
|
|
2448
|
-
}
|
|
2449
|
-
await inspectHostJobs(ws);
|
|
2450
|
-
localStates.push({
|
|
2451
|
-
sandboxId: ws.sandboxId,
|
|
2452
|
-
sandboxName: ws.sandboxName,
|
|
2453
|
-
port: ws.port,
|
|
2454
|
-
folderPath: ws.folderPath,
|
|
2455
|
-
aiServerUp: up,
|
|
2456
|
-
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2457
|
-
appUrl: ws.appUrl || null,
|
|
2458
|
-
appsRequested: appsWanted(ws),
|
|
2459
|
-
});
|
|
4504
|
+
while (claimedActions.length) {
|
|
4505
|
+
const batch = claimedActions.splice(0, claimedActions.length);
|
|
4506
|
+
await runActions(cfg, batch);
|
|
2460
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
|
+
};
|
|
2461
4515
|
|
|
2462
|
-
|
|
4516
|
+
const queueActions = (actions) => {
|
|
4517
|
+
if (!Array.isArray(actions) || actions.length === 0) return;
|
|
4518
|
+
claimedActions.push(...actions);
|
|
4519
|
+
void runWork();
|
|
4520
|
+
};
|
|
2463
4521
|
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
local.port = remote.port;
|
|
2484
|
-
saveConfig(cfg);
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
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
|
+
};
|
|
2488
4541
|
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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);
|
|
2493
4581
|
} catch (err) {
|
|
2494
4582
|
warn(err instanceof Error ? err.message : String(err));
|
|
4583
|
+
} finally {
|
|
4584
|
+
presenceBusy = false;
|
|
2495
4585
|
}
|
|
2496
4586
|
};
|
|
2497
4587
|
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
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})`);
|
|
2502
4635
|
try {
|
|
2503
|
-
await
|
|
2504
|
-
|
|
2505
|
-
|
|
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));
|
|
2506
4674
|
}
|
|
2507
4675
|
};
|
|
2508
4676
|
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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 */
|
|
2523
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);
|
|
2524
4771
|
});
|
|
2525
|
-
|
|
4772
|
+
|
|
4773
|
+
ws.addEventListener("error", () => {
|
|
4774
|
+
// close handler drives reconnect
|
|
4775
|
+
});
|
|
4776
|
+
};
|
|
4777
|
+
|
|
4778
|
+
connectWs();
|
|
2526
4779
|
|
|
2527
4780
|
const shutdown = () => {
|
|
4781
|
+
stopped = true;
|
|
4782
|
+
wsGeneration += 1;
|
|
4783
|
+
clearHeartbeatTimer();
|
|
4784
|
+
clearPingTimer();
|
|
4785
|
+
clearReconnectTimer();
|
|
2528
4786
|
stopAllCloudflare();
|
|
4787
|
+
try {
|
|
4788
|
+
socket?.close();
|
|
4789
|
+
} catch {
|
|
4790
|
+
/* ignore */
|
|
4791
|
+
}
|
|
2529
4792
|
log("shutting down (other terminals stay open)");
|
|
2530
4793
|
process.exit(0);
|
|
2531
4794
|
};
|