@maintainer-pro/ai-bridge 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +4 -1
- package/src/daemon.mjs +1649 -112
package/src/daemon.mjs
CHANGED
|
@@ -7,18 +7,20 @@
|
|
|
7
7
|
* npx @maintainer-pro/ai-bridge
|
|
8
8
|
* npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
|
|
9
9
|
*/
|
|
10
|
-
import { spawn
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import http from "node:http";
|
|
14
|
+
import net from "node:net";
|
|
14
15
|
import os from "node:os";
|
|
15
16
|
import path from "node:path";
|
|
16
17
|
import readline from "node:readline";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
19
|
|
|
19
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const PACKAGE_VERSION = readPackageVersion();
|
|
21
22
|
const HEARTBEAT_MS = 15_000;
|
|
23
|
+
const ACTION_POLL_MS = 2_000;
|
|
22
24
|
|
|
23
25
|
const log = (msg) => console.log(`[bridge] ${msg}`);
|
|
24
26
|
const warn = (msg) => console.warn(`[bridge] ${msg}`);
|
|
@@ -85,7 +87,7 @@ Flags:
|
|
|
85
87
|
--pair <code> Claim a pair code from admin → Bridges page
|
|
86
88
|
--admin-url <url> Maintainer Pro base URL (required for first pair)
|
|
87
89
|
--offer-folder <path> Suggest this folder in admin (repeatable via config)
|
|
88
|
-
--no-ai-server Do not
|
|
90
|
+
--no-ai-server Do not open ai-server terminals for workspaces
|
|
89
91
|
--help
|
|
90
92
|
`);
|
|
91
93
|
}
|
|
@@ -161,26 +163,161 @@ async function api(baseUrl, token, method, pathname, body) {
|
|
|
161
163
|
return data;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
/** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
|
|
167
|
+
let aiCliModule = null;
|
|
168
|
+
|
|
169
|
+
async function loadAiCli() {
|
|
170
|
+
if (!aiCliModule) {
|
|
171
|
+
aiCliModule = (async () => {
|
|
172
|
+
try {
|
|
173
|
+
return await import("@maintainer-pro/ai-cli");
|
|
174
|
+
} catch {
|
|
175
|
+
const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
|
|
176
|
+
if (fs.existsSync(local)) {
|
|
177
|
+
return await import(pathToFileURL(local).href);
|
|
178
|
+
}
|
|
179
|
+
throw new Error("@maintainer-pro/ai-cli is not installed");
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
return aiCliModule;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function detectCliProviders() {
|
|
165
187
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
188
|
+
const { resolveProvider } = await loadAiCli();
|
|
189
|
+
const provider = await resolveProvider({ preference: "auto" });
|
|
190
|
+
return [provider.id];
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function applyProjectInfo(ws, info, cfg) {
|
|
197
|
+
if (!ws || !info) return;
|
|
198
|
+
ws.projectInfo = {
|
|
199
|
+
kind: info.kind,
|
|
200
|
+
name: info.name,
|
|
201
|
+
summary: info.summary,
|
|
202
|
+
scripts: info.scripts || {},
|
|
203
|
+
ports: info.ports || {},
|
|
204
|
+
issues: info.issues || [],
|
|
205
|
+
fixes: info.fixes || [],
|
|
206
|
+
ready: Boolean(info.ready),
|
|
207
|
+
provider: info.provider,
|
|
208
|
+
};
|
|
209
|
+
if (info.kind) ws.clientKind = info.kind;
|
|
210
|
+
const uiPort = Number(info.ports?.ui || info.ports?.app);
|
|
211
|
+
if (
|
|
212
|
+
uiPort &&
|
|
213
|
+
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
214
|
+
) {
|
|
215
|
+
ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${uiPort}`, uiPort);
|
|
216
|
+
}
|
|
217
|
+
persistWorkspaceEntry(cfg, ws);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
221
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
222
|
+
const label = ws.sandboxName || ws.applicationName || "this sandbox";
|
|
223
|
+
try {
|
|
224
|
+
const { inspectAndRepairWorkspace } = await loadAiCli();
|
|
225
|
+
log(`asking ai-cli to inspect ${folder}`);
|
|
226
|
+
const info = await inspectAndRepairWorkspace({
|
|
227
|
+
workspaceDir: folder,
|
|
228
|
+
appName: ws.applicationName || ws.sandboxName || undefined,
|
|
229
|
+
problem: opts.problem,
|
|
230
|
+
extraContext: opts.extraContext,
|
|
231
|
+
});
|
|
232
|
+
applyProjectInfo(ws, info, opts.cfg);
|
|
233
|
+
if (info.issues?.length) {
|
|
234
|
+
recordProcessProblem({
|
|
235
|
+
sandboxId: ws.sandboxId,
|
|
236
|
+
code: "project_issue",
|
|
237
|
+
role: "inspect",
|
|
238
|
+
title: `Project needs attention (${label})`,
|
|
239
|
+
message: info.issues.join(" "),
|
|
240
|
+
resolution: info.fixes?.length
|
|
241
|
+
? info.fixes.join(" ")
|
|
242
|
+
: "Fix the project in that folder, then use Start chat server.",
|
|
243
|
+
});
|
|
168
244
|
} else {
|
|
169
|
-
|
|
245
|
+
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
170
246
|
}
|
|
171
|
-
|
|
247
|
+
if (info.summary) log(`ai-cli: ${info.summary}`);
|
|
248
|
+
if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
|
|
249
|
+
return info;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
252
|
+
warn(`ai-cli inspect failed: ${message}`);
|
|
253
|
+
recordProcessProblem({
|
|
254
|
+
sandboxId: ws.sandboxId,
|
|
255
|
+
code: "project_issue",
|
|
256
|
+
role: "inspect",
|
|
257
|
+
title: `Could not inspect the project (${label})`,
|
|
258
|
+
message,
|
|
259
|
+
resolution:
|
|
260
|
+
"Install and authenticate a coding-agent CLI (agent, claude, or agy), then Check connection.",
|
|
261
|
+
actionCode: "recheck",
|
|
262
|
+
});
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isPortFree(port) {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
const server = net.createServer();
|
|
270
|
+
server.unref();
|
|
271
|
+
server.once("error", () => resolve(false));
|
|
272
|
+
server.once("listening", () => {
|
|
273
|
+
server.close(() => resolve(true));
|
|
274
|
+
});
|
|
275
|
+
server.listen(port);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prefer `preferred` if it is free and not already reserved this launch.
|
|
281
|
+
* Walks upward so bind failures are avoided without a central port service.
|
|
282
|
+
*/
|
|
283
|
+
async function findFreePort(preferred = 3100, reserved = new Set()) {
|
|
284
|
+
let port = Math.max(1024, Number(preferred) || 3100);
|
|
285
|
+
while (port <= 49151) {
|
|
286
|
+
if (!reserved.has(port) && (await isPortFree(port))) {
|
|
287
|
+
reserved.add(port);
|
|
288
|
+
return port;
|
|
289
|
+
}
|
|
290
|
+
port += 1;
|
|
291
|
+
}
|
|
292
|
+
throw new Error("No free TCP port found");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function urlWithPort(url, port) {
|
|
296
|
+
try {
|
|
297
|
+
const parsed = new URL(url);
|
|
298
|
+
parsed.port = String(port);
|
|
299
|
+
return parsed.toString().replace(/\/$/, "");
|
|
172
300
|
} catch {
|
|
173
|
-
return
|
|
301
|
+
return `http://localhost:${port}`;
|
|
174
302
|
}
|
|
175
303
|
}
|
|
176
304
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
305
|
+
function isLocalAppUrl(url) {
|
|
306
|
+
try {
|
|
307
|
+
const host = new URL(url).hostname;
|
|
308
|
+
return host === "localhost" || host === "127.0.0.1";
|
|
309
|
+
} catch {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function persistWorkspaceEntry(cfg, ws) {
|
|
315
|
+
if (!cfg || !ws?.sandboxId) return;
|
|
316
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
317
|
+
const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
|
|
318
|
+
if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
|
|
319
|
+
else cfg.workspaces.push(ws);
|
|
320
|
+
saveConfig(cfg);
|
|
184
321
|
}
|
|
185
322
|
|
|
186
323
|
function probeUrl(url, timeoutMs = 2500) {
|
|
@@ -207,10 +344,46 @@ function probeUrl(url, timeoutMs = 2500) {
|
|
|
207
344
|
});
|
|
208
345
|
}
|
|
209
346
|
|
|
347
|
+
function listDriveRoots() {
|
|
348
|
+
if (process.platform !== "win32") return ["/"];
|
|
349
|
+
const roots = [];
|
|
350
|
+
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
|
|
351
|
+
const root = `${letter}:\\`;
|
|
352
|
+
try {
|
|
353
|
+
if (fs.existsSync(root)) roots.push(root);
|
|
354
|
+
} catch {
|
|
355
|
+
/* skip */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return roots.length ? roots : ["C:\\"];
|
|
359
|
+
}
|
|
360
|
+
|
|
210
361
|
function listDirEntries(dirPath) {
|
|
211
|
-
const
|
|
362
|
+
const raw = String(dirPath || "").trim();
|
|
363
|
+
const home = os.homedir();
|
|
364
|
+
if (!raw || raw === "roots") {
|
|
365
|
+
const roots = listDriveRoots();
|
|
366
|
+
return {
|
|
367
|
+
path: "",
|
|
368
|
+
parent: null,
|
|
369
|
+
home,
|
|
370
|
+
entries: roots.map((root) => ({
|
|
371
|
+
name: root,
|
|
372
|
+
path: root,
|
|
373
|
+
isDir: true,
|
|
374
|
+
})),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const resolved = path.resolve(raw);
|
|
212
379
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
213
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
error: "Not a directory",
|
|
382
|
+
path: resolved,
|
|
383
|
+
parent: path.dirname(resolved),
|
|
384
|
+
home,
|
|
385
|
+
entries: [],
|
|
386
|
+
};
|
|
214
387
|
}
|
|
215
388
|
const names = fs.readdirSync(resolved);
|
|
216
389
|
const entries = [];
|
|
@@ -232,7 +405,13 @@ function listDirEntries(dirPath) {
|
|
|
232
405
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
233
406
|
return a.name.localeCompare(b.name);
|
|
234
407
|
});
|
|
235
|
-
|
|
408
|
+
const parent = path.dirname(resolved);
|
|
409
|
+
return {
|
|
410
|
+
path: resolved,
|
|
411
|
+
parent: parent === resolved ? null : parent,
|
|
412
|
+
home,
|
|
413
|
+
entries: entries.slice(0, 400),
|
|
414
|
+
};
|
|
236
415
|
}
|
|
237
416
|
|
|
238
417
|
function mergeEnvFile(file, values) {
|
|
@@ -256,6 +435,401 @@ function mergeEnvFile(file, values) {
|
|
|
256
435
|
fs.writeFileSync(file, body + "\n", "utf8");
|
|
257
436
|
}
|
|
258
437
|
|
|
438
|
+
const IGNORE_NAMES = new Set([
|
|
439
|
+
".git",
|
|
440
|
+
".DS_Store",
|
|
441
|
+
"Thumbs.db",
|
|
442
|
+
"node_modules",
|
|
443
|
+
".maintainer-pro",
|
|
444
|
+
".maintainer-pro-bridge.json",
|
|
445
|
+
".cloudflare-tunnel-url",
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
function isIgnorableEntry(name) {
|
|
449
|
+
if (IGNORE_NAMES.has(name)) return true;
|
|
450
|
+
if (name.startsWith(".env")) return true;
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Detect project shape in a folder.
|
|
456
|
+
* @returns {"empty"|"next"|"html"|"other"}
|
|
457
|
+
*/
|
|
458
|
+
function detectProjectKind(dir) {
|
|
459
|
+
if (!fs.existsSync(dir)) return "empty";
|
|
460
|
+
const names = fs.readdirSync(dir).filter((n) => !isIgnorableEntry(n));
|
|
461
|
+
if (names.length === 0) return "empty";
|
|
462
|
+
|
|
463
|
+
const has = (n) => names.includes(n) || fs.existsSync(path.join(dir, n));
|
|
464
|
+
if (
|
|
465
|
+
has("next.config.js") ||
|
|
466
|
+
has("next.config.mjs") ||
|
|
467
|
+
has("next.config.ts") ||
|
|
468
|
+
has("app") ||
|
|
469
|
+
has("pages")
|
|
470
|
+
) {
|
|
471
|
+
// package.json with next is a stronger signal
|
|
472
|
+
try {
|
|
473
|
+
const pkg = JSON.parse(
|
|
474
|
+
fs.readFileSync(path.join(dir, "package.json"), "utf8")
|
|
475
|
+
);
|
|
476
|
+
if (pkg.dependencies?.next || pkg.devDependencies?.next) return "next";
|
|
477
|
+
} catch {
|
|
478
|
+
/* fall through */
|
|
479
|
+
}
|
|
480
|
+
if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts")) {
|
|
481
|
+
return "next";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (has("index.html") || has(path.join("public", "index.html"))) {
|
|
486
|
+
return "html";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Only README / license → treat as empty scaffold target
|
|
490
|
+
const meaningful = names.filter(
|
|
491
|
+
(n) =>
|
|
492
|
+
!/^readme/i.test(n) &&
|
|
493
|
+
!/^license/i.test(n) &&
|
|
494
|
+
n !== "package.json"
|
|
495
|
+
);
|
|
496
|
+
if (meaningful.length === 0 && !has("package.json")) return "empty";
|
|
497
|
+
|
|
498
|
+
return "other";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function escapeHtml(value) {
|
|
502
|
+
return String(value)
|
|
503
|
+
.replaceAll("&", "&")
|
|
504
|
+
.replaceAll("<", "<")
|
|
505
|
+
.replaceAll(">", ">")
|
|
506
|
+
.replaceAll('"', """);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function emptyProjectIndexHtml(appName) {
|
|
510
|
+
return `<!doctype html>
|
|
511
|
+
<html lang="en">
|
|
512
|
+
<head>
|
|
513
|
+
<meta charset="utf-8" />
|
|
514
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
515
|
+
<title>${escapeHtml(appName)}</title>
|
|
516
|
+
<style>
|
|
517
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a2330; }
|
|
518
|
+
p { color: #5c6b7a; }
|
|
519
|
+
</style>
|
|
520
|
+
</head>
|
|
521
|
+
<body>
|
|
522
|
+
<h1>${escapeHtml(appName)}</h1>
|
|
523
|
+
<p>Ask the assistant to change this page.</p>
|
|
524
|
+
<script src="/embed-config.js"></script>
|
|
525
|
+
<script src="/ai-ui.iife.js"></script>
|
|
526
|
+
<script>
|
|
527
|
+
(function () {
|
|
528
|
+
var cfg = window.__MAINTAINER_PRO__ || {};
|
|
529
|
+
AiUi.init({
|
|
530
|
+
apiUrl: cfg.apiUrl || "/api/chat",
|
|
531
|
+
title: "AI Assistant",
|
|
532
|
+
maintainerProUrl: cfg.maintainerProUrl || undefined,
|
|
533
|
+
maintainerProApiKey: cfg.maintainerProApiKey || undefined,
|
|
534
|
+
});
|
|
535
|
+
})();
|
|
536
|
+
</script>
|
|
537
|
+
</body>
|
|
538
|
+
</html>
|
|
539
|
+
`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const WIDGET_MARKER = "AiUi.init";
|
|
543
|
+
|
|
544
|
+
function injectHtmlWidget(html, aiServerUrl) {
|
|
545
|
+
if (html.includes(WIDGET_MARKER) || html.includes("ai-ui.iife.js")) {
|
|
546
|
+
return { html, injected: false };
|
|
547
|
+
}
|
|
548
|
+
const snippet = `
|
|
549
|
+
<script src="${aiServerUrl}/embed-config.js"></script>
|
|
550
|
+
<script src="${aiServerUrl}/ai-ui.iife.js"></script>
|
|
551
|
+
<script>
|
|
552
|
+
(function () {
|
|
553
|
+
var cfg = window.__MAINTAINER_PRO__ || {};
|
|
554
|
+
if (!cfg.apiUrl) {
|
|
555
|
+
console.error("Maintainer Pro: embed-config.js missing apiUrl (is ai-server running?)");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
AiUi.init({
|
|
559
|
+
apiUrl: cfg.apiUrl,
|
|
560
|
+
title: "AI Assistant",
|
|
561
|
+
maintainerProUrl: cfg.maintainerProUrl || undefined,
|
|
562
|
+
maintainerProApiKey: cfg.maintainerProApiKey || undefined,
|
|
563
|
+
});
|
|
564
|
+
})();
|
|
565
|
+
</script>
|
|
566
|
+
`;
|
|
567
|
+
if (/<\/body>/i.test(html)) {
|
|
568
|
+
return {
|
|
569
|
+
html: html.replace(/<\/body>/i, `${snippet}</body>`),
|
|
570
|
+
injected: true,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
return { html: html + snippet, injected: true };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function nextWidgetComponentSource() {
|
|
577
|
+
return `"use client";
|
|
578
|
+
|
|
579
|
+
import { useEffect } from "react";
|
|
580
|
+
|
|
581
|
+
declare global {
|
|
582
|
+
interface Window {
|
|
583
|
+
AiUi?: { init: (opts: Record<string, unknown>) => void; destroy?: () => void };
|
|
584
|
+
__MAINTAINER_PRO__?: {
|
|
585
|
+
aiServerUrl?: string;
|
|
586
|
+
apiUrl?: string;
|
|
587
|
+
maintainerProUrl?: string;
|
|
588
|
+
maintainerProApiKey?: string;
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const AI_SERVER_URL = (process.env.NEXT_PUBLIC_AI_SERVER_URL || "").replace(/\\/$/, "");
|
|
594
|
+
|
|
595
|
+
export function MaintainerProWidget() {
|
|
596
|
+
useEffect(() => {
|
|
597
|
+
if (!AI_SERVER_URL) {
|
|
598
|
+
console.error("Set NEXT_PUBLIC_AI_SERVER_URL to the ai-server origin");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let cancelled = false;
|
|
602
|
+
|
|
603
|
+
const start = (cfg: {
|
|
604
|
+
apiUrl?: string;
|
|
605
|
+
maintainerProUrl?: string;
|
|
606
|
+
maintainerProApiKey?: string;
|
|
607
|
+
}) => {
|
|
608
|
+
if (cancelled) return;
|
|
609
|
+
const AiUi = window.AiUi;
|
|
610
|
+
if (!AiUi?.init) {
|
|
611
|
+
setTimeout(() => start(cfg), 40);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
AiUi.init({
|
|
615
|
+
apiUrl: cfg.apiUrl || \`\${AI_SERVER_URL}/api/chat\`,
|
|
616
|
+
title: "AI Assistant",
|
|
617
|
+
maintainerProUrl: cfg.maintainerProUrl,
|
|
618
|
+
maintainerProApiKey: cfg.maintainerProApiKey,
|
|
619
|
+
});
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const ensureScript = (src: string) =>
|
|
623
|
+
new Promise<void>((resolve, reject) => {
|
|
624
|
+
const existing = document.querySelector<HTMLScriptElement>(\`script[src="\${src}"]\`);
|
|
625
|
+
if (existing) {
|
|
626
|
+
if (existing.dataset.loaded === "1") resolve();
|
|
627
|
+
else existing.addEventListener("load", () => resolve(), { once: true });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const script = document.createElement("script");
|
|
631
|
+
script.src = src;
|
|
632
|
+
script.async = true;
|
|
633
|
+
script.onload = () => {
|
|
634
|
+
script.dataset.loaded = "1";
|
|
635
|
+
resolve();
|
|
636
|
+
};
|
|
637
|
+
script.onerror = () => reject(new Error(\`Failed to load \${src}\`));
|
|
638
|
+
document.body.appendChild(script);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
void (async () => {
|
|
642
|
+
try {
|
|
643
|
+
await ensureScript(\`\${AI_SERVER_URL}/embed-config.js\`);
|
|
644
|
+
await ensureScript(\`\${AI_SERVER_URL}/ai-ui.iife.js\`);
|
|
645
|
+
start(window.__MAINTAINER_PRO__ || {});
|
|
646
|
+
} catch (err) {
|
|
647
|
+
console.error(err);
|
|
648
|
+
}
|
|
649
|
+
})();
|
|
650
|
+
|
|
651
|
+
return () => {
|
|
652
|
+
cancelled = true;
|
|
653
|
+
window.AiUi?.destroy?.();
|
|
654
|
+
};
|
|
655
|
+
}, []);
|
|
656
|
+
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function tryMountNextWidget(dir) {
|
|
663
|
+
const candidates = [
|
|
664
|
+
path.join(dir, "app", "layout.tsx"),
|
|
665
|
+
path.join(dir, "app", "layout.jsx"),
|
|
666
|
+
path.join(dir, "src", "app", "layout.tsx"),
|
|
667
|
+
path.join(dir, "src", "app", "layout.jsx"),
|
|
668
|
+
];
|
|
669
|
+
for (const layout of candidates) {
|
|
670
|
+
if (!fs.existsSync(layout)) continue;
|
|
671
|
+
let text = fs.readFileSync(layout, "utf8");
|
|
672
|
+
if (text.includes("MaintainerProWidget")) {
|
|
673
|
+
return { mounted: false, reason: "already mounted" };
|
|
674
|
+
}
|
|
675
|
+
const fromAppRoot =
|
|
676
|
+
/[/\\]app[/\\]layout\.(t|j)sx$/.test(layout) &&
|
|
677
|
+
!/[/\\]src[/\\]app[/\\]/.test(layout);
|
|
678
|
+
const imp = fromAppRoot
|
|
679
|
+
? "../components/MaintainerProWidget"
|
|
680
|
+
: "@/components/MaintainerProWidget";
|
|
681
|
+
|
|
682
|
+
text = `import { MaintainerProWidget } from "${imp}";\n` + text;
|
|
683
|
+
if (/\{children\}/.test(text)) {
|
|
684
|
+
text = text.replace(
|
|
685
|
+
/\{children\}/,
|
|
686
|
+
"{children}\n <MaintainerProWidget />"
|
|
687
|
+
);
|
|
688
|
+
fs.writeFileSync(layout, text, "utf8");
|
|
689
|
+
return { mounted: true, layout };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return { mounted: false, reason: "no layout found" };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Configure client files for a workspace.
|
|
697
|
+
*/
|
|
698
|
+
function configureClient(opts) {
|
|
699
|
+
const {
|
|
700
|
+
dir,
|
|
701
|
+
port,
|
|
702
|
+
appName,
|
|
703
|
+
mode, // auto | empty | existing | skip
|
|
704
|
+
hostAppUrl,
|
|
705
|
+
} = opts;
|
|
706
|
+
const aiOrigin = `http://localhost:${port}`;
|
|
707
|
+
/** @type {string[]} */
|
|
708
|
+
const notes = [];
|
|
709
|
+
/** @type {string[]} */
|
|
710
|
+
const filesWritten = [];
|
|
711
|
+
|
|
712
|
+
let kind = detectProjectKind(dir);
|
|
713
|
+
if (mode === "empty") kind = "empty";
|
|
714
|
+
if (mode === "existing") {
|
|
715
|
+
if (kind === "empty") kind = "other";
|
|
716
|
+
}
|
|
717
|
+
if (mode === "skip") {
|
|
718
|
+
return {
|
|
719
|
+
kind: "skipped",
|
|
720
|
+
notes: ["Client scaffolding skipped (manual)."],
|
|
721
|
+
filesWritten,
|
|
722
|
+
corsOrigin: hostAppUrl || null,
|
|
723
|
+
appUrl: hostAppUrl || null,
|
|
724
|
+
sameOrigin: false,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (kind === "empty" || mode === "empty") {
|
|
729
|
+
const indexPath = path.join(dir, "index.html");
|
|
730
|
+
if (!fs.existsSync(indexPath)) {
|
|
731
|
+
fs.writeFileSync(indexPath, emptyProjectIndexHtml(appName), "utf8");
|
|
732
|
+
filesWritten.push("index.html");
|
|
733
|
+
notes.push("Created index.html (served by ai-server).");
|
|
734
|
+
} else {
|
|
735
|
+
notes.push("index.html already present.");
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
kind: "empty",
|
|
739
|
+
notes,
|
|
740
|
+
filesWritten,
|
|
741
|
+
corsOrigin: aiOrigin,
|
|
742
|
+
appUrl: hostAppUrl || aiOrigin,
|
|
743
|
+
sameOrigin: true,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (kind === "html") {
|
|
748
|
+
const candidates = [
|
|
749
|
+
path.join(dir, "index.html"),
|
|
750
|
+
path.join(dir, "public", "index.html"),
|
|
751
|
+
];
|
|
752
|
+
for (const htmlPath of candidates) {
|
|
753
|
+
if (!fs.existsSync(htmlPath)) continue;
|
|
754
|
+
const raw = fs.readFileSync(htmlPath, "utf8");
|
|
755
|
+
const { html, injected } = injectHtmlWidget(raw, aiOrigin);
|
|
756
|
+
if (injected) {
|
|
757
|
+
fs.writeFileSync(htmlPath, html, "utf8");
|
|
758
|
+
filesWritten.push(path.relative(dir, htmlPath));
|
|
759
|
+
notes.push(`Injected widget into ${path.relative(dir, htmlPath)}.`);
|
|
760
|
+
} else {
|
|
761
|
+
notes.push(`Widget already present in ${path.relative(dir, htmlPath)}.`);
|
|
762
|
+
}
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
const origin = hostAppUrl || "http://localhost:3000";
|
|
766
|
+
return {
|
|
767
|
+
kind: "html",
|
|
768
|
+
notes,
|
|
769
|
+
filesWritten,
|
|
770
|
+
corsOrigin: origin,
|
|
771
|
+
appUrl: origin,
|
|
772
|
+
sameOrigin: false,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (kind === "next") {
|
|
777
|
+
const envLocal = path.join(dir, ".env.local");
|
|
778
|
+
mergeEnvFile(envLocal, {
|
|
779
|
+
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
780
|
+
});
|
|
781
|
+
filesWritten.push(".env.local");
|
|
782
|
+
notes.push("Set NEXT_PUBLIC_AI_SERVER_URL in .env.local.");
|
|
783
|
+
|
|
784
|
+
const useSrc = fs.existsSync(path.join(dir, "src", "app"));
|
|
785
|
+
const compDir = useSrc
|
|
786
|
+
? path.join(dir, "src", "components")
|
|
787
|
+
: path.join(dir, "components");
|
|
788
|
+
fs.mkdirSync(compDir, { recursive: true });
|
|
789
|
+
const widgetPath = path.join(compDir, "MaintainerProWidget.tsx");
|
|
790
|
+
if (!fs.existsSync(widgetPath)) {
|
|
791
|
+
fs.writeFileSync(widgetPath, nextWidgetComponentSource(), "utf8");
|
|
792
|
+
filesWritten.push(path.relative(dir, widgetPath));
|
|
793
|
+
notes.push("Added MaintainerProWidget.tsx.");
|
|
794
|
+
}
|
|
795
|
+
const mount = tryMountNextWidget(dir);
|
|
796
|
+
if (mount.mounted) {
|
|
797
|
+
notes.push(`Mounted widget in ${path.relative(dir, mount.layout)}.`);
|
|
798
|
+
} else {
|
|
799
|
+
notes.push(
|
|
800
|
+
"Add <MaintainerProWidget /> to your root layout if it is not mounted yet."
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const origin = hostAppUrl || "http://localhost:3000";
|
|
804
|
+
return {
|
|
805
|
+
kind: "next",
|
|
806
|
+
notes,
|
|
807
|
+
filesWritten,
|
|
808
|
+
corsOrigin: origin,
|
|
809
|
+
appUrl: origin,
|
|
810
|
+
sameOrigin: false,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// other — write host env hints only
|
|
815
|
+
const origin = hostAppUrl || "http://localhost:3000";
|
|
816
|
+
mergeEnvFile(path.join(dir, ".env"), {
|
|
817
|
+
AI_SERVER_URL: aiOrigin,
|
|
818
|
+
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
819
|
+
});
|
|
820
|
+
notes.push(
|
|
821
|
+
"Existing project detected. Set AI_SERVER_URL / mount the widget manually if needed."
|
|
822
|
+
);
|
|
823
|
+
return {
|
|
824
|
+
kind: "other",
|
|
825
|
+
notes,
|
|
826
|
+
filesWritten,
|
|
827
|
+
corsOrigin: origin,
|
|
828
|
+
appUrl: origin,
|
|
829
|
+
sameOrigin: false,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
259
833
|
function collectOfferedFolders(cfg) {
|
|
260
834
|
/** @type {string[]} */
|
|
261
835
|
const folders = [];
|
|
@@ -270,26 +844,389 @@ function collectOfferedFolders(cfg) {
|
|
|
270
844
|
return folders;
|
|
271
845
|
}
|
|
272
846
|
|
|
273
|
-
/**
|
|
274
|
-
const
|
|
847
|
+
/** Prevents opening a new window on every heartbeat while a process is starting. */
|
|
848
|
+
const launchedAt = new Map();
|
|
849
|
+
|
|
850
|
+
/** Last process problems to send on heartbeat. Key: sandboxId::code::role */
|
|
851
|
+
const processProblems = new Map();
|
|
275
852
|
|
|
276
|
-
function
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
853
|
+
function forgetLaunch(sandboxId) {
|
|
854
|
+
for (const key of [...launchedAt.keys()]) {
|
|
855
|
+
if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
|
|
856
|
+
launchedAt.delete(key);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const key of [...processProblems.keys()]) {
|
|
860
|
+
if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
|
|
281
861
|
}
|
|
862
|
+
stopCloudflare(sandboxId);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function problemKey(sandboxId, code, role = "") {
|
|
866
|
+
return `${sandboxId || ""}::${code}::${role}`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function clipIssueText(text, max) {
|
|
870
|
+
const value = String(text || "").trim();
|
|
871
|
+
if (value.length <= max) return value;
|
|
872
|
+
return `${value.slice(0, max - 1)}…`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function processRoleLabel(role) {
|
|
876
|
+
if (role === "ui") return "app UI";
|
|
877
|
+
if (role === "backend") return "backend";
|
|
878
|
+
if (role === "ai") return "chat script";
|
|
879
|
+
return "app";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function recordProcessProblem(issue) {
|
|
883
|
+
const sandboxId = issue.sandboxId || "";
|
|
884
|
+
const role = issue.role || "";
|
|
885
|
+
processProblems.set(problemKey(sandboxId, issue.code, role), {
|
|
886
|
+
code: issue.code,
|
|
887
|
+
role,
|
|
888
|
+
severity: issue.severity || "error",
|
|
889
|
+
title: clipIssueText(issue.title, 200),
|
|
890
|
+
message: clipIssueText(issue.message, 2000),
|
|
891
|
+
resolution: clipIssueText(issue.resolution || "", 2000),
|
|
892
|
+
actionCode: issue.actionCode ?? "start_ai_server",
|
|
893
|
+
sandboxId: sandboxId || null,
|
|
894
|
+
});
|
|
895
|
+
warn(`${issue.title}: ${issue.message}`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function clearProcessProblem(sandboxId, code, role = "") {
|
|
899
|
+
processProblems.delete(problemKey(sandboxId, code, role));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function issuesForSandbox(sandboxId) {
|
|
903
|
+
return [...processProblems.values()].filter(
|
|
904
|
+
(issue) => issue.sandboxId === sandboxId
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function recentlyLaunched(launchKey) {
|
|
909
|
+
const last = launchedAt.get(launchKey) ?? 0;
|
|
910
|
+
return last > 0 && Date.now() - last < 60_000;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function readPackageJson(dir) {
|
|
282
914
|
try {
|
|
283
|
-
|
|
915
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
284
916
|
} catch {
|
|
285
|
-
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function isChatScript(name, command) {
|
|
922
|
+
const text = `${name} ${command}`;
|
|
923
|
+
return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(text);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isUiCommand(command) {
|
|
927
|
+
return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
|
|
928
|
+
command
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function portFromText(text, fallback) {
|
|
933
|
+
const match =
|
|
934
|
+
String(text || "").match(/--port(?:\s+|=)(\d{2,5})/i) ||
|
|
935
|
+
String(text || "").match(/\bPORT[=:]\s*(\d{2,5})/i) ||
|
|
936
|
+
String(text || "").match(/:(\d{2,5})\b/);
|
|
937
|
+
return match ? Number(match[1]) : fallback;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* @returns {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>}
|
|
942
|
+
*/
|
|
943
|
+
function planHostJobs(dir, appUrl, hints = null) {
|
|
944
|
+
const pkg = readPackageJson(dir);
|
|
945
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
946
|
+
const hinted = hints?.scripts && typeof hints.scripts === "object" ? hints.scripts : {};
|
|
947
|
+
const hintedPorts = hints?.ports && typeof hints.ports === "object" ? hints.ports : {};
|
|
948
|
+
const pickHint = (name) =>
|
|
949
|
+
name && scripts[name] && !isChatScript(name, scripts[name]) ? name : null;
|
|
950
|
+
const pick = (names) =>
|
|
951
|
+
names.find((name) => scripts[name] && !isChatScript(name, scripts[name]));
|
|
952
|
+
|
|
953
|
+
const uiScript =
|
|
954
|
+
pickHint(hinted.ui) ||
|
|
955
|
+
pick([
|
|
956
|
+
"dev:client",
|
|
957
|
+
"dev:ui",
|
|
958
|
+
"dev:web",
|
|
959
|
+
"dev:frontend",
|
|
960
|
+
"client",
|
|
961
|
+
"start:client",
|
|
962
|
+
]);
|
|
963
|
+
const apiScript =
|
|
964
|
+
pickHint(hinted.backend) ||
|
|
965
|
+
pick([
|
|
966
|
+
"dev:server",
|
|
967
|
+
"dev:backend",
|
|
968
|
+
"dev:api",
|
|
969
|
+
"server",
|
|
970
|
+
"start:server",
|
|
971
|
+
]);
|
|
972
|
+
|
|
973
|
+
/** @type {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>} */
|
|
974
|
+
const jobs = [];
|
|
975
|
+
const addJob = (role, script, fallbackPort, probe) => {
|
|
976
|
+
if (!script || jobs.some((job) => job.script === script)) return;
|
|
977
|
+
const port = portFromText(scripts[script] || probe, fallbackPort);
|
|
978
|
+
jobs.push({
|
|
979
|
+
role,
|
|
980
|
+
script,
|
|
981
|
+
command: `npm run ${script}`,
|
|
982
|
+
preferredPort: port,
|
|
983
|
+
probeUrl: probe || `http://127.0.0.1:${port}`,
|
|
984
|
+
});
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
addJob("ui", uiScript, Number(hintedPorts.ui) || 5173, appUrl || null);
|
|
988
|
+
addJob("backend", apiScript, Number(hintedPorts.backend) || 4100, null);
|
|
989
|
+
if (jobs.length === 0 && pickHint(hinted.app)) {
|
|
990
|
+
addJob(
|
|
991
|
+
isUiCommand(scripts[hinted.app]) ? "ui" : "app",
|
|
992
|
+
hinted.app,
|
|
993
|
+
Number(hintedPorts.app) || 3000,
|
|
994
|
+
appUrl || null
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (scripts.dev && !isChatScript("dev", scripts.dev)) {
|
|
999
|
+
const nested = [
|
|
1000
|
+
...String(scripts.dev).matchAll(/npm run ([a-zA-Z0-9:_-]+)/g),
|
|
1001
|
+
]
|
|
1002
|
+
.map((match) => match[1])
|
|
1003
|
+
.filter(
|
|
1004
|
+
(name) =>
|
|
1005
|
+
name !== "dev" && scripts[name] && !isChatScript(name, scripts[name])
|
|
1006
|
+
);
|
|
1007
|
+
if (nested.length >= 2) {
|
|
1008
|
+
for (const name of nested) {
|
|
1009
|
+
const role = /client|ui|web|frontend/i.test(name)
|
|
1010
|
+
? "ui"
|
|
1011
|
+
: /server|backend|api/i.test(name)
|
|
1012
|
+
? "backend"
|
|
1013
|
+
: name;
|
|
1014
|
+
addJob(role, name, 3000, role === "ui" ? appUrl || null : null);
|
|
1015
|
+
}
|
|
1016
|
+
} else if (jobs.length === 0) {
|
|
1017
|
+
addJob(isUiCommand(scripts.dev) ? "ui" : "app", "dev", 3000, appUrl || null);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return jobs;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function friendlyLaunchError(raw, title) {
|
|
1024
|
+
const text = String(raw || "").trim();
|
|
1025
|
+
if (/ENOENT/i.test(text)) {
|
|
1026
|
+
return `Could not find a program to open a terminal for "${title}". ${text}`;
|
|
286
1027
|
}
|
|
287
|
-
|
|
1028
|
+
return text || `Could not open a terminal for "${title}".`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function runLauncher(command, args, extra = {}) {
|
|
1032
|
+
return new Promise((resolve) => {
|
|
1033
|
+
let settled = false;
|
|
1034
|
+
const done = (result) => {
|
|
1035
|
+
if (settled) return;
|
|
1036
|
+
settled = true;
|
|
1037
|
+
resolve(result);
|
|
1038
|
+
};
|
|
1039
|
+
let child;
|
|
1040
|
+
try {
|
|
1041
|
+
child = spawn(command, args, {
|
|
1042
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1043
|
+
windowsHide: extra.windowsHide,
|
|
1044
|
+
});
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
done({
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1049
|
+
});
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
let stderr = "";
|
|
1053
|
+
child.stderr.on("data", (chunk) => {
|
|
1054
|
+
stderr += String(chunk);
|
|
1055
|
+
});
|
|
1056
|
+
child.once("error", (err) => {
|
|
1057
|
+
done({ ok: false, error: err.message });
|
|
1058
|
+
});
|
|
1059
|
+
child.once("exit", (code) => {
|
|
1060
|
+
if (code === 0 || code === null) done({ ok: true });
|
|
1061
|
+
else {
|
|
1062
|
+
const detail = stderr.trim().replace(/\s+/g, " ");
|
|
1063
|
+
done({
|
|
1064
|
+
ok: false,
|
|
1065
|
+
error: detail || `${command} exited with code ${code}`,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
child.once("spawn", () => {
|
|
1070
|
+
setTimeout(() => done({ ok: true }), 400);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
288
1073
|
}
|
|
289
1074
|
|
|
290
|
-
function
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
1075
|
+
async function openInNewTerminal(opts) {
|
|
1076
|
+
const { title, folder, command, env = {}, launchKey } = opts;
|
|
1077
|
+
if (launchKey) {
|
|
1078
|
+
if (recentlyLaunched(launchKey)) return { ok: true, skipped: true };
|
|
1079
|
+
launchedAt.set(launchKey, Date.now());
|
|
1080
|
+
}
|
|
1081
|
+
if (!fs.existsSync(folder)) {
|
|
1082
|
+
const error = `Folder is missing: ${folder}`;
|
|
1083
|
+
warn(`cannot open terminal: ${error}`);
|
|
1084
|
+
return { ok: false, error };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const envWin = Object.entries(env)
|
|
1088
|
+
.map(([key, value]) => `set ${key}=${value}`)
|
|
1089
|
+
.join("&& ");
|
|
1090
|
+
const envUnix = Object.entries(env)
|
|
1091
|
+
.map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
|
|
1092
|
+
.join(" && ");
|
|
1093
|
+
|
|
1094
|
+
log(`opening separate terminal [${title}] in ${folder}: ${command}`);
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
if (process.platform === "win32") {
|
|
1098
|
+
const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
|
|
1099
|
+
const escaped = inner.replace(/'/g, "''");
|
|
1100
|
+
const opened = await runLauncher(
|
|
1101
|
+
"powershell.exe",
|
|
1102
|
+
[
|
|
1103
|
+
"-NoProfile",
|
|
1104
|
+
"-WindowStyle",
|
|
1105
|
+
"Hidden",
|
|
1106
|
+
"-Command",
|
|
1107
|
+
`Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
|
|
1108
|
+
],
|
|
1109
|
+
{ windowsHide: true }
|
|
1110
|
+
);
|
|
1111
|
+
if (!opened.ok) {
|
|
1112
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1113
|
+
}
|
|
1114
|
+
return { ok: true };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const script = `cd ${JSON.stringify(folder)} && ${envUnix ? `${envUnix} && ` : ""}${command}`;
|
|
1118
|
+
if (process.platform === "darwin") {
|
|
1119
|
+
const opened = await runLauncher("osascript", [
|
|
1120
|
+
"-e",
|
|
1121
|
+
`tell application "Terminal" to do script ${JSON.stringify(script)}`,
|
|
1122
|
+
]);
|
|
1123
|
+
if (!opened.ok) {
|
|
1124
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1125
|
+
}
|
|
1126
|
+
return { ok: true };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const opened = await runLauncher(process.env.TERMINAL || "x-terminal-emulator", [
|
|
1130
|
+
"-e",
|
|
1131
|
+
"bash",
|
|
1132
|
+
"-lc",
|
|
1133
|
+
`${script}; exec bash`,
|
|
1134
|
+
]);
|
|
1135
|
+
if (!opened.ok) {
|
|
1136
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1137
|
+
}
|
|
1138
|
+
return { ok: true };
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
error: friendlyLaunchError(
|
|
1143
|
+
err instanceof Error ? err.message : String(err),
|
|
1144
|
+
title
|
|
1145
|
+
),
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function commandWithPort(job, scripts, port) {
|
|
1151
|
+
const raw = String(scripts?.[job.script] || "");
|
|
1152
|
+
if (
|
|
1153
|
+
job.role === "ui" ||
|
|
1154
|
+
job.role === "app" ||
|
|
1155
|
+
isUiCommand(raw) ||
|
|
1156
|
+
/--port\b/i.test(raw)
|
|
1157
|
+
) {
|
|
1158
|
+
return `${job.command} -- --port ${port}`;
|
|
1159
|
+
}
|
|
1160
|
+
return job.command;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function startAiServerForWorkspace(ws, opts = {}) {
|
|
1164
|
+
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
1165
|
+
const cfg = opts.cfg || null;
|
|
1166
|
+
const folder = path.resolve(ws.folderPath);
|
|
1167
|
+
const preferred = Number(ws.port) || 3100;
|
|
1168
|
+
const launchKey = `${ws.sandboxId}:${folder}:ai`;
|
|
1169
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1170
|
+
|
|
1171
|
+
if (!fs.existsSync(folder)) {
|
|
1172
|
+
recordProcessProblem({
|
|
1173
|
+
sandboxId: ws.sandboxId,
|
|
1174
|
+
code: "ai_server_launch",
|
|
1175
|
+
role: "ai",
|
|
1176
|
+
title: `Could not start the chat script (${label})`,
|
|
1177
|
+
message: `The project folder is missing: ${folder}`,
|
|
1178
|
+
resolution: "Attach the folder again from Local setup.",
|
|
1179
|
+
});
|
|
1180
|
+
return { port: preferred, up: false, launched: false };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
|
|
1184
|
+
reserved.add(preferred);
|
|
1185
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1186
|
+
return { port: preferred, up: true, launched: false };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (recentlyLaunched(launchKey)) {
|
|
1190
|
+
reserved.add(preferred);
|
|
1191
|
+
return { port: preferred, up: false, launched: false, starting: true };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
let port = preferred;
|
|
1195
|
+
try {
|
|
1196
|
+
if (reserved.has(preferred)) {
|
|
1197
|
+
if (!(await isPortFree(preferred))) {
|
|
1198
|
+
reserved.delete(preferred);
|
|
1199
|
+
port = await findFreePort(preferred, reserved);
|
|
1200
|
+
}
|
|
1201
|
+
} else {
|
|
1202
|
+
port = await findFreePort(preferred, reserved);
|
|
1203
|
+
}
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
recordProcessProblem({
|
|
1206
|
+
sandboxId: ws.sandboxId,
|
|
1207
|
+
code: "ai_server_launch",
|
|
1208
|
+
role: "ai",
|
|
1209
|
+
title: `Could not start the chat script (${label})`,
|
|
1210
|
+
message: `No free port found (tried from ${preferred}). ${
|
|
1211
|
+
err instanceof Error ? err.message : String(err)
|
|
1212
|
+
}`,
|
|
1213
|
+
resolution: "Close other local servers, then use Start chat server.",
|
|
1214
|
+
});
|
|
1215
|
+
return { port: preferred, up: false, launched: false };
|
|
1216
|
+
}
|
|
1217
|
+
if (port !== preferred) {
|
|
1218
|
+
log(`port ${preferred} busy; using ${port} for ai-server`);
|
|
1219
|
+
ws.port = port;
|
|
1220
|
+
const envPath = path.join(folder, ".env");
|
|
1221
|
+
if (fs.existsSync(envPath)) {
|
|
1222
|
+
mergeEnvFile(envPath, {
|
|
1223
|
+
PORT: String(port),
|
|
1224
|
+
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1225
|
+
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1229
|
+
}
|
|
293
1230
|
|
|
294
1231
|
const localCli = path.resolve(
|
|
295
1232
|
__dirname,
|
|
@@ -300,42 +1237,371 @@ function startAiServerForWorkspace(ws) {
|
|
|
300
1237
|
"cli.js"
|
|
301
1238
|
);
|
|
302
1239
|
const useLocal = fs.existsSync(localCli);
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
};
|
|
1240
|
+
const run = useLocal
|
|
1241
|
+
? process.platform === "win32"
|
|
1242
|
+
? `"${process.execPath}" "${localCli}" --port ${port}`
|
|
1243
|
+
: `${JSON.stringify(process.execPath)} ${JSON.stringify(localCli)} --port ${port}`
|
|
1244
|
+
: `npx --yes @maintainer-pro/ai-server --port ${port}`;
|
|
309
1245
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1246
|
+
const opened = await openInNewTerminal({
|
|
1247
|
+
title: `MP-ai-${port}`,
|
|
1248
|
+
folder,
|
|
1249
|
+
command: run,
|
|
1250
|
+
env: {
|
|
1251
|
+
PORT: String(port),
|
|
1252
|
+
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1253
|
+
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1254
|
+
},
|
|
1255
|
+
launchKey,
|
|
1256
|
+
});
|
|
1257
|
+
if (opened.skipped) {
|
|
1258
|
+
return { port, up: false, launched: false, starting: true };
|
|
1259
|
+
}
|
|
1260
|
+
if (!opened.ok) {
|
|
1261
|
+
recordProcessProblem({
|
|
1262
|
+
sandboxId: ws.sandboxId,
|
|
1263
|
+
code: "ai_server_launch",
|
|
1264
|
+
role: "ai",
|
|
1265
|
+
title: `Could not start the chat script (${label})`,
|
|
1266
|
+
message: opened.error,
|
|
1267
|
+
resolution:
|
|
1268
|
+
"Allow the bridge to open terminal windows, or start the script manually in that folder.",
|
|
1269
|
+
});
|
|
1270
|
+
return { port, up: false, launched: false };
|
|
1271
|
+
}
|
|
1272
|
+
return { port, up: false, launched: true, starting: true };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function sleep(ms) {
|
|
1276
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** @type {Map<string, { child: import("node:child_process").ChildProcess, url: string | null, localUrl: string }>} */
|
|
1280
|
+
const cloudflareTunnels = new Map();
|
|
1281
|
+
|
|
1282
|
+
function stopCloudflare(sandboxId) {
|
|
1283
|
+
const row = cloudflareTunnels.get(sandboxId);
|
|
1284
|
+
if (row?.child && !row.child.killed) {
|
|
1285
|
+
try {
|
|
1286
|
+
row.child.kill();
|
|
1287
|
+
} catch {
|
|
1288
|
+
/* ignore */
|
|
323
1289
|
}
|
|
1290
|
+
}
|
|
1291
|
+
cloudflareTunnels.delete(sandboxId);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function stopAllCloudflare() {
|
|
1295
|
+
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function localUrlForWorkspace(ws) {
|
|
1299
|
+
if (ws.appUrl && isLocalAppUrl(ws.appUrl)) return ws.appUrl;
|
|
1300
|
+
return `http://127.0.0.1:${Number(ws.port) || 3100}`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function parseTryCloudflareUrl(text) {
|
|
1304
|
+
const match = String(text || "").match(
|
|
1305
|
+
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
324
1306
|
);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1307
|
+
return match ? match[0].replace(/\/$/, "") : null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function spawnCloudflared(localUrl) {
|
|
1311
|
+
const args = ["tunnel", "--url", localUrl, "--no-autoupdate"];
|
|
1312
|
+
const trySpawn = (file, argv, extra = {}) =>
|
|
1313
|
+
new Promise((resolve, reject) => {
|
|
1314
|
+
const child = spawn(file, argv, {
|
|
1315
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1316
|
+
windowsHide: true,
|
|
1317
|
+
...extra,
|
|
1318
|
+
});
|
|
1319
|
+
const onError = (err) => reject(err);
|
|
1320
|
+
child.once("error", onError);
|
|
1321
|
+
child.once("spawn", () => {
|
|
1322
|
+
child.off("error", onError);
|
|
1323
|
+
resolve(child);
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
return trySpawn("cloudflared", args).catch(() =>
|
|
1328
|
+
trySpawn(
|
|
1329
|
+
process.platform === "win32" ? "npx.cmd" : "npx",
|
|
1330
|
+
["--yes", "cloudflared", ...args],
|
|
1331
|
+
{ shell: process.platform === "win32" }
|
|
1332
|
+
)
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function waitForTunnelUrl(child, timeoutMs = 90_000) {
|
|
1337
|
+
return new Promise((resolve, reject) => {
|
|
1338
|
+
let buffer = "";
|
|
1339
|
+
let settled = false;
|
|
1340
|
+
const done = (err, url) => {
|
|
1341
|
+
if (settled) return;
|
|
1342
|
+
settled = true;
|
|
1343
|
+
clearTimeout(timer);
|
|
1344
|
+
if (err) reject(err);
|
|
1345
|
+
else resolve(url);
|
|
1346
|
+
};
|
|
1347
|
+
const onData = (chunk) => {
|
|
1348
|
+
buffer += String(chunk);
|
|
1349
|
+
const url = parseTryCloudflareUrl(buffer);
|
|
1350
|
+
if (url) done(null, url);
|
|
1351
|
+
};
|
|
1352
|
+
child.stdout?.on("data", onData);
|
|
1353
|
+
child.stderr?.on("data", onData);
|
|
1354
|
+
child.once("exit", (code) => {
|
|
1355
|
+
done(
|
|
1356
|
+
new Error(
|
|
1357
|
+
`cloudflared exited ${code ?? "early"} before it published a URL`
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
const timer = setTimeout(() => {
|
|
1362
|
+
done(
|
|
1363
|
+
new Error(
|
|
1364
|
+
"Cloudflare did not publish a URL in time. Is cloudflared installed and online?"
|
|
1365
|
+
)
|
|
1366
|
+
);
|
|
1367
|
+
}, timeoutMs);
|
|
333
1368
|
});
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function configureCloudflareForWorkspace(ws, cfg) {
|
|
1372
|
+
const sandboxId = ws.sandboxId;
|
|
1373
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1374
|
+
const localUrl = localUrlForWorkspace(ws);
|
|
1375
|
+
const existing = cloudflareTunnels.get(sandboxId);
|
|
1376
|
+
if (existing?.url && existing.localUrl === localUrl && existing.child && !existing.child.killed) {
|
|
1377
|
+
return {
|
|
1378
|
+
sandboxId,
|
|
1379
|
+
folderPath: ws.folderPath,
|
|
1380
|
+
appUrl: existing.url,
|
|
1381
|
+
origins: [existing.url],
|
|
1382
|
+
localUrl,
|
|
1383
|
+
cloudflare: true,
|
|
1384
|
+
reused: true,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
if (existing) stopCloudflare(sandboxId);
|
|
1388
|
+
|
|
1389
|
+
log(`starting Cloudflare tunnel for ${label} → ${localUrl}`);
|
|
1390
|
+
let child;
|
|
1391
|
+
try {
|
|
1392
|
+
child = await spawnCloudflared(localUrl);
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1395
|
+
recordProcessProblem({
|
|
1396
|
+
sandboxId,
|
|
1397
|
+
code: "cloudflare_launch",
|
|
1398
|
+
role: "tunnel",
|
|
1399
|
+
title: `Could not start Cloudflare (${label})`,
|
|
1400
|
+
message,
|
|
1401
|
+
resolution:
|
|
1402
|
+
"Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) or allow npx to download it, then try again.",
|
|
1403
|
+
actionCode: "configure_cloudflare",
|
|
1404
|
+
});
|
|
1405
|
+
throw new Error(
|
|
1406
|
+
`Could not start cloudflared. Install it or allow npx to download it. ${message}`
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
cloudflareTunnels.set(sandboxId, { child, url: null, localUrl });
|
|
1411
|
+
child.once("exit", () => {
|
|
1412
|
+
const row = cloudflareTunnels.get(sandboxId);
|
|
1413
|
+
if (row?.child === child) cloudflareTunnels.delete(sandboxId);
|
|
337
1414
|
});
|
|
338
|
-
|
|
1415
|
+
|
|
1416
|
+
const publicUrl = await waitForTunnelUrl(child);
|
|
1417
|
+
cloudflareTunnels.set(sandboxId, { child, url: publicUrl, localUrl });
|
|
1418
|
+
child.unref();
|
|
1419
|
+
|
|
1420
|
+
ws.cloudflareUrl = publicUrl;
|
|
1421
|
+
ws.appUrl = publicUrl;
|
|
1422
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
1423
|
+
if (folder && fs.existsSync(folder)) {
|
|
1424
|
+
fs.writeFileSync(
|
|
1425
|
+
path.join(folder, ".cloudflare-tunnel-url"),
|
|
1426
|
+
`${publicUrl}\n`,
|
|
1427
|
+
"utf8"
|
|
1428
|
+
);
|
|
1429
|
+
const envPath = path.join(folder, ".env");
|
|
1430
|
+
const envValues = {
|
|
1431
|
+
APP_URL: publicUrl,
|
|
1432
|
+
PUBLIC_URL: publicUrl,
|
|
1433
|
+
CORS_ORIGIN: publicUrl,
|
|
1434
|
+
};
|
|
1435
|
+
if (isAiServerTarget(ws, localUrl)) {
|
|
1436
|
+
envValues.AI_SERVER_URL = publicUrl;
|
|
1437
|
+
envValues.NEXT_PUBLIC_AI_SERVER_URL = publicUrl;
|
|
1438
|
+
}
|
|
1439
|
+
if (fs.existsSync(envPath)) mergeEnvFile(envPath, envValues);
|
|
1440
|
+
}
|
|
1441
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1442
|
+
clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
|
|
1443
|
+
log(`Cloudflare URL for ${label}: ${publicUrl}`);
|
|
1444
|
+
return {
|
|
1445
|
+
sandboxId,
|
|
1446
|
+
folderPath: ws.folderPath,
|
|
1447
|
+
appUrl: publicUrl,
|
|
1448
|
+
origins: [publicUrl],
|
|
1449
|
+
localUrl,
|
|
1450
|
+
cloudflare: true,
|
|
1451
|
+
reused: false,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function isAiServerTarget(ws, localUrl) {
|
|
1456
|
+
try {
|
|
1457
|
+
const port = Number(new URL(localUrl).port);
|
|
1458
|
+
return port === Number(ws.port);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function ensureHostProcesses(ws, opts = {}) {
|
|
1465
|
+
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
1466
|
+
const cfg = opts.cfg || null;
|
|
1467
|
+
const folder = path.resolve(ws.folderPath);
|
|
1468
|
+
const scripts = readPackageJson(folder)?.scripts || {};
|
|
1469
|
+
const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
|
|
1470
|
+
const started = [];
|
|
1471
|
+
let persisted = false;
|
|
1472
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1473
|
+
|
|
1474
|
+
if (jobs.length > 0 && !fs.existsSync(folder)) {
|
|
1475
|
+
recordProcessProblem({
|
|
1476
|
+
sandboxId: ws.sandboxId,
|
|
1477
|
+
code: "host_process_launch",
|
|
1478
|
+
role: "app",
|
|
1479
|
+
title: `Could not start the app (${label})`,
|
|
1480
|
+
message: `The project folder is missing: ${folder}`,
|
|
1481
|
+
resolution: "Attach the folder again from Local setup.",
|
|
1482
|
+
});
|
|
1483
|
+
return started;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
for (const job of jobs) {
|
|
1487
|
+
const preferred = Number(job.preferredPort) || 3000;
|
|
1488
|
+
const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
|
|
1489
|
+
"localhost",
|
|
1490
|
+
"127.0.0.1"
|
|
1491
|
+
);
|
|
1492
|
+
if (await probeUrl(probe)) {
|
|
1493
|
+
reserved.add(portFromText(probe, preferred));
|
|
1494
|
+
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1495
|
+
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
1496
|
+
log(`${job.role} already running at ${job.probeUrl || probe}`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1500
|
+
if (recentlyLaunched(launchKey)) {
|
|
1501
|
+
reserved.add(preferred);
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let port = preferred;
|
|
1506
|
+
try {
|
|
1507
|
+
port = await findFreePort(preferred, reserved);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
recordProcessProblem({
|
|
1510
|
+
sandboxId: ws.sandboxId,
|
|
1511
|
+
code: "host_process_launch",
|
|
1512
|
+
role: job.role,
|
|
1513
|
+
title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
|
|
1514
|
+
message: `No free port found for "${job.script}" (tried from ${preferred}). ${
|
|
1515
|
+
err instanceof Error ? err.message : String(err)
|
|
1516
|
+
}`,
|
|
1517
|
+
resolution: "Close other local servers, then use Start chat server.",
|
|
1518
|
+
});
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if (port !== preferred) {
|
|
1522
|
+
log(`port ${preferred} busy; using ${port} for ${job.role}`);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const needsInstall =
|
|
1526
|
+
fs.existsSync(path.join(folder, "package.json")) &&
|
|
1527
|
+
!fs.existsSync(path.join(folder, "node_modules"));
|
|
1528
|
+
const run = commandWithPort(job, scripts, port);
|
|
1529
|
+
const command = needsInstall ? `npm install && ${run}` : run;
|
|
1530
|
+
const opened = await openInNewTerminal({
|
|
1531
|
+
title: `MP-${job.role}-${port}`,
|
|
1532
|
+
folder,
|
|
1533
|
+
command,
|
|
1534
|
+
env: { PORT: String(port) },
|
|
1535
|
+
launchKey,
|
|
1536
|
+
});
|
|
1537
|
+
if (opened.skipped) continue;
|
|
1538
|
+
if (!opened.ok) {
|
|
1539
|
+
recordProcessProblem({
|
|
1540
|
+
sandboxId: ws.sandboxId,
|
|
1541
|
+
code: "host_process_launch",
|
|
1542
|
+
role: job.role,
|
|
1543
|
+
title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
|
|
1544
|
+
message: `${opened.error} Command: ${command}`,
|
|
1545
|
+
resolution:
|
|
1546
|
+
"Allow the bridge to open terminal windows, or run that command manually in the folder.",
|
|
1547
|
+
});
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
started.push(job.role);
|
|
1551
|
+
if (
|
|
1552
|
+
(job.role === "ui" || job.role === "app") &&
|
|
1553
|
+
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
1554
|
+
) {
|
|
1555
|
+
ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
|
|
1556
|
+
persisted = true;
|
|
1557
|
+
}
|
|
1558
|
+
await sleep(400);
|
|
1559
|
+
}
|
|
1560
|
+
if (persisted) persistWorkspaceEntry(cfg, ws);
|
|
1561
|
+
return started;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function inspectHostJobs(ws) {
|
|
1565
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1566
|
+
const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
|
|
1567
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1568
|
+
const hosts = [];
|
|
1569
|
+
for (const job of jobs) {
|
|
1570
|
+
const preferred = Number(job.preferredPort) || 3000;
|
|
1571
|
+
const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
|
|
1572
|
+
"localhost",
|
|
1573
|
+
"127.0.0.1"
|
|
1574
|
+
);
|
|
1575
|
+
const up = await probeUrl(probe);
|
|
1576
|
+
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1577
|
+
const starting = recentlyLaunched(launchKey);
|
|
1578
|
+
hosts.push({
|
|
1579
|
+
role: job.role,
|
|
1580
|
+
script: job.script,
|
|
1581
|
+
probeUrl: job.probeUrl || probe,
|
|
1582
|
+
up,
|
|
1583
|
+
starting,
|
|
1584
|
+
});
|
|
1585
|
+
if (up) {
|
|
1586
|
+
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1587
|
+
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (starting) continue;
|
|
1591
|
+
const tried = launchedAt.has(launchKey);
|
|
1592
|
+
recordProcessProblem({
|
|
1593
|
+
sandboxId: ws.sandboxId,
|
|
1594
|
+
code: "host_process_down",
|
|
1595
|
+
role: job.role,
|
|
1596
|
+
title: `The ${processRoleLabel(job.role)} is not running (${label})`,
|
|
1597
|
+
message: tried
|
|
1598
|
+
? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
|
|
1599
|
+
: `Nothing is running at ${probe} for "${job.script}".`,
|
|
1600
|
+
resolution:
|
|
1601
|
+
"Fix the error in that terminal, then use Start chat server to try again.",
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
return hosts;
|
|
339
1605
|
}
|
|
340
1606
|
|
|
341
1607
|
async function setupWorkspace(cfg, action) {
|
|
@@ -343,7 +1609,29 @@ async function setupWorkspace(cfg, action) {
|
|
|
343
1609
|
const sandboxId = String(
|
|
344
1610
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
345
1611
|
);
|
|
346
|
-
const
|
|
1612
|
+
const requestedPort = Number(action.payload?.port) || 3100;
|
|
1613
|
+
const reserved = new Set();
|
|
1614
|
+
for (const other of cfg.workspaces || []) {
|
|
1615
|
+
if (other.sandboxId !== sandboxId && other.port) {
|
|
1616
|
+
reserved.add(Number(other.port));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
let port = requestedPort;
|
|
1620
|
+
if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
|
|
1621
|
+
reserved.add(requestedPort);
|
|
1622
|
+
} else {
|
|
1623
|
+
port = await findFreePort(requestedPort, reserved);
|
|
1624
|
+
if (port !== requestedPort) {
|
|
1625
|
+
log(`port ${requestedPort} busy; using ${port} for ai-server`);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
|
|
1629
|
+
const hostAppUrl =
|
|
1630
|
+
typeof action.payload?.hostAppUrl === "string" &&
|
|
1631
|
+
action.payload.hostAppUrl.trim()
|
|
1632
|
+
? action.payload.hostAppUrl.trim().replace(/\/$/, "")
|
|
1633
|
+
: null;
|
|
1634
|
+
|
|
347
1635
|
if (!folderPath || !sandboxId) {
|
|
348
1636
|
throw new Error("setup_workspace requires folderPath and sandboxId");
|
|
349
1637
|
}
|
|
@@ -357,13 +1645,34 @@ async function setupWorkspace(cfg, action) {
|
|
|
357
1645
|
`/api/v1/bridge/machine/sandboxes/${sandboxId}/setup-config?port=${port}`
|
|
358
1646
|
);
|
|
359
1647
|
|
|
1648
|
+
const appName =
|
|
1649
|
+
config.sandbox?.applicationName ||
|
|
1650
|
+
config.sandbox?.name ||
|
|
1651
|
+
"Maintainer Pro App";
|
|
1652
|
+
|
|
1653
|
+
const client = configureClient({
|
|
1654
|
+
dir: resolved,
|
|
1655
|
+
port,
|
|
1656
|
+
appName,
|
|
1657
|
+
mode: clientMode,
|
|
1658
|
+
hostAppUrl,
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const aiOrigin = `http://localhost:${port}`;
|
|
1662
|
+
const corsOrigin = client.corsOrigin || aiOrigin;
|
|
1663
|
+
const appUrl = client.appUrl || corsOrigin;
|
|
1664
|
+
|
|
360
1665
|
const envPath = path.join(resolved, ".env");
|
|
361
1666
|
const envValues = {
|
|
362
1667
|
...config.env,
|
|
363
1668
|
AI_CLI_WORKSPACE: ".",
|
|
364
|
-
AI_SERVER_UI: ".",
|
|
365
|
-
|
|
366
|
-
|
|
1669
|
+
AI_SERVER_UI: client.sameOrigin || client.kind === "empty" ? "." : ".",
|
|
1670
|
+
PORT: String(port),
|
|
1671
|
+
AI_SERVER_URL: aiOrigin,
|
|
1672
|
+
NEXT_PUBLIC_AI_SERVER_URL: aiOrigin,
|
|
1673
|
+
CORS_ORIGIN: corsOrigin,
|
|
1674
|
+
APP_URL: appUrl,
|
|
1675
|
+
AI_SERVER_PRODUCT_DESCRIPTION: appName,
|
|
367
1676
|
};
|
|
368
1677
|
mergeEnvFile(envPath, envValues);
|
|
369
1678
|
|
|
@@ -375,6 +1684,9 @@ async function setupWorkspace(cfg, action) {
|
|
|
375
1684
|
port,
|
|
376
1685
|
sandboxName: config.sandbox?.name,
|
|
377
1686
|
applicationName: config.sandbox?.applicationName,
|
|
1687
|
+
clientKind: client.kind,
|
|
1688
|
+
appUrl,
|
|
1689
|
+
sameOrigin: Boolean(client.sameOrigin),
|
|
378
1690
|
};
|
|
379
1691
|
if (existing >= 0) cfg.workspaces[existing] = entry;
|
|
380
1692
|
else cfg.workspaces.push(entry);
|
|
@@ -382,18 +1694,66 @@ async function setupWorkspace(cfg, action) {
|
|
|
382
1694
|
if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
|
|
383
1695
|
saveConfig(cfg);
|
|
384
1696
|
|
|
1697
|
+
const projectInfo = await inspectProjectWithAiCli(entry, {
|
|
1698
|
+
cfg,
|
|
1699
|
+
extraContext: [
|
|
1700
|
+
`Scaffold kind: ${client.kind}`,
|
|
1701
|
+
`Chat script port: ${port}`,
|
|
1702
|
+
client.notes.join(" "),
|
|
1703
|
+
]
|
|
1704
|
+
.filter(Boolean)
|
|
1705
|
+
.join("\n"),
|
|
1706
|
+
});
|
|
1707
|
+
|
|
385
1708
|
if (!cfg.noAiServer) {
|
|
386
|
-
startAiServerForWorkspace(entry);
|
|
387
|
-
await
|
|
1709
|
+
await startAiServerForWorkspace(entry, { reserved, cfg });
|
|
1710
|
+
await sleep(1500);
|
|
1711
|
+
}
|
|
1712
|
+
const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
|
|
1713
|
+
await sleep(800);
|
|
1714
|
+
await inspectHostJobs(entry);
|
|
1715
|
+
|
|
1716
|
+
const openUrl = client.sameOrigin
|
|
1717
|
+
? `http://localhost:${entry.port}`
|
|
1718
|
+
: entry.appUrl || appUrl;
|
|
1719
|
+
const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
|
|
1720
|
+
if (aiServerUp) {
|
|
1721
|
+
clearProcessProblem(sandboxId, "ai_server_launch", "ai");
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const processIssues = issuesForSandbox(sandboxId).map(
|
|
1725
|
+
({ role: _role, ...issue }) => issue
|
|
1726
|
+
);
|
|
1727
|
+
const warning = processIssues[0]?.message || null;
|
|
1728
|
+
|
|
1729
|
+
for (const note of client.notes) log(note);
|
|
1730
|
+
if (startedHosts.length) {
|
|
1731
|
+
log(`started ${startedHosts.join(" + ")} in separate terminals`);
|
|
1732
|
+
}
|
|
1733
|
+
if (aiServerUp) {
|
|
1734
|
+
log(`ai-server up — open ${openUrl}`);
|
|
1735
|
+
} else if (warning) {
|
|
1736
|
+
log(`process issue: ${warning}`);
|
|
1737
|
+
} else {
|
|
1738
|
+
log("ai-server not reachable yet; it may still be starting");
|
|
388
1739
|
}
|
|
389
1740
|
|
|
390
1741
|
return {
|
|
391
1742
|
sandboxId,
|
|
392
1743
|
folderPath: resolved,
|
|
393
|
-
port,
|
|
394
|
-
appUrl:
|
|
395
|
-
origins:
|
|
1744
|
+
port: entry.port,
|
|
1745
|
+
appUrl: entry.appUrl || appUrl,
|
|
1746
|
+
origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
|
|
396
1747
|
wroteEnv: true,
|
|
1748
|
+
clientKind: client.kind,
|
|
1749
|
+
clientFiles: client.filesWritten,
|
|
1750
|
+
clientNotes: client.notes,
|
|
1751
|
+
aiServerUp,
|
|
1752
|
+
startedHosts,
|
|
1753
|
+
openUrl,
|
|
1754
|
+
processIssues,
|
|
1755
|
+
warning,
|
|
1756
|
+
projectInfo,
|
|
397
1757
|
};
|
|
398
1758
|
}
|
|
399
1759
|
|
|
@@ -410,7 +1770,15 @@ async function runActions(cfg, actions) {
|
|
|
410
1770
|
} else if (action.code === "setup_workspace") {
|
|
411
1771
|
result = await setupWorkspace(cfg, action);
|
|
412
1772
|
} else if (action.code === "recheck") {
|
|
413
|
-
|
|
1773
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1774
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
1775
|
+
const projectInfo = ws
|
|
1776
|
+
? await inspectProjectWithAiCli(ws, { cfg })
|
|
1777
|
+
: null;
|
|
1778
|
+
result = {
|
|
1779
|
+
recheckedAt: new Date().toISOString(),
|
|
1780
|
+
projectInfo,
|
|
1781
|
+
};
|
|
414
1782
|
} else if (action.code === "start_ai_server") {
|
|
415
1783
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
416
1784
|
const ws =
|
|
@@ -426,23 +1794,88 @@ async function runActions(cfg, actions) {
|
|
|
426
1794
|
ok = false;
|
|
427
1795
|
result = { error: "No workspace or --no-ai-server" };
|
|
428
1796
|
} else {
|
|
429
|
-
|
|
430
|
-
|
|
1797
|
+
const reserved = new Set();
|
|
1798
|
+
for (const other of cfg.workspaces || []) {
|
|
1799
|
+
if (other.sandboxId !== ws.sandboxId && other.port) {
|
|
1800
|
+
reserved.add(Number(other.port));
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const problem = issuesForSandbox(ws.sandboxId)
|
|
1804
|
+
.map((issue) => issue.message)
|
|
1805
|
+
.join("\n");
|
|
1806
|
+
const projectInfo = await inspectProjectWithAiCli(ws, {
|
|
1807
|
+
cfg,
|
|
1808
|
+
problem:
|
|
1809
|
+
problem ||
|
|
1810
|
+
"Local processes are not running or the project setup looks incomplete.",
|
|
1811
|
+
});
|
|
1812
|
+
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
1813
|
+
await sleep(1500);
|
|
1814
|
+
const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
|
|
1815
|
+
await sleep(800);
|
|
1816
|
+
await inspectHostJobs(ws);
|
|
1817
|
+
const up = await probeUrl(
|
|
1818
|
+
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
1819
|
+
);
|
|
1820
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1821
|
+
const processIssues = issuesForSandbox(ws.sandboxId).map(
|
|
1822
|
+
({ role: _role, ...issue }) => issue
|
|
1823
|
+
);
|
|
1824
|
+
const warning = processIssues[0]?.message || null;
|
|
431
1825
|
result = {
|
|
432
|
-
up
|
|
433
|
-
|
|
434
|
-
|
|
1826
|
+
up,
|
|
1827
|
+
startedHosts,
|
|
1828
|
+
sandboxId: ws.sandboxId,
|
|
1829
|
+
folderPath: ws.folderPath,
|
|
1830
|
+
port: ws.port,
|
|
1831
|
+
appUrl: ws.appUrl,
|
|
1832
|
+
processIssues,
|
|
1833
|
+
warning,
|
|
1834
|
+
projectInfo,
|
|
435
1835
|
};
|
|
1836
|
+
if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
|
|
1837
|
+
result.error = warning;
|
|
1838
|
+
ok = false;
|
|
1839
|
+
}
|
|
436
1840
|
}
|
|
437
1841
|
} else if (action.code === "refresh_public_url") {
|
|
1842
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1843
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
438
1844
|
result = {
|
|
439
|
-
appUrl:
|
|
1845
|
+
appUrl:
|
|
1846
|
+
ws?.cloudflareUrl ||
|
|
1847
|
+
ws?.appUrl ||
|
|
1848
|
+
process.env.APP_URL ||
|
|
1849
|
+
process.env.PUBLIC_URL ||
|
|
1850
|
+
null,
|
|
440
1851
|
};
|
|
1852
|
+
} else if (action.code === "configure_cloudflare") {
|
|
1853
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1854
|
+
const ws =
|
|
1855
|
+
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
1856
|
+
(action.payload?.folderPath
|
|
1857
|
+
? {
|
|
1858
|
+
sandboxId,
|
|
1859
|
+
folderPath: String(action.payload.folderPath),
|
|
1860
|
+
port: Number(action.payload.port) || 3100,
|
|
1861
|
+
appUrl:
|
|
1862
|
+
typeof action.payload.appUrl === "string"
|
|
1863
|
+
? action.payload.appUrl
|
|
1864
|
+
: null,
|
|
1865
|
+
sandboxName: action.payload.sandboxName,
|
|
1866
|
+
}
|
|
1867
|
+
: null);
|
|
1868
|
+
if (!ws) {
|
|
1869
|
+
ok = false;
|
|
1870
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
1871
|
+
} else {
|
|
1872
|
+
result = await configureCloudflareForWorkspace(ws, cfg);
|
|
1873
|
+
}
|
|
441
1874
|
} else if (action.code === "remove_workspace") {
|
|
442
1875
|
const sandboxId = String(
|
|
443
1876
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
444
1877
|
);
|
|
445
|
-
|
|
1878
|
+
forgetLaunch(sandboxId);
|
|
446
1879
|
cfg.workspaces = (cfg.workspaces || []).filter(
|
|
447
1880
|
(w) => w.sandboxId !== sandboxId
|
|
448
1881
|
);
|
|
@@ -475,10 +1908,53 @@ async function runActions(cfg, actions) {
|
|
|
475
1908
|
}
|
|
476
1909
|
}
|
|
477
1910
|
|
|
478
|
-
function
|
|
1911
|
+
async function collectWorkspaceStates(cfg) {
|
|
1912
|
+
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
|
|
1913
|
+
const localStates = [];
|
|
1914
|
+
for (const ws of cfg.workspaces || []) {
|
|
1915
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1916
|
+
const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
|
|
1917
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1918
|
+
await inspectHostJobs(ws);
|
|
1919
|
+
localStates.push({
|
|
1920
|
+
sandboxId: ws.sandboxId,
|
|
1921
|
+
sandboxName: ws.sandboxName,
|
|
1922
|
+
port: ws.port,
|
|
1923
|
+
folderPath: ws.folderPath,
|
|
1924
|
+
aiServerUp: up,
|
|
1925
|
+
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
1926
|
+
appUrl: ws.appUrl || null,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return localStates;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function sendHeartbeat(cfg, folders, localStates) {
|
|
1933
|
+
return api(
|
|
1934
|
+
cfg.adminUrl,
|
|
1935
|
+
cfg.token,
|
|
1936
|
+
"POST",
|
|
1937
|
+
"/api/v1/bridge/machine/heartbeat",
|
|
1938
|
+
{
|
|
1939
|
+
hostname: os.hostname(),
|
|
1940
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
1941
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
1942
|
+
folders,
|
|
1943
|
+
issues: await buildIssues(cfg, localStates),
|
|
1944
|
+
workspaces: localStates.map((st) => ({
|
|
1945
|
+
sandboxId: st.sandboxId,
|
|
1946
|
+
aiServerUp: st.aiServerUp,
|
|
1947
|
+
port: st.port,
|
|
1948
|
+
appUrl: st.appUrl || undefined,
|
|
1949
|
+
})),
|
|
1950
|
+
}
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function buildIssues(cfg, workspaceStates) {
|
|
479
1955
|
/** @type {Array<Record<string, unknown>>} */
|
|
480
1956
|
const issues = [];
|
|
481
|
-
const cli = detectCliProviders();
|
|
1957
|
+
const cli = await detectCliProviders();
|
|
482
1958
|
if (!cli.length) {
|
|
483
1959
|
issues.push({
|
|
484
1960
|
code: "missing_cli",
|
|
@@ -492,17 +1968,54 @@ function buildIssues(cfg, workspaceStates) {
|
|
|
492
1968
|
});
|
|
493
1969
|
}
|
|
494
1970
|
for (const st of workspaceStates) {
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1971
|
+
if (st.aiServerUp || st.startingAi) continue;
|
|
1972
|
+
const launch = [...processProblems.values()].find(
|
|
1973
|
+
(issue) =>
|
|
1974
|
+
issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
|
|
1975
|
+
);
|
|
1976
|
+
issues.push({
|
|
1977
|
+
code: "ai_server_down",
|
|
1978
|
+
severity: "error",
|
|
1979
|
+
title:
|
|
1980
|
+
launch?.title ||
|
|
1981
|
+
`Local script is not running (${st.sandboxName || "sandbox"})`,
|
|
1982
|
+
message:
|
|
1983
|
+
launch?.message ||
|
|
1984
|
+
`Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
|
|
1985
|
+
resolution:
|
|
1986
|
+
launch?.resolution ||
|
|
1987
|
+
"Read the error in that terminal, then use Start chat server.",
|
|
1988
|
+
actionCode: "start_ai_server",
|
|
1989
|
+
sandboxId: st.sandboxId,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
for (const problem of processProblems.values()) {
|
|
1993
|
+
if (
|
|
1994
|
+
problem.code === "ai_server_launch" ||
|
|
1995
|
+
problem.code === "ai_server_down"
|
|
1996
|
+
) {
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
if (
|
|
2000
|
+
problem.code === "host_process_down" &&
|
|
2001
|
+
[...processProblems.values()].some(
|
|
2002
|
+
(other) =>
|
|
2003
|
+
other.sandboxId === problem.sandboxId &&
|
|
2004
|
+
other.role === problem.role &&
|
|
2005
|
+
other.code === "host_process_launch"
|
|
2006
|
+
)
|
|
2007
|
+
) {
|
|
2008
|
+
continue;
|
|
505
2009
|
}
|
|
2010
|
+
issues.push({
|
|
2011
|
+
code: problem.code,
|
|
2012
|
+
severity: problem.severity,
|
|
2013
|
+
title: problem.title,
|
|
2014
|
+
message: problem.message,
|
|
2015
|
+
resolution: problem.resolution,
|
|
2016
|
+
actionCode: problem.actionCode,
|
|
2017
|
+
sandboxId: problem.sandboxId,
|
|
2018
|
+
});
|
|
506
2019
|
}
|
|
507
2020
|
return issues;
|
|
508
2021
|
}
|
|
@@ -542,6 +2055,8 @@ async function pairFlow(args) {
|
|
|
542
2055
|
saveConfig(cfg);
|
|
543
2056
|
log(`paired as ${result.machine?.name || cfg.machineId}`);
|
|
544
2057
|
log(`config ${configPath()}`);
|
|
2058
|
+
log("Keep this process running.");
|
|
2059
|
+
log("Next: Admin → Bridges → pick a folder → Setup sandbox.");
|
|
545
2060
|
return cfg;
|
|
546
2061
|
}
|
|
547
2062
|
|
|
@@ -584,35 +2099,32 @@ async function main() {
|
|
|
584
2099
|
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
|
|
585
2100
|
const localStates = [];
|
|
586
2101
|
|
|
2102
|
+
const reserved = new Set();
|
|
587
2103
|
for (const ws of cfg.workspaces || []) {
|
|
2104
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
588
2105
|
const up = await probeUrl(
|
|
589
2106
|
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
590
2107
|
);
|
|
2108
|
+
if (!cfg.noAiServer && !up) {
|
|
2109
|
+
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
2110
|
+
} else if (ws.port) {
|
|
2111
|
+
reserved.add(Number(ws.port));
|
|
2112
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2113
|
+
}
|
|
2114
|
+
await ensureHostProcesses(ws, { reserved, cfg });
|
|
2115
|
+
await inspectHostJobs(ws);
|
|
591
2116
|
localStates.push({
|
|
592
2117
|
sandboxId: ws.sandboxId,
|
|
593
2118
|
sandboxName: ws.sandboxName,
|
|
594
2119
|
port: ws.port,
|
|
595
2120
|
folderPath: ws.folderPath,
|
|
596
2121
|
aiServerUp: up,
|
|
2122
|
+
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2123
|
+
appUrl: ws.appUrl || null,
|
|
597
2124
|
});
|
|
598
|
-
if (!cfg.noAiServer && !up) {
|
|
599
|
-
startAiServerForWorkspace(ws);
|
|
600
|
-
}
|
|
601
2125
|
}
|
|
602
2126
|
|
|
603
|
-
const hb = await
|
|
604
|
-
cfg.adminUrl,
|
|
605
|
-
cfg.token,
|
|
606
|
-
"POST",
|
|
607
|
-
"/api/v1/bridge/machine/heartbeat",
|
|
608
|
-
{
|
|
609
|
-
hostname: os.hostname(),
|
|
610
|
-
platform: `${os.platform()}-${os.arch()}`,
|
|
611
|
-
bridgeVersion: PACKAGE_VERSION,
|
|
612
|
-
folders,
|
|
613
|
-
issues: buildIssues(cfg, localStates),
|
|
614
|
-
}
|
|
615
|
-
);
|
|
2127
|
+
const hb = await sendHeartbeat(cfg, folders, localStates);
|
|
616
2128
|
|
|
617
2129
|
// Sync local workspace list from server assignments
|
|
618
2130
|
if (Array.isArray(hb.workspaces)) {
|
|
@@ -641,20 +2153,45 @@ async function main() {
|
|
|
641
2153
|
|
|
642
2154
|
if (Array.isArray(hb.actions) && hb.actions.length > 0) {
|
|
643
2155
|
await runActions(cfg, hb.actions);
|
|
2156
|
+
await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
|
|
644
2157
|
}
|
|
645
2158
|
} catch (err) {
|
|
646
2159
|
warn(err instanceof Error ? err.message : String(err));
|
|
647
2160
|
}
|
|
648
2161
|
};
|
|
649
2162
|
|
|
650
|
-
|
|
2163
|
+
let cycleBusy = false;
|
|
2164
|
+
const runLocked = async (fn) => {
|
|
2165
|
+
if (cycleBusy) return;
|
|
2166
|
+
cycleBusy = true;
|
|
2167
|
+
try {
|
|
2168
|
+
await fn();
|
|
2169
|
+
} finally {
|
|
2170
|
+
cycleBusy = false;
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
await runLocked(tick);
|
|
651
2175
|
setInterval(() => {
|
|
652
|
-
void tick
|
|
2176
|
+
void runLocked(tick);
|
|
653
2177
|
}, HEARTBEAT_MS);
|
|
2178
|
+
setInterval(() => {
|
|
2179
|
+
void runLocked(async () => {
|
|
2180
|
+
const pending = await api(
|
|
2181
|
+
cfg.adminUrl,
|
|
2182
|
+
cfg.token,
|
|
2183
|
+
"GET",
|
|
2184
|
+
"/api/v1/bridge/machine/actions"
|
|
2185
|
+
);
|
|
2186
|
+
if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
|
|
2187
|
+
await runActions(cfg, pending.actions);
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
}, ACTION_POLL_MS);
|
|
654
2191
|
|
|
655
2192
|
const shutdown = () => {
|
|
656
|
-
|
|
657
|
-
|
|
2193
|
+
stopAllCloudflare();
|
|
2194
|
+
log("shutting down (other terminals stay open)");
|
|
658
2195
|
process.exit(0);
|
|
659
2196
|
};
|
|
660
2197
|
process.on("SIGINT", shutdown);
|