@maintainer-pro/ai-bridge 0.1.16 → 0.1.18

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/src/daemon.mjs CHANGED
@@ -23,7 +23,13 @@ import {
23
23
  lookupShareResponse,
24
24
  prepareShareHttpRequest,
25
25
  processShareHttpResponse,
26
+ rewriteMappedLocalUrls,
27
+ rewriteShareRequestBody,
28
+ shareServiceWorkerScript,
29
+ shareShimScript,
30
+ shareTokenRoot,
26
31
  shouldProcessShareResponse,
32
+ shouldRewriteBody,
27
33
  } from "./share-rewrite.mjs";
28
34
  import {
29
35
  canonicalActionCode,
@@ -80,6 +86,34 @@ function shortId(value) {
80
86
  return text.length > 12 ? `${text.slice(0, 8)}…` : text;
81
87
  }
82
88
 
89
+ function sameFolder(a, b) {
90
+ const na = path.resolve(String(a || ""));
91
+ const nb = path.resolve(String(b || ""));
92
+ if (process.platform === "win32") return na.toLowerCase() === nb.toLowerCase();
93
+ return na === nb;
94
+ }
95
+
96
+ function workspaceFolders(ws) {
97
+ const primary = String(ws?.folderPath || "").trim();
98
+ const extra = Array.isArray(ws?.extraFolders) ? ws.extraFolders : [];
99
+ const out = [];
100
+ const seen = new Set();
101
+ for (const raw of [primary, ...extra]) {
102
+ if (!raw || typeof raw !== "string") continue;
103
+ const resolved = path.resolve(raw);
104
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
105
+ if (seen.has(key)) continue;
106
+ seen.add(key);
107
+ out.push(resolved);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function folderForApp(ws, app) {
113
+ if (app?.folderPath) return path.resolve(String(app.folderPath));
114
+ return path.resolve(ws?.folderPath || "");
115
+ }
116
+
83
117
  function actionLabel(action) {
84
118
  const sandbox = action.sandboxId || action.payload?.sandboxId;
85
119
  const folder = action.payload?.folderPath;
@@ -364,7 +398,8 @@ async function detectCliProviders() {
364
398
  }
365
399
 
366
400
  async function resolveWorkspaceHostApps(ws, opts = {}) {
367
- const folder = path.resolve(ws.folderPath || "");
401
+ const folders = workspaceFolders(ws);
402
+ const folder = folders[0] || path.resolve(ws.folderPath || "");
368
403
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
369
404
  const desired = Array.isArray(opts.desired)
370
405
  ? opts.desired
@@ -379,10 +414,11 @@ async function resolveWorkspaceHostApps(ws, opts = {}) {
379
414
  activity(
380
415
  ws.sandboxId,
381
416
  "info",
382
- `detecting ports for ${label} in ${folder} (env first${opts.force ? ", redetect" : ""})`
417
+ `detecting ports for ${label} in ${folders.join(", ") || folder} (env first${opts.force ? ", redetect" : ""})`
383
418
  );
384
419
  const result = await cli.resolveHostApps({
385
420
  workspaceDir: folder,
421
+ workspaceDirs: folders,
386
422
  appName: ws.applicationName || ws.sandboxName,
387
423
  preferredAiPort: Number(ws.port) || 3100,
388
424
  desired: opts.ignoreDesired ? [] : desired,
@@ -391,6 +427,9 @@ async function resolveWorkspaceHostApps(ws, opts = {}) {
391
427
  sandboxId: ws.sandboxId,
392
428
  });
393
429
  const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
430
+ if (opts.unionDetected && previous.length && typeof cli.unionHostApps === "function") {
431
+ result.apps = cli.unionHostApps(previous, result.apps);
432
+ }
394
433
  result.apps = result.apps.map((app) => {
395
434
  const match = previous.find((row) => row && row.id === app.id);
396
435
  return match ? { ...app, host: match.host === true || app.host === true } : app;
@@ -494,6 +533,14 @@ function projectFingerprint(folder) {
494
533
  "next.config.js",
495
534
  "next.config.mjs",
496
535
  "next.config.ts",
536
+ "pom.xml",
537
+ "build.gradle",
538
+ "build.gradle.kts",
539
+ "build.sbt",
540
+ "src/main/resources/application.yml",
541
+ "src/main/resources/application.properties",
542
+ "src/main/resources/application.conf",
543
+ "conf/application.conf",
497
544
  ]) {
498
545
  const file = path.join(resolved, rel);
499
546
  if (!fs.existsSync(file)) continue;
@@ -1440,28 +1487,71 @@ function proxySlugForApp(app) {
1440
1487
  return id || "app";
1441
1488
  }
1442
1489
 
1490
+ function uniqueProxySlugs(apps) {
1491
+ const used = new Set();
1492
+ /** @type {Record<string, string>} */
1493
+ const slugs = {};
1494
+ for (const app of apps || []) {
1495
+ let slug = proxySlugForApp(app);
1496
+ if (!slug || !app?.id) continue;
1497
+ if (used.has(slug)) {
1498
+ const fromId = String(app.id)
1499
+ .trim()
1500
+ .toLowerCase()
1501
+ .replace(/[^a-z0-9_-]+/g, "-")
1502
+ .replace(/^-+|-+$/g, "");
1503
+ slug =
1504
+ fromId && !used.has(fromId)
1505
+ ? fromId
1506
+ : `${slug}-${Number(app.port) || used.size}`;
1507
+ }
1508
+ used.add(slug);
1509
+ slugs[app.id] = slug;
1510
+ }
1511
+ return slugs;
1512
+ }
1513
+
1514
+ function sharePortUrls(ws) {
1515
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
1516
+ /** @type {Record<string, string>} */
1517
+ const out = {};
1518
+ for (const app of apps) {
1519
+ const port = Number(app.port);
1520
+ if (!port) continue;
1521
+ const url = proxyUrlForApp(ws, app);
1522
+ if (url) out[String(port)] = url.replace(/\/$/, "");
1523
+ }
1524
+ return out;
1525
+ }
1526
+
1443
1527
  function applyAssignedProxy(ws, remote, cfg) {
1444
1528
  const proxy =
1445
1529
  remote?.proxy && typeof remote.proxy === "object" ? remote.proxy : null;
1446
1530
  if (!proxy?.token) return;
1447
1531
  const origin = String(proxy.origin || cfg?.adminUrl || "").replace(/\/$/, "");
1448
- const slugs =
1532
+ const assigned =
1449
1533
  proxy.slugs && typeof proxy.slugs === "object" ? proxy.slugs : {};
1534
+ const unique = uniqueProxySlugs(appsFrom(ws, remote));
1535
+ /** @type {Record<string, string>} */
1536
+ const slugs = {};
1450
1537
  /** @type {Record<string, string>} */
1451
1538
  const urls = {};
1452
- const apps = Array.isArray(ws.hostApps)
1453
- ? ws.hostApps
1454
- : Array.isArray(remote.hostApps)
1455
- ? remote.hostApps
1456
- : [];
1539
+ const apps = appsFrom(ws, remote);
1457
1540
  for (const app of apps) {
1458
- const slug = slugs[app.id] || proxySlugForApp(app);
1541
+ const slug = assigned[app.id] || unique[app.id] || proxySlugForApp(app);
1459
1542
  if (!slug || !origin) continue;
1543
+ slugs[app.id] = slug;
1460
1544
  urls[app.id] = `${origin}/p/${proxy.token}/${slug}`;
1461
1545
  }
1462
1546
  ws.proxy = { origin, token: String(proxy.token), slugs, urls };
1463
1547
  }
1464
1548
 
1549
+ function appsFrom(ws, remote) {
1550
+ if (Array.isArray(ws?.hostApps) && ws.hostApps.length) return ws.hostApps;
1551
+ if (Array.isArray(remote?.hostApps)) return remote.hostApps;
1552
+ return [];
1553
+ }
1554
+
1465
1555
  function proxyUrlForApp(ws, app) {
1466
1556
  if (!usesBridgeProxy(app)) return "";
1467
1557
  const urls =
@@ -1478,15 +1568,17 @@ function proxyUrlForApp(ws, app) {
1478
1568
  return `${origin}/p/${token}/${slug}`;
1479
1569
  }
1480
1570
 
1481
- function jobsFromHostApps(ws) {
1571
+ function jobsFromHostApps(ws, filter = {}) {
1482
1572
  const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1483
1573
  const hostId = hostAppOf(ws)?.id;
1484
1574
  return apps
1485
1575
  .filter((app) => app && app.role !== "ai-server")
1576
+ .filter((app) => (filter.appId ? app.id === filter.appId : true))
1486
1577
  .map((app) => {
1487
1578
  const port = Number(app.port) || 3000;
1488
1579
  const command = String(app.startCommand || "").trim();
1489
- const script = command.replace(/^npm\s+run\s+/, "") || "dev";
1580
+ const script = command.replace(/^npm\s+run\s+/, "") || command || "dev";
1581
+ const folder = folderForApp(ws, app);
1490
1582
  return {
1491
1583
  role: isBackendApp(app)
1492
1584
  ? "backend"
@@ -1500,6 +1592,7 @@ function jobsFromHostApps(ws) {
1500
1592
  port,
1501
1593
  probeUrl: `http://127.0.0.1:${port}`,
1502
1594
  appId: app.id,
1595
+ folder,
1503
1596
  };
1504
1597
  });
1505
1598
  }
@@ -2021,7 +2114,7 @@ const embeddedChat = new Map();
2021
2114
  const embeddedChatStarting = new Map();
2022
2115
 
2023
2116
  const PROXY_CHUNK_BYTES = 256 * 1024;
2024
- /** @type {Map<string, import("node:http").ClientRequest>} */
2117
+ /** @type {Map<string, { req: import("node:http").ClientRequest, rewrite?: boolean, chunks?: Buffer[], headers?: Record<string, string>, portUrls?: Record<string, string> } | import("node:http").ClientRequest>} */
2025
2118
  const proxyHttpReqs = new Map();
2026
2119
  /** Reuse sockets to the local app — Next/Vite fetch many files per page. */
2027
2120
  const proxyKeepAliveAgent = new http.Agent({
@@ -2032,6 +2125,8 @@ const proxyKeepAliveAgent = new http.Agent({
2032
2125
  });
2033
2126
  /** @type {Map<string, WebSocket>} */
2034
2127
  const proxyLocalSockets = new Map();
2128
+ /** @type {Map<string, Record<string, string>>} */
2129
+ const proxyWsPortUrls = new Map();
2035
2130
  /** @type {Record<string, unknown> | null} */
2036
2131
  let bridgeCfg = null;
2037
2132
 
@@ -2145,6 +2240,9 @@ function shareProxyContext(msg, ws, appId) {
2145
2240
  ).replace(/\/$/, "");
2146
2241
  const acceptEncoding =
2147
2242
  typeof msg.acceptEncoding === "string" ? msg.acceptEncoding : "";
2243
+ const fromWs = sharePortUrls(ws);
2244
+ const fromMsg =
2245
+ msg?.portUrls && typeof msg.portUrls === "object" ? msg.portUrls : {};
2148
2246
  return {
2149
2247
  path: safeProxyPath(msg.path),
2150
2248
  slug,
@@ -2152,6 +2250,7 @@ function shareProxyContext(msg, ws, appId) {
2152
2250
  aiPublicBase,
2153
2251
  acceptEncoding,
2154
2252
  port: localPortForProxy(ws, appId),
2253
+ portUrls: { ...fromWs, ...fromMsg },
2155
2254
  };
2156
2255
  }
2157
2256
 
@@ -2165,6 +2264,43 @@ function sendProcessedProxyHttp(id, ctx, status, headers, body) {
2165
2264
  replyProxyHttp(id, processed.status, processed.headers, processed.body);
2166
2265
  }
2167
2266
 
2267
+ function publicPathPrefix(publicBase) {
2268
+ try {
2269
+ return new URL(String(publicBase || "")).pathname.replace(/\/$/, "") || "";
2270
+ } catch {
2271
+ return "";
2272
+ }
2273
+ }
2274
+
2275
+ function replyShareInterceptor(id, ctx, path) {
2276
+ const pathname = String(path || "").split("?")[0] || "";
2277
+ if (pathname !== "/__mp/shim.js" && pathname !== "/__mp/sw.js") return false;
2278
+ const tokenRoot = shareTokenRoot(ctx.publicBase) || "/";
2279
+ /** @type {Record<string, string>} */
2280
+ const headers = {
2281
+ "content-type": "application/javascript; charset=utf-8",
2282
+ "cache-control": "private, no-store",
2283
+ };
2284
+ let body = "";
2285
+ if (pathname === "/__mp/sw.js") {
2286
+ headers["service-worker-allowed"] = tokenRoot;
2287
+ body = shareServiceWorkerScript(ctx.portUrls, tokenRoot);
2288
+ } else {
2289
+ body = shareShimScript(publicPathPrefix(ctx.publicBase), ctx.portUrls);
2290
+ }
2291
+ replyProxyHttp(id, 200, headers, body);
2292
+ return true;
2293
+ }
2294
+
2295
+ function destroyProxyHttpReq(entry) {
2296
+ const req = entry?.req || entry;
2297
+ try {
2298
+ req?.destroy?.();
2299
+ } catch {
2300
+ /* ignore */
2301
+ }
2302
+ }
2303
+
2168
2304
  function bridgeEmbedConfigJs(ws) {
2169
2305
  const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
2170
2306
  const aiApp = { id: "ai-server", role: "ai-server" };
@@ -2191,19 +2327,24 @@ async function handleAiProxyHttpFromAdmin(msg, ws) {
2191
2327
  const reqPath = safeProxyPath(msg.path);
2192
2328
  const pathname = reqPath.split("?")[0] || "/";
2193
2329
  const ctx = shareProxyContext(msg, ws, "ai-server");
2330
+ if (replyShareInterceptor(id, ctx, reqPath)) return;
2194
2331
  const prepared = prepareShareHttpRequest(
2195
2332
  proxyReqHeaders(msg.headers),
2196
2333
  ctx.publicBase,
2197
2334
  ctx.acceptEncoding,
2198
- reqPath
2335
+ reqPath,
2336
+ ctx.portUrls
2199
2337
  );
2200
2338
  ctx.acceptEncoding = prepared.acceptEncoding;
2201
2339
  ctx.ifNoneMatch = prepared.ifNoneMatch;
2202
2340
  const headers = prepared.headers;
2203
- const body =
2341
+ const body = rewriteShareRequestBody(
2204
2342
  typeof msg.body === "string" && msg.body
2205
2343
  ? Buffer.from(msg.body, "base64")
2206
- : Buffer.alloc(0);
2344
+ : Buffer.alloc(0),
2345
+ headers,
2346
+ ctx.portUrls
2347
+ );
2207
2348
 
2208
2349
  if (pathname === "/ai-ui.iife.js") {
2209
2350
  const file = findIife();
@@ -2295,11 +2436,7 @@ function handleProxyHttpFromAdmin(msg) {
2295
2436
  }
2296
2437
  const existing = proxyHttpReqs.get(id);
2297
2438
  if (existing) {
2298
- try {
2299
- existing.destroy();
2300
- } catch {
2301
- /* ignore */
2302
- }
2439
+ destroyProxyHttpReq(existing);
2303
2440
  proxyHttpReqs.delete(id);
2304
2441
  }
2305
2442
  const port = localPortForProxy(ws, appId);
@@ -2315,11 +2452,13 @@ function handleProxyHttpFromAdmin(msg) {
2315
2452
  const path = safeProxyPath(msg.path);
2316
2453
  const ctx = shareProxyContext(msg, ws, appId);
2317
2454
  ctx.port = port;
2455
+ if (replyShareInterceptor(id, ctx, path)) return;
2318
2456
  const prepared = prepareShareHttpRequest(
2319
2457
  proxyReqHeaders(msg.headers),
2320
2458
  ctx.publicBase,
2321
2459
  ctx.acceptEncoding,
2322
- path
2460
+ path,
2461
+ ctx.portUrls
2323
2462
  );
2324
2463
  ctx.acceptEncoding = prepared.acceptEncoding;
2325
2464
  ctx.ifNoneMatch = prepared.ifNoneMatch;
@@ -2467,20 +2606,59 @@ function handleProxyHttpFromAdmin(msg) {
2467
2606
  error: err instanceof Error ? err.message : String(err),
2468
2607
  });
2469
2608
  });
2470
- proxyHttpReqs.set(id, req);
2471
- if (initialBody?.length) req.write(initialBody);
2472
- if (endAfter) req.end();
2609
+ proxyHttpReqs.set(id, {
2610
+ req,
2611
+ rewrite:
2612
+ shouldRewriteBody(headers) &&
2613
+ ctx.portUrls &&
2614
+ Object.keys(ctx.portUrls).length > 0,
2615
+ chunks: [],
2616
+ headers,
2617
+ portUrls: ctx.portUrls,
2618
+ });
2619
+ const entry = proxyHttpReqs.get(id);
2620
+ if (entry.rewrite) {
2621
+ if (initialBody?.length) entry.chunks.push(initialBody);
2622
+ if (endAfter) {
2623
+ const body = rewriteShareRequestBody(
2624
+ Buffer.concat(entry.chunks),
2625
+ headers,
2626
+ ctx.portUrls
2627
+ );
2628
+ if (body?.length) req.write(body);
2629
+ req.end();
2630
+ }
2631
+ } else {
2632
+ if (initialBody?.length) req.write(initialBody);
2633
+ if (endAfter) req.end();
2634
+ }
2473
2635
  };
2474
2636
  connect();
2475
2637
  }
2476
2638
 
2477
2639
  function handleProxyHttpBodyFromAdmin(msg) {
2478
2640
  const id = typeof msg.id === "string" ? msg.id : "";
2479
- const req = proxyHttpReqs.get(id);
2480
- if (!req) return;
2481
- if (typeof msg.data === "string" && msg.data) {
2482
- req.write(Buffer.from(msg.data, "base64"));
2641
+ const entry = proxyHttpReqs.get(id);
2642
+ if (!entry) return;
2643
+ const req = entry.req || entry;
2644
+ const chunk =
2645
+ typeof msg.data === "string" && msg.data
2646
+ ? Buffer.from(msg.data, "base64")
2647
+ : null;
2648
+ if (entry.rewrite) {
2649
+ if (chunk?.length) entry.chunks.push(chunk);
2650
+ if (msg.eof === true) {
2651
+ const body = rewriteShareRequestBody(
2652
+ Buffer.concat(entry.chunks || []),
2653
+ entry.headers,
2654
+ entry.portUrls
2655
+ );
2656
+ if (body?.length) req.write(body);
2657
+ req.end();
2658
+ }
2659
+ return;
2483
2660
  }
2661
+ if (chunk?.length) req.write(chunk);
2484
2662
  if (msg.eof === true) req.end();
2485
2663
  }
2486
2664
 
@@ -2509,10 +2687,14 @@ function attachProxyLocalWs(id, socket) {
2509
2687
  socket.addEventListener("message", (event) => {
2510
2688
  try {
2511
2689
  if (typeof event.data === "string") {
2690
+ const portUrls = proxyWsPortUrls.get(id);
2691
+ const data = portUrls
2692
+ ? rewriteMappedLocalUrls(event.data, portUrls)
2693
+ : event.data;
2512
2694
  bridgeSend({
2513
2695
  type: "proxy.ws.frame",
2514
2696
  id,
2515
- data: event.data,
2697
+ data,
2516
2698
  binary: false,
2517
2699
  });
2518
2700
  return;
@@ -2532,6 +2714,7 @@ function attachProxyLocalWs(id, socket) {
2532
2714
  });
2533
2715
  socket.addEventListener("close", (event) => {
2534
2716
  proxyLocalSockets.delete(id);
2717
+ proxyWsPortUrls.delete(id);
2535
2718
  bridgeSend({
2536
2719
  type: "proxy.ws.close",
2537
2720
  id,
@@ -2616,6 +2799,9 @@ function handleProxyWsOpenFromAdmin(msg) {
2616
2799
  }
2617
2800
  const path = safeProxyPath(msg.path);
2618
2801
  const protocols = wsProtocolsFromMsg(msg);
2802
+ const fromMsg =
2803
+ msg?.portUrls && typeof msg.portUrls === "object" ? msg.portUrls : {};
2804
+ proxyWsPortUrls.set(id, { ...sharePortUrls(ws), ...fromMsg });
2619
2805
  if (isAiServerAppId(appId, ws)) {
2620
2806
  void (async () => {
2621
2807
  const embedded = await ensureEmbeddedChat(ws);
@@ -2644,7 +2830,9 @@ function handleProxyWsFrameFromAdmin(msg) {
2644
2830
  if (msg.binary === true) {
2645
2831
  socket.send(Buffer.from(String(msg.data || ""), "base64"));
2646
2832
  } else {
2647
- socket.send(String(msg.data || ""));
2833
+ const portUrls = proxyWsPortUrls.get(id);
2834
+ const data = String(msg.data || "");
2835
+ socket.send(portUrls ? rewriteMappedLocalUrls(data, portUrls) : data);
2648
2836
  }
2649
2837
  } catch {
2650
2838
  /* ignore */
@@ -2656,6 +2844,7 @@ function handleProxyWsCloseFromAdmin(msg) {
2656
2844
  const socket = proxyLocalSockets.get(id);
2657
2845
  if (!socket) return;
2658
2846
  proxyLocalSockets.delete(id);
2847
+ proxyWsPortUrls.delete(id);
2659
2848
  try {
2660
2849
  socket.close(
2661
2850
  typeof msg.code === "number" ? msg.code : 1000,
@@ -3089,6 +3278,10 @@ async function openInNewTerminal(opts) {
3089
3278
  }
3090
3279
 
3091
3280
  function commandWithPort(job, scripts, port) {
3281
+ const command = String(job.command || "");
3282
+ if (!/^(npm|pnpm|yarn|npx)\s/i.test(command)) {
3283
+ return command;
3284
+ }
3092
3285
  const raw = String(scripts?.[job.script] || "");
3093
3286
  if (
3094
3287
  job.role === "ui" ||
@@ -3096,9 +3289,9 @@ function commandWithPort(job, scripts, port) {
3096
3289
  isUiCommand(raw) ||
3097
3290
  /--port\b/i.test(raw)
3098
3291
  ) {
3099
- return `${job.command} -- --port ${port}`;
3292
+ return `${command} -- --port ${port}`;
3100
3293
  }
3101
- return job.command;
3294
+ return command;
3102
3295
  }
3103
3296
 
3104
3297
  async function stopEmbeddedChat(sandboxId) {
@@ -3551,9 +3744,8 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3551
3744
  await ensureHostProcesses(ws, {
3552
3745
  reserved,
3553
3746
  cfg,
3554
- onlyRoles: [app.role === "custom" ? "app" : app.role],
3555
3747
  force: true,
3556
- plannedJobs: jobsFromHostApps(ws),
3748
+ plannedJobs: jobsFromHostApps(ws, { appId: app.id }),
3557
3749
  });
3558
3750
  }
3559
3751
  const status = await reconcileWorkspacePresence(ws, cfg, {
@@ -3665,32 +3857,21 @@ async function ensureHostProcesses(ws, opts = {}) {
3665
3857
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
3666
3858
  const cfg = opts.cfg || null;
3667
3859
  const folder = path.resolve(ws.folderPath);
3668
- const scripts = readPackageJson(folder)?.scripts || {};
3669
3860
  const jobs = Array.isArray(opts.plannedJobs)
3670
3861
  ? opts.plannedJobs
3671
- : planHostJobs(
3672
- folder,
3673
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3674
- ws.projectInfo
3675
- );
3862
+ : jobsFromHostApps(ws).length
3863
+ ? jobsFromHostApps(ws)
3864
+ : planHostJobs(
3865
+ folder,
3866
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3867
+ ws.projectInfo
3868
+ );
3676
3869
  const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
3677
3870
  const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
3678
3871
  const started = [];
3679
3872
  let persisted = false;
3680
3873
  const label = ws.sandboxName || "this sandbox";
3681
3874
 
3682
- if (jobs.length > 0 && !fs.existsSync(folder)) {
3683
- recordProcessProblem({
3684
- sandboxId: ws.sandboxId,
3685
- code: "host_process_launch",
3686
- role: "app",
3687
- title: `Could not start the app (${label})`,
3688
- message: `The project folder is missing: ${folder}`,
3689
- resolution: "Attach the folder again from Local setup.",
3690
- });
3691
- return started;
3692
- }
3693
-
3694
3875
  log(
3695
3876
  `start hosts ${label}: ${
3696
3877
  jobs.length
@@ -3704,6 +3885,19 @@ async function ensureHostProcesses(ws, opts = {}) {
3704
3885
 
3705
3886
  for (const job of jobs) {
3706
3887
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
3888
+ const jobFolder = path.resolve(job.folder || folder);
3889
+ if (!fs.existsSync(jobFolder)) {
3890
+ recordProcessProblem({
3891
+ sandboxId: ws.sandboxId,
3892
+ code: "host_process_launch",
3893
+ role: job.role,
3894
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
3895
+ message: `The project folder is missing: ${jobFolder}`,
3896
+ resolution: "Attach the folder again from Local setup.",
3897
+ });
3898
+ continue;
3899
+ }
3900
+ const scripts = readPackageJson(jobFolder)?.scripts || {};
3707
3901
  const preferred = Number(job.port || job.preferredPort) || 3000;
3708
3902
  const probe = job.port
3709
3903
  ? `http://127.0.0.1:${job.port}`
@@ -3715,7 +3909,7 @@ async function ensureHostProcesses(ws, opts = {}) {
3715
3909
  log(`${job.role} already running at ${job.probeUrl || probe}`);
3716
3910
  continue;
3717
3911
  }
3718
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
3912
+ const launchKey = `${ws.sandboxId}:${jobFolder}:${job.command || job.script}:${preferred}`;
3719
3913
  if (!opts.force && recentlyLaunched(launchKey)) {
3720
3914
  reserved.add(preferred);
3721
3915
  log(`start ${job.role} skip ${label}: launched recently`);
@@ -3747,13 +3941,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3747
3941
  }
3748
3942
 
3749
3943
  const needsInstall =
3750
- fs.existsSync(path.join(folder, "package.json")) &&
3751
- !fs.existsSync(path.join(folder, "node_modules"));
3944
+ fs.existsSync(path.join(jobFolder, "package.json")) &&
3945
+ !fs.existsSync(path.join(jobFolder, "node_modules"));
3752
3946
  const run = commandWithPort(job, scripts, port);
3753
3947
  const command = needsInstall ? `npm install && ${run}` : run;
3754
3948
  const opened = await openInNewTerminal({
3755
3949
  title: `MP-${job.role}-${port}`,
3756
- folder,
3950
+ folder: jobFolder,
3757
3951
  command,
3758
3952
  env: { PORT: String(port), ...extraEnv },
3759
3953
  launchKey,
@@ -3812,7 +4006,7 @@ async function inspectHostJobs(ws) {
3812
4006
  const preferred = Number(job.preferredPort) || 3000;
3813
4007
  const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
3814
4008
  const up = await probeLoopbackUrl(probe);
3815
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
4009
+ const launchKey = `${ws.sandboxId}:${job.folder || folder}:${job.command || job.script}:${preferred}`;
3816
4010
  const starting = recentlyLaunched(launchKey);
3817
4011
  hosts.push({
3818
4012
  role: job.role,
@@ -3904,6 +4098,79 @@ async function setupWorkspace(cfg, action) {
3904
4098
  config.sandbox?.name ||
3905
4099
  "Maintainer Pro App";
3906
4100
 
4101
+ const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
4102
+ ? config.aiIgnorePaths
4103
+ : [];
4104
+ const addFolder = Boolean(action.payload?.addFolder);
4105
+ cfg.workspaces = cfg.workspaces || [];
4106
+ const existingIdx = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
4107
+ const existingWs = existingIdx >= 0 ? cfg.workspaces[existingIdx] : null;
4108
+
4109
+ if (addFolder && existingWs?.folderPath) {
4110
+ const extra = Array.isArray(existingWs.extraFolders)
4111
+ ? [...existingWs.extraFolders]
4112
+ : [];
4113
+ if (
4114
+ !sameFolder(existingWs.folderPath, resolved) &&
4115
+ !extra.some((folder) => sameFolder(folder, resolved))
4116
+ ) {
4117
+ extra.push(resolved);
4118
+ }
4119
+ existingWs.extraFolders = extra;
4120
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
4121
+ persistWorkspaceEntry(cfg, existingWs);
4122
+ const ports = await resolveWorkspaceHostApps(existingWs, {
4123
+ cfg,
4124
+ allowAi: false,
4125
+ ignoreDesired: true,
4126
+ unionDetected: true,
4127
+ force: true,
4128
+ });
4129
+ const needsReview =
4130
+ Boolean(ports.confused) ||
4131
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server");
4132
+ if (needsReview) {
4133
+ activity(
4134
+ sandboxId,
4135
+ "warn",
4136
+ "Review the suggested apps and ports in Maintainer Pro before Start Apps."
4137
+ );
4138
+ }
4139
+ await inspectHostJobs(existingWs);
4140
+ const host = workspaceHostReport(existingWs);
4141
+ const aiServerUp = await isChatServerOnPort(existingWs.port);
4142
+ log(`setup added folder ${resolved} sandbox=${shortId(sandboxId)}`);
4143
+ return {
4144
+ sandboxId,
4145
+ folderPath: existingWs.folderPath,
4146
+ extraFolders: extra,
4147
+ addFolder: true,
4148
+ addedFolder: resolved,
4149
+ port: existingWs.port,
4150
+ appUrl: host.appUrl || existingWs.appUrl || null,
4151
+ origins: host.origins,
4152
+ wroteEnv: false,
4153
+ clientKind: existingWs.clientKind || "skip",
4154
+ clientFiles: [],
4155
+ clientNotes: [`Added folder ${resolved}`],
4156
+ aiServerUp,
4157
+ startedHosts: [],
4158
+ openUrl: existingWs.appUrl || `http://localhost:${existingWs.port}`,
4159
+ processIssues: issuesForSandbox(sandboxId).map(
4160
+ ({ role: _role, ...issue }) => issue
4161
+ ),
4162
+ warning: needsReview
4163
+ ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4164
+ : "Folder attached. Review apps if needed, then use Start Apps.",
4165
+ waitingForStart: !aiServerUp,
4166
+ needsReview,
4167
+ hostApps: ports.apps || existingWs.hostApps || [],
4168
+ reasons: ports.reasons || [],
4169
+ projectInfo: existingWs.projectInfo || null,
4170
+ ignorePaths: access.ignorePaths,
4171
+ };
4172
+ }
4173
+
3907
4174
  const client = configureClient({
3908
4175
  dir: resolved,
3909
4176
  port,
@@ -3916,23 +4183,22 @@ async function setupWorkspace(cfg, action) {
3916
4183
  const corsOrigin = client.corsOrigin || aiOrigin;
3917
4184
  const appUrl = client.appUrl || corsOrigin;
3918
4185
 
3919
- const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3920
- ? config.aiIgnorePaths
3921
- : [];
3922
4186
  const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
3923
4187
 
3924
- cfg.workspaces = cfg.workspaces || [];
3925
- const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
3926
4188
  const entry = {
3927
4189
  sandboxId,
3928
4190
  folderPath: resolved,
4191
+ extraFolders: Array.isArray(existingWs?.extraFolders)
4192
+ ? existingWs.extraFolders.filter((folder) => !sameFolder(folder, resolved))
4193
+ : [],
3929
4194
  port,
3930
4195
  sandboxName: config.sandbox?.name,
3931
4196
  applicationName: config.sandbox?.applicationName,
3932
4197
  clientKind: client.kind,
3933
4198
  appUrl,
3934
4199
  sameOrigin: Boolean(client.sameOrigin),
3935
- appsRequested: false,
4200
+ appsRequested: existingWs?.appsRequested || false,
4201
+ hostApps: existingWs?.hostApps,
3936
4202
  store: {
3937
4203
  serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
3938
4204
  clientKey: String(
@@ -3942,7 +4208,7 @@ async function setupWorkspace(cfg, action) {
3942
4208
  ).trim(),
3943
4209
  },
3944
4210
  };
3945
- if (existing >= 0) cfg.workspaces[existing] = entry;
4211
+ if (existingIdx >= 0) cfg.workspaces[existingIdx] = { ...existingWs, ...entry };
3946
4212
  else cfg.workspaces.push(entry);
3947
4213
  saveConfig(cfg);
3948
4214
 
@@ -3994,7 +4260,9 @@ async function setupWorkspace(cfg, action) {
3994
4260
  const host = workspaceHostReport(entry);
3995
4261
  return {
3996
4262
  sandboxId,
3997
- folderPath: resolved,
4263
+ folderPath: entry.folderPath,
4264
+ extraFolders: entry.extraFolders || [],
4265
+ addFolder: false,
3998
4266
  port: entry.port,
3999
4267
  appUrl: host.appUrl || entry.appUrl || appUrl,
4000
4268
  origins: host.origins.length
@@ -4019,7 +4287,8 @@ async function setupWorkspace(cfg, action) {
4019
4287
  }
4020
4288
 
4021
4289
  async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4022
- const folder = path.resolve(ws.folderPath || "");
4290
+ const folders = workspaceFolders(ws);
4291
+ const folder = folders[0] || path.resolve(ws.folderPath || "");
4023
4292
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
4024
4293
  activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
4025
4294
  try {
@@ -4031,6 +4300,7 @@ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4031
4300
  }
4032
4301
  const proposal = await cli.proposeHostAppsFromConfig({
4033
4302
  workspaceDir: folder,
4303
+ workspaceDirs: folders,
4034
4304
  appName: ws.applicationName || ws.sandboxName,
4035
4305
  preferredAiPort: Number(ws.port) || 3100,
4036
4306
  allowAi: opts.allowAi !== false,
@@ -4145,12 +4415,17 @@ async function runActions(cfg, actions) {
4145
4415
  partnerIgnorePaths,
4146
4416
  ws.sandboxId
4147
4417
  );
4418
+ const extras = workspaceFolders(ws).slice(1).map((folder) =>
4419
+ applyAccessPolicy(folder, partnerIgnorePaths, ws.sandboxId)
4420
+ );
4148
4421
  log(
4149
- `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
4422
+ `access policy synced for ${workspaceFolders(ws).join(", ")} (${access.ignorePaths.length} ignore rules)`
4150
4423
  );
4151
4424
  result = {
4152
4425
  folderPath: path.resolve(ws.folderPath),
4426
+ extraFolders: workspaceFolders(ws).slice(1),
4153
4427
  ignorePaths: access.ignorePaths,
4428
+ extraIgnorePaths: extras.map((row) => row.ignorePaths),
4154
4429
  syncedAt: new Date().toISOString(),
4155
4430
  };
4156
4431
  }
@@ -4386,13 +4661,46 @@ async function runActions(cfg, actions) {
4386
4661
  const sandboxId = String(
4387
4662
  action.sandboxId || action.payload?.sandboxId || ""
4388
4663
  );
4389
- await forgetLaunch(sandboxId);
4390
- cfg.workspaces = (cfg.workspaces || []).filter(
4391
- (w) => w.sandboxId !== sandboxId
4392
- );
4393
- saveConfig(cfg);
4394
- result = { removedSandboxId: sandboxId };
4395
- log(`${label} removed workspace`);
4664
+ if (action.payload?.keepWorkspace) {
4665
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4666
+ if (!ws) {
4667
+ ok = false;
4668
+ result = { error: "No folder is attached for this sandbox" };
4669
+ } else {
4670
+ const removed = String(action.payload.folderPath || "");
4671
+ const remaining = Array.isArray(action.payload.extraFolders)
4672
+ ? action.payload.extraFolders
4673
+ : workspaceFolders(ws).filter((folder) => !sameFolder(folder, removed));
4674
+ const primary =
4675
+ String(action.payload.folderPathRemaining || remaining[0] || ws.folderPath);
4676
+ ws.folderPath = primary;
4677
+ ws.extraFolders = remaining.filter((folder) => !sameFolder(folder, primary));
4678
+ ws.hostApps = (Array.isArray(ws.hostApps) ? ws.hostApps : []).filter(
4679
+ (app) =>
4680
+ !app ||
4681
+ app.role === "ai-server" ||
4682
+ !app.folderPath ||
4683
+ !sameFolder(app.folderPath, removed)
4684
+ );
4685
+ persistWorkspaceEntry(cfg, ws);
4686
+ result = {
4687
+ sandboxId,
4688
+ folderPath: ws.folderPath,
4689
+ extraFolders: ws.extraFolders,
4690
+ port: ws.port,
4691
+ hostApps: ws.hostApps,
4692
+ };
4693
+ log(`${label} removed extra folder ${removed}`);
4694
+ }
4695
+ } else {
4696
+ await forgetLaunch(sandboxId);
4697
+ cfg.workspaces = (cfg.workspaces || []).filter(
4698
+ (w) => w.sandboxId !== sandboxId
4699
+ );
4700
+ saveConfig(cfg);
4701
+ result = { removedSandboxId: sandboxId };
4702
+ log(`${label} removed workspace`);
4703
+ }
4396
4704
  } else {
4397
4705
  ok = false;
4398
4706
  result = { error: `Unknown action ${action.code}` };