@maintainer-pro/ai-bridge 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/daemon.mjs +320 -144
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
package/src/daemon.mjs CHANGED
@@ -1075,7 +1075,9 @@ function runLauncher(command, args, extra = {}) {
1075
1075
  async function openInNewTerminal(opts) {
1076
1076
  const { title, folder, command, env = {}, launchKey } = opts;
1077
1077
  if (launchKey) {
1078
- if (recentlyLaunched(launchKey)) return { ok: true, skipped: true };
1078
+ if (!opts.force && recentlyLaunched(launchKey)) {
1079
+ return { ok: true, skipped: true };
1080
+ }
1079
1081
  launchedAt.set(launchKey, Date.now());
1080
1082
  }
1081
1083
  if (!fs.existsSync(folder)) {
@@ -1276,188 +1278,350 @@ function sleep(ms) {
1276
1278
  return new Promise((resolve) => setTimeout(resolve, ms));
1277
1279
  }
1278
1280
 
1279
- /** @type {Map<string, { child: import("node:child_process").ChildProcess, url: string | null, localUrl: string }>} */
1281
+ /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
1280
1282
  const cloudflareTunnels = new Map();
1281
1283
 
1284
+ function stopAllCloudflare() {
1285
+ for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1286
+ }
1287
+
1288
+ function parseTryCloudflareUrl(text) {
1289
+ const match = String(text || "").match(
1290
+ /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1291
+ );
1292
+ return match ? match[0].replace(/\/$/, "") : null;
1293
+ }
1294
+
1295
+ function killProcessesByCommand(fragment) {
1296
+ if (!fragment) return Promise.resolve();
1297
+ return new Promise((resolve) => {
1298
+ const done = () => resolve();
1299
+ if (process.platform === "win32") {
1300
+ const escaped = String(fragment).replace(/'/g, "''");
1301
+ const child = spawn(
1302
+ "powershell.exe",
1303
+ [
1304
+ "-NoProfile",
1305
+ "-Command",
1306
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escaped}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
1307
+ ],
1308
+ { windowsHide: true, stdio: "ignore" }
1309
+ );
1310
+ child.on("exit", done);
1311
+ child.on("error", done);
1312
+ setTimeout(done, 8000);
1313
+ return;
1314
+ }
1315
+ const child = spawn("pkill", ["-f", String(fragment)], { stdio: "ignore" });
1316
+ child.on("exit", done);
1317
+ child.on("error", done);
1318
+ setTimeout(done, 4000);
1319
+ });
1320
+ }
1321
+
1322
+ function killPort(port) {
1323
+ const n = Number(port);
1324
+ if (!n) return Promise.resolve();
1325
+ return new Promise((resolve) => {
1326
+ const done = () => resolve();
1327
+ if (process.platform === "win32") {
1328
+ const child = spawn(
1329
+ "powershell.exe",
1330
+ [
1331
+ "-NoProfile",
1332
+ "-Command",
1333
+ `$ErrorActionPreference='SilentlyContinue'; $pids=@(); $pids += Get-NetTCPConnection -LocalPort ${n} -State Listen | Select-Object -ExpandProperty OwningProcess; if (-not $pids) { netstat -ano | Select-String ':${n}\\s' | ForEach-Object { if ($_ -match 'LISTENING\\s+(\\d+)') { $pids += [int]$Matches[1] } } }; $pids | Sort-Object -Unique | Where-Object { $_ -gt 0 -and $_ -ne ${process.pid} } | ForEach-Object { Stop-Process -Id $_ -Force }`,
1334
+ ],
1335
+ { windowsHide: true, stdio: "ignore" }
1336
+ );
1337
+ child.on("exit", done);
1338
+ child.on("error", done);
1339
+ setTimeout(done, 8000);
1340
+ return;
1341
+ }
1342
+ const child = spawn(
1343
+ "sh",
1344
+ ["-c", `pids=$(lsof -ti tcp:${n} 2>/dev/null); [ -n "$pids" ] && kill $pids`],
1345
+ { stdio: "ignore" }
1346
+ );
1347
+ child.on("exit", done);
1348
+ child.on("error", done);
1349
+ setTimeout(done, 4000);
1350
+ });
1351
+ }
1352
+
1282
1353
  function stopCloudflare(sandboxId) {
1283
1354
  const row = cloudflareTunnels.get(sandboxId);
1284
- if (row?.child && !row.child.killed) {
1355
+ const files = row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [];
1356
+ cloudflareTunnels.delete(sandboxId);
1357
+ return Promise.all(files.map((file) => killProcessesByCommand(file)));
1358
+ }
1359
+
1360
+ function forgetProcessLaunches(sandboxId) {
1361
+ for (const key of [...launchedAt.keys()]) {
1362
+ if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
1363
+ launchedAt.delete(key);
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ async function stopWorkspaceApps(ws) {
1369
+ const folder = path.resolve(ws.folderPath || "");
1370
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1371
+ const ports = new Set();
1372
+ if (ws.port) ports.add(Number(ws.port));
1373
+ for (const job of jobs) {
1374
+ if (job.preferredPort) ports.add(Number(job.preferredPort));
1375
+ }
1376
+ if (ws.appUrl && isLocalAppUrl(ws.appUrl)) {
1285
1377
  try {
1286
- row.child.kill();
1378
+ const port = Number(new URL(ws.appUrl).port);
1379
+ if (port) ports.add(port);
1287
1380
  } catch {
1288
1381
  /* ignore */
1289
1382
  }
1290
1383
  }
1291
- cloudflareTunnels.delete(sandboxId);
1292
- }
1293
-
1294
- function stopAllCloudflare() {
1295
- for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
1384
+ log(`stopping local apps on ports ${[...ports].join(", ") || "(none)"}`);
1385
+ for (const port of ports) {
1386
+ await killPort(port);
1387
+ }
1388
+ forgetProcessLaunches(ws.sandboxId);
1389
+ await sleep(400);
1296
1390
  }
1297
1391
 
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}`;
1392
+ async function waitUntilReachable(url, timeoutMs, label) {
1393
+ const start = Date.now();
1394
+ while (Date.now() - start < timeoutMs) {
1395
+ if (await probeUrl(url)) return true;
1396
+ await sleep(600);
1397
+ }
1398
+ throw new Error(`${label} did not become reachable at ${url}`);
1301
1399
  }
1302
1400
 
1303
- function parseTryCloudflareUrl(text) {
1304
- const match = String(text || "").match(
1305
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
1401
+ async function waitForUrlInFile(file, timeoutMs = 90_000) {
1402
+ const start = Date.now();
1403
+ while (Date.now() - start < timeoutMs) {
1404
+ if (fs.existsSync(file)) {
1405
+ const url = parseTryCloudflareUrl(fs.readFileSync(file, "utf8"));
1406
+ if (url) return url;
1407
+ }
1408
+ await sleep(500);
1409
+ }
1410
+ throw new Error(
1411
+ `Cloudflare did not publish a URL in time (${path.basename(file)}). Check that terminal.`
1306
1412
  );
1307
- return match ? match[0].replace(/\/$/, "") : null;
1308
1413
  }
1309
1414
 
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
- });
1415
+ function cloudflaredCommand(localUrl, logFile) {
1416
+ const run = `npx --yes cloudflared tunnel --url ${localUrl} --no-autoupdate`;
1417
+ if (process.platform === "win32") {
1418
+ const dest = String(logFile).replace(/'/g, "''");
1419
+ return `powershell -NoProfile -Command "${run} 2>&1 | Tee-Object -FilePath '${dest}'"`;
1420
+ }
1421
+ return `${run} 2>&1 | tee ${JSON.stringify(logFile)}`;
1422
+ }
1326
1423
 
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
- )
1424
+ async function startCloudflareTerminal(ws, role, localUrl) {
1425
+ const folder = path.resolve(ws.folderPath);
1426
+ const logDir = path.join(folder, ".maintainer-pro");
1427
+ fs.mkdirSync(logDir, { recursive: true });
1428
+ const logFile = path.join(
1429
+ logDir,
1430
+ `cf-${String(ws.sandboxId).slice(0, 8)}-${role}.log`
1333
1431
  );
1432
+ try {
1433
+ fs.unlinkSync(logFile);
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
1437
+ const opened = await openInNewTerminal({
1438
+ title: `MP-cf-${role}`,
1439
+ folder,
1440
+ command: cloudflaredCommand(localUrl, logFile),
1441
+ launchKey: `${ws.sandboxId}:cf:${role}`,
1442
+ force: true,
1443
+ });
1444
+ if (!opened.ok) {
1445
+ throw new Error(opened.error || `Could not open a Cloudflare terminal for ${role}`);
1446
+ }
1447
+ const publicUrl = await waitForUrlInFile(logFile);
1448
+ return { role, localUrl, publicUrl, logFile };
1334
1449
  }
1335
1450
 
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);
1368
- });
1451
+ function uiPublicEnv(tunnels) {
1452
+ /** @type {Record<string, string>} */
1453
+ const env = {};
1454
+ if (tunnels.ai) {
1455
+ env.AI_SERVER_URL = tunnels.ai;
1456
+ env.NEXT_PUBLIC_AI_SERVER_URL = tunnels.ai;
1457
+ env.VITE_AI_SERVER_URL = tunnels.ai;
1458
+ }
1459
+ if (tunnels.backend) {
1460
+ env.API_URL = tunnels.backend;
1461
+ env.API_BASE_URL = tunnels.backend;
1462
+ env.VITE_API_URL = tunnels.backend;
1463
+ env.VITE_API_BASE_URL = tunnels.backend;
1464
+ env.NEXT_PUBLIC_API_URL = tunnels.backend;
1465
+ env.NEXT_PUBLIC_API_BASE_URL = tunnels.backend;
1466
+ env.BACKEND_URL = tunnels.backend;
1467
+ }
1468
+ if (tunnels.ui) {
1469
+ env.APP_URL = tunnels.ui;
1470
+ env.PUBLIC_URL = tunnels.ui;
1471
+ env.CORS_ORIGIN = tunnels.ui;
1472
+ } else if (tunnels.ai) {
1473
+ env.CORS_ORIGIN = tunnels.ai;
1474
+ }
1475
+ return env;
1476
+ }
1477
+
1478
+ function writeTunnelEnv(ws, tunnels) {
1479
+ const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1480
+ if (!folder || !fs.existsSync(folder)) return;
1481
+ const env = uiPublicEnv(tunnels);
1482
+ const lines = Object.entries(tunnels)
1483
+ .filter(([, url]) => url)
1484
+ .map(([role, url]) => `${role}=${url}`);
1485
+ fs.writeFileSync(
1486
+ path.join(folder, ".cloudflare-tunnel-url"),
1487
+ `${lines.join("\n")}\n`,
1488
+ "utf8"
1489
+ );
1490
+ const envPath = path.join(folder, ".env");
1491
+ if (Object.keys(env).length) mergeEnvFile(envPath, env);
1492
+ const localEnv = {};
1493
+ if (env.NEXT_PUBLIC_AI_SERVER_URL) {
1494
+ localEnv.NEXT_PUBLIC_AI_SERVER_URL = env.NEXT_PUBLIC_AI_SERVER_URL;
1495
+ }
1496
+ if (env.NEXT_PUBLIC_API_URL) {
1497
+ localEnv.NEXT_PUBLIC_API_URL = env.NEXT_PUBLIC_API_URL;
1498
+ }
1499
+ if (Object.keys(localEnv).length) {
1500
+ mergeEnvFile(path.join(folder, ".env.local"), localEnv);
1501
+ }
1369
1502
  }
1370
1503
 
1371
1504
  async function configureCloudflareForWorkspace(ws, cfg) {
1372
1505
  const sandboxId = ws.sandboxId;
1373
1506
  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
- };
1507
+ const folder = path.resolve(ws.folderPath || "");
1508
+ const reserved = new Set();
1509
+ for (const other of cfg.workspaces || []) {
1510
+ if (other.sandboxId !== sandboxId && other.port) {
1511
+ reserved.add(Number(other.port));
1512
+ }
1386
1513
  }
1387
- if (existing) stopCloudflare(sandboxId);
1388
1514
 
1389
- log(`starting Cloudflare tunnel for ${label} ${localUrl}`);
1390
- let child;
1515
+ log(`Cloudflare setup for ${label}: stop apps, then tunnel ai-server/backend before UI`);
1391
1516
  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}`
1517
+ await stopCloudflare(sandboxId);
1518
+ await stopWorkspaceApps(ws);
1519
+
1520
+ if (!cfg.noAiServer) {
1521
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1522
+ await waitUntilReachable(
1523
+ `http://127.0.0.1:${ws.port}/embed-config.js`,
1524
+ 45_000,
1525
+ "Chat script"
1407
1526
  );
1408
1527
  }
1409
1528
 
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);
1414
- });
1529
+ const jobs = planHostJobs(folder, null, ws.projectInfo);
1530
+ const backendJob = jobs.find((job) => job.role === "backend");
1531
+ const uiJob = jobs.find((job) => job.role === "ui" || job.role === "app");
1415
1532
 
1416
- const publicUrl = await waitForTunnelUrl(child);
1417
- cloudflareTunnels.set(sandboxId, { child, url: publicUrl, localUrl });
1418
- child.unref();
1533
+ if (backendJob) {
1534
+ await ensureHostProcesses(ws, {
1535
+ reserved,
1536
+ cfg,
1537
+ onlyRoles: ["backend"],
1538
+ force: true,
1539
+ });
1540
+ const backendPort = Number(backendJob.preferredPort) || 4100;
1541
+ await waitUntilReachable(
1542
+ `http://127.0.0.1:${backendPort}`,
1543
+ 45_000,
1544
+ "Backend"
1545
+ );
1546
+ }
1419
1547
 
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"
1548
+ /** @type {Record<string, string>} */
1549
+ const tunnels = {};
1550
+ /** @type {Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }>} */
1551
+ const started = [];
1552
+
1553
+ const aiLocal = `http://127.0.0.1:${Number(ws.port) || 3100}`;
1554
+ const aiTunnel = await startCloudflareTerminal(ws, "ai", aiLocal);
1555
+ tunnels.ai = aiTunnel.publicUrl;
1556
+ started.push(aiTunnel);
1557
+ log(`Cloudflare chat script: ${aiTunnel.publicUrl}`);
1558
+
1559
+ if (backendJob) {
1560
+ const backendLocal = `http://127.0.0.1:${Number(backendJob.preferredPort) || 4100}`;
1561
+ const backendTunnel = await startCloudflareTerminal(ws, "backend", backendLocal);
1562
+ tunnels.backend = backendTunnel.publicUrl;
1563
+ started.push(backendTunnel);
1564
+ log(`Cloudflare backend: ${backendTunnel.publicUrl}`);
1565
+ }
1566
+
1567
+ writeTunnelEnv(ws, tunnels);
1568
+ const uiEnv = uiPublicEnv(tunnels);
1569
+
1570
+ if (uiJob) {
1571
+ await ensureHostProcesses(ws, {
1572
+ reserved,
1573
+ cfg,
1574
+ onlyRoles: ["ui", "app"],
1575
+ extraEnv: uiEnv,
1576
+ force: true,
1577
+ });
1578
+ const uiPort = Number(uiJob.preferredPort) || 5173;
1579
+ await waitUntilReachable(`http://127.0.0.1:${uiPort}`, 60_000, "App UI");
1580
+ const uiTunnel = await startCloudflareTerminal(
1581
+ ws,
1582
+ "ui",
1583
+ `http://127.0.0.1:${uiPort}`
1428
1584
  );
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);
1585
+ tunnels.ui = uiTunnel.publicUrl;
1586
+ started.push(uiTunnel);
1587
+ log(`Cloudflare UI: ${uiTunnel.publicUrl}`);
1588
+ writeTunnelEnv(ws, tunnels);
1589
+ launchedAt.delete(`${sandboxId}:${folder}:ai`);
1590
+ await killPort(ws.port);
1591
+ await sleep(1500);
1592
+ await startAiServerForWorkspace(ws, { reserved, cfg });
1440
1593
  }
1594
+
1595
+ const appUrl = tunnels.ui || tunnels.ai;
1596
+ ws.cloudflareUrl = appUrl;
1597
+ ws.cloudflare = tunnels;
1598
+ ws.appUrl = appUrl;
1441
1599
  persistWorkspaceEntry(cfg, ws);
1600
+ cloudflareTunnels.set(sandboxId, { tunnels: started });
1442
1601
  clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
1443
- log(`Cloudflare URL for ${label}: ${publicUrl}`);
1602
+
1444
1603
  return {
1445
1604
  sandboxId,
1446
1605
  folderPath: ws.folderPath,
1447
- appUrl: publicUrl,
1448
- origins: [publicUrl],
1449
- localUrl,
1606
+ appUrl,
1607
+ origins: Object.values(tunnels).filter(Boolean),
1608
+ tunnels,
1450
1609
  cloudflare: true,
1451
1610
  reused: false,
1452
1611
  };
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;
1612
+ } catch (err) {
1613
+ const message = err instanceof Error ? err.message : String(err);
1614
+ recordProcessProblem({
1615
+ sandboxId,
1616
+ code: "cloudflare_launch",
1617
+ role: "tunnel",
1618
+ title: `Could not start Cloudflare (${label})`,
1619
+ message,
1620
+ resolution:
1621
+ "Install cloudflared or allow npx to download it, then try Share with Cloudflare again.",
1622
+ actionCode: "configure_cloudflare",
1623
+ });
1624
+ throw err;
1461
1625
  }
1462
1626
  }
1463
1627
 
@@ -1466,7 +1630,13 @@ async function ensureHostProcesses(ws, opts = {}) {
1466
1630
  const cfg = opts.cfg || null;
1467
1631
  const folder = path.resolve(ws.folderPath);
1468
1632
  const scripts = readPackageJson(folder)?.scripts || {};
1469
- const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1633
+ const jobs = planHostJobs(
1634
+ folder,
1635
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1636
+ ws.projectInfo
1637
+ );
1638
+ const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
1639
+ const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
1470
1640
  const started = [];
1471
1641
  let persisted = false;
1472
1642
  const label = ws.sandboxName || "this sandbox";
@@ -1484,6 +1654,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1484
1654
  }
1485
1655
 
1486
1656
  for (const job of jobs) {
1657
+ if (onlyRoles && !onlyRoles.has(job.role)) continue;
1487
1658
  const preferred = Number(job.preferredPort) || 3000;
1488
1659
  const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1489
1660
  "localhost",
@@ -1497,7 +1668,7 @@ async function ensureHostProcesses(ws, opts = {}) {
1497
1668
  continue;
1498
1669
  }
1499
1670
  const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
1500
- if (recentlyLaunched(launchKey)) {
1671
+ if (!opts.force && recentlyLaunched(launchKey)) {
1501
1672
  reserved.add(preferred);
1502
1673
  continue;
1503
1674
  }
@@ -1531,8 +1702,9 @@ async function ensureHostProcesses(ws, opts = {}) {
1531
1702
  title: `MP-${job.role}-${port}`,
1532
1703
  folder,
1533
1704
  command,
1534
- env: { PORT: String(port) },
1705
+ env: { PORT: String(port), ...extraEnv },
1535
1706
  launchKey,
1707
+ force: Boolean(opts.force),
1536
1708
  });
1537
1709
  if (opened.skipped) continue;
1538
1710
  if (!opened.ok) {
@@ -1563,7 +1735,11 @@ async function ensureHostProcesses(ws, opts = {}) {
1563
1735
 
1564
1736
  async function inspectHostJobs(ws) {
1565
1737
  const folder = path.resolve(ws.folderPath || "");
1566
- const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
1738
+ const jobs = planHostJobs(
1739
+ folder,
1740
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
1741
+ ws.projectInfo
1742
+ );
1567
1743
  const label = ws.sandboxName || "this sandbox";
1568
1744
  const hosts = [];
1569
1745
  for (const job of jobs) {