@maintainer-pro/ai-bridge 0.1.16 → 0.1.19

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,25 @@ 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
+ ctx.publicBase
2348
+ );
2207
2349
 
2208
2350
  if (pathname === "/ai-ui.iife.js") {
2209
2351
  const file = findIife();
@@ -2295,11 +2437,7 @@ function handleProxyHttpFromAdmin(msg) {
2295
2437
  }
2296
2438
  const existing = proxyHttpReqs.get(id);
2297
2439
  if (existing) {
2298
- try {
2299
- existing.destroy();
2300
- } catch {
2301
- /* ignore */
2302
- }
2440
+ destroyProxyHttpReq(existing);
2303
2441
  proxyHttpReqs.delete(id);
2304
2442
  }
2305
2443
  const port = localPortForProxy(ws, appId);
@@ -2315,11 +2453,13 @@ function handleProxyHttpFromAdmin(msg) {
2315
2453
  const path = safeProxyPath(msg.path);
2316
2454
  const ctx = shareProxyContext(msg, ws, appId);
2317
2455
  ctx.port = port;
2456
+ if (replyShareInterceptor(id, ctx, path)) return;
2318
2457
  const prepared = prepareShareHttpRequest(
2319
2458
  proxyReqHeaders(msg.headers),
2320
2459
  ctx.publicBase,
2321
2460
  ctx.acceptEncoding,
2322
- path
2461
+ path,
2462
+ ctx.portUrls
2323
2463
  );
2324
2464
  ctx.acceptEncoding = prepared.acceptEncoding;
2325
2465
  ctx.ifNoneMatch = prepared.ifNoneMatch;
@@ -2467,20 +2607,62 @@ function handleProxyHttpFromAdmin(msg) {
2467
2607
  error: err instanceof Error ? err.message : String(err),
2468
2608
  });
2469
2609
  });
2470
- proxyHttpReqs.set(id, req);
2471
- if (initialBody?.length) req.write(initialBody);
2472
- if (endAfter) req.end();
2610
+ proxyHttpReqs.set(id, {
2611
+ req,
2612
+ rewrite:
2613
+ shouldRewriteBody(headers) &&
2614
+ ctx.portUrls &&
2615
+ Object.keys(ctx.portUrls).length > 0,
2616
+ chunks: [],
2617
+ headers,
2618
+ portUrls: ctx.portUrls,
2619
+ publicBase: ctx.publicBase,
2620
+ });
2621
+ const entry = proxyHttpReqs.get(id);
2622
+ if (entry.rewrite) {
2623
+ if (initialBody?.length) entry.chunks.push(initialBody);
2624
+ if (endAfter) {
2625
+ const body = rewriteShareRequestBody(
2626
+ Buffer.concat(entry.chunks),
2627
+ headers,
2628
+ ctx.portUrls,
2629
+ ctx.publicBase
2630
+ );
2631
+ if (body?.length) req.write(body);
2632
+ req.end();
2633
+ }
2634
+ } else {
2635
+ if (initialBody?.length) req.write(initialBody);
2636
+ if (endAfter) req.end();
2637
+ }
2473
2638
  };
2474
2639
  connect();
2475
2640
  }
2476
2641
 
2477
2642
  function handleProxyHttpBodyFromAdmin(msg) {
2478
2643
  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"));
2644
+ const entry = proxyHttpReqs.get(id);
2645
+ if (!entry) return;
2646
+ const req = entry.req || entry;
2647
+ const chunk =
2648
+ typeof msg.data === "string" && msg.data
2649
+ ? Buffer.from(msg.data, "base64")
2650
+ : null;
2651
+ if (entry.rewrite) {
2652
+ if (chunk?.length) entry.chunks.push(chunk);
2653
+ if (msg.eof === true) {
2654
+ const body = rewriteShareRequestBody(
2655
+ Buffer.concat(entry.chunks || []),
2656
+ entry.headers,
2657
+ entry.portUrls,
2658
+ entry.publicBase
2659
+ );
2660
+ if (body?.length) req.write(body);
2661
+ req.end();
2662
+ }
2663
+ return;
2483
2664
  }
2665
+ if (chunk?.length) req.write(chunk);
2484
2666
  if (msg.eof === true) req.end();
2485
2667
  }
2486
2668
 
@@ -2509,10 +2691,14 @@ function attachProxyLocalWs(id, socket) {
2509
2691
  socket.addEventListener("message", (event) => {
2510
2692
  try {
2511
2693
  if (typeof event.data === "string") {
2694
+ const portUrls = proxyWsPortUrls.get(id);
2695
+ const data = portUrls
2696
+ ? rewriteMappedLocalUrls(event.data, portUrls)
2697
+ : event.data;
2512
2698
  bridgeSend({
2513
2699
  type: "proxy.ws.frame",
2514
2700
  id,
2515
- data: event.data,
2701
+ data,
2516
2702
  binary: false,
2517
2703
  });
2518
2704
  return;
@@ -2532,6 +2718,7 @@ function attachProxyLocalWs(id, socket) {
2532
2718
  });
2533
2719
  socket.addEventListener("close", (event) => {
2534
2720
  proxyLocalSockets.delete(id);
2721
+ proxyWsPortUrls.delete(id);
2535
2722
  bridgeSend({
2536
2723
  type: "proxy.ws.close",
2537
2724
  id,
@@ -2616,6 +2803,9 @@ function handleProxyWsOpenFromAdmin(msg) {
2616
2803
  }
2617
2804
  const path = safeProxyPath(msg.path);
2618
2805
  const protocols = wsProtocolsFromMsg(msg);
2806
+ const fromMsg =
2807
+ msg?.portUrls && typeof msg.portUrls === "object" ? msg.portUrls : {};
2808
+ proxyWsPortUrls.set(id, { ...sharePortUrls(ws), ...fromMsg });
2619
2809
  if (isAiServerAppId(appId, ws)) {
2620
2810
  void (async () => {
2621
2811
  const embedded = await ensureEmbeddedChat(ws);
@@ -2644,7 +2834,9 @@ function handleProxyWsFrameFromAdmin(msg) {
2644
2834
  if (msg.binary === true) {
2645
2835
  socket.send(Buffer.from(String(msg.data || ""), "base64"));
2646
2836
  } else {
2647
- socket.send(String(msg.data || ""));
2837
+ const portUrls = proxyWsPortUrls.get(id);
2838
+ const data = String(msg.data || "");
2839
+ socket.send(portUrls ? rewriteMappedLocalUrls(data, portUrls) : data);
2648
2840
  }
2649
2841
  } catch {
2650
2842
  /* ignore */
@@ -2656,6 +2848,7 @@ function handleProxyWsCloseFromAdmin(msg) {
2656
2848
  const socket = proxyLocalSockets.get(id);
2657
2849
  if (!socket) return;
2658
2850
  proxyLocalSockets.delete(id);
2851
+ proxyWsPortUrls.delete(id);
2659
2852
  try {
2660
2853
  socket.close(
2661
2854
  typeof msg.code === "number" ? msg.code : 1000,
@@ -3089,6 +3282,10 @@ async function openInNewTerminal(opts) {
3089
3282
  }
3090
3283
 
3091
3284
  function commandWithPort(job, scripts, port) {
3285
+ const command = String(job.command || "");
3286
+ if (!/^(npm|pnpm|yarn|npx)\s/i.test(command)) {
3287
+ return command;
3288
+ }
3092
3289
  const raw = String(scripts?.[job.script] || "");
3093
3290
  if (
3094
3291
  job.role === "ui" ||
@@ -3096,9 +3293,9 @@ function commandWithPort(job, scripts, port) {
3096
3293
  isUiCommand(raw) ||
3097
3294
  /--port\b/i.test(raw)
3098
3295
  ) {
3099
- return `${job.command} -- --port ${port}`;
3296
+ return `${command} -- --port ${port}`;
3100
3297
  }
3101
- return job.command;
3298
+ return command;
3102
3299
  }
3103
3300
 
3104
3301
  async function stopEmbeddedChat(sandboxId) {
@@ -3551,9 +3748,8 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3551
3748
  await ensureHostProcesses(ws, {
3552
3749
  reserved,
3553
3750
  cfg,
3554
- onlyRoles: [app.role === "custom" ? "app" : app.role],
3555
3751
  force: true,
3556
- plannedJobs: jobsFromHostApps(ws),
3752
+ plannedJobs: jobsFromHostApps(ws, { appId: app.id }),
3557
3753
  });
3558
3754
  }
3559
3755
  const status = await reconcileWorkspacePresence(ws, cfg, {
@@ -3665,32 +3861,21 @@ async function ensureHostProcesses(ws, opts = {}) {
3665
3861
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
3666
3862
  const cfg = opts.cfg || null;
3667
3863
  const folder = path.resolve(ws.folderPath);
3668
- const scripts = readPackageJson(folder)?.scripts || {};
3669
3864
  const jobs = Array.isArray(opts.plannedJobs)
3670
3865
  ? opts.plannedJobs
3671
- : planHostJobs(
3672
- folder,
3673
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3674
- ws.projectInfo
3675
- );
3866
+ : jobsFromHostApps(ws).length
3867
+ ? jobsFromHostApps(ws)
3868
+ : planHostJobs(
3869
+ folder,
3870
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3871
+ ws.projectInfo
3872
+ );
3676
3873
  const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
3677
3874
  const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
3678
3875
  const started = [];
3679
3876
  let persisted = false;
3680
3877
  const label = ws.sandboxName || "this sandbox";
3681
3878
 
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
3879
  log(
3695
3880
  `start hosts ${label}: ${
3696
3881
  jobs.length
@@ -3704,6 +3889,19 @@ async function ensureHostProcesses(ws, opts = {}) {
3704
3889
 
3705
3890
  for (const job of jobs) {
3706
3891
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
3892
+ const jobFolder = path.resolve(job.folder || folder);
3893
+ if (!fs.existsSync(jobFolder)) {
3894
+ recordProcessProblem({
3895
+ sandboxId: ws.sandboxId,
3896
+ code: "host_process_launch",
3897
+ role: job.role,
3898
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
3899
+ message: `The project folder is missing: ${jobFolder}`,
3900
+ resolution: "Attach the folder again from Local setup.",
3901
+ });
3902
+ continue;
3903
+ }
3904
+ const scripts = readPackageJson(jobFolder)?.scripts || {};
3707
3905
  const preferred = Number(job.port || job.preferredPort) || 3000;
3708
3906
  const probe = job.port
3709
3907
  ? `http://127.0.0.1:${job.port}`
@@ -3715,7 +3913,7 @@ async function ensureHostProcesses(ws, opts = {}) {
3715
3913
  log(`${job.role} already running at ${job.probeUrl || probe}`);
3716
3914
  continue;
3717
3915
  }
3718
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
3916
+ const launchKey = `${ws.sandboxId}:${jobFolder}:${job.command || job.script}:${preferred}`;
3719
3917
  if (!opts.force && recentlyLaunched(launchKey)) {
3720
3918
  reserved.add(preferred);
3721
3919
  log(`start ${job.role} skip ${label}: launched recently`);
@@ -3747,13 +3945,13 @@ async function ensureHostProcesses(ws, opts = {}) {
3747
3945
  }
3748
3946
 
3749
3947
  const needsInstall =
3750
- fs.existsSync(path.join(folder, "package.json")) &&
3751
- !fs.existsSync(path.join(folder, "node_modules"));
3948
+ fs.existsSync(path.join(jobFolder, "package.json")) &&
3949
+ !fs.existsSync(path.join(jobFolder, "node_modules"));
3752
3950
  const run = commandWithPort(job, scripts, port);
3753
3951
  const command = needsInstall ? `npm install && ${run}` : run;
3754
3952
  const opened = await openInNewTerminal({
3755
3953
  title: `MP-${job.role}-${port}`,
3756
- folder,
3954
+ folder: jobFolder,
3757
3955
  command,
3758
3956
  env: { PORT: String(port), ...extraEnv },
3759
3957
  launchKey,
@@ -3812,7 +4010,7 @@ async function inspectHostJobs(ws) {
3812
4010
  const preferred = Number(job.preferredPort) || 3000;
3813
4011
  const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
3814
4012
  const up = await probeLoopbackUrl(probe);
3815
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
4013
+ const launchKey = `${ws.sandboxId}:${job.folder || folder}:${job.command || job.script}:${preferred}`;
3816
4014
  const starting = recentlyLaunched(launchKey);
3817
4015
  hosts.push({
3818
4016
  role: job.role,
@@ -3904,6 +4102,79 @@ async function setupWorkspace(cfg, action) {
3904
4102
  config.sandbox?.name ||
3905
4103
  "Maintainer Pro App";
3906
4104
 
4105
+ const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
4106
+ ? config.aiIgnorePaths
4107
+ : [];
4108
+ const addFolder = Boolean(action.payload?.addFolder);
4109
+ cfg.workspaces = cfg.workspaces || [];
4110
+ const existingIdx = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
4111
+ const existingWs = existingIdx >= 0 ? cfg.workspaces[existingIdx] : null;
4112
+
4113
+ if (addFolder && existingWs?.folderPath) {
4114
+ const extra = Array.isArray(existingWs.extraFolders)
4115
+ ? [...existingWs.extraFolders]
4116
+ : [];
4117
+ if (
4118
+ !sameFolder(existingWs.folderPath, resolved) &&
4119
+ !extra.some((folder) => sameFolder(folder, resolved))
4120
+ ) {
4121
+ extra.push(resolved);
4122
+ }
4123
+ existingWs.extraFolders = extra;
4124
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
4125
+ persistWorkspaceEntry(cfg, existingWs);
4126
+ const ports = await resolveWorkspaceHostApps(existingWs, {
4127
+ cfg,
4128
+ allowAi: false,
4129
+ ignoreDesired: true,
4130
+ unionDetected: true,
4131
+ force: true,
4132
+ });
4133
+ const needsReview =
4134
+ Boolean(ports.confused) ||
4135
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server");
4136
+ if (needsReview) {
4137
+ activity(
4138
+ sandboxId,
4139
+ "warn",
4140
+ "Review the suggested apps and ports in Maintainer Pro before Start Apps."
4141
+ );
4142
+ }
4143
+ await inspectHostJobs(existingWs);
4144
+ const host = workspaceHostReport(existingWs);
4145
+ const aiServerUp = await isChatServerOnPort(existingWs.port);
4146
+ log(`setup added folder ${resolved} sandbox=${shortId(sandboxId)}`);
4147
+ return {
4148
+ sandboxId,
4149
+ folderPath: existingWs.folderPath,
4150
+ extraFolders: extra,
4151
+ addFolder: true,
4152
+ addedFolder: resolved,
4153
+ port: existingWs.port,
4154
+ appUrl: host.appUrl || existingWs.appUrl || null,
4155
+ origins: host.origins,
4156
+ wroteEnv: false,
4157
+ clientKind: existingWs.clientKind || "skip",
4158
+ clientFiles: [],
4159
+ clientNotes: [`Added folder ${resolved}`],
4160
+ aiServerUp,
4161
+ startedHosts: [],
4162
+ openUrl: existingWs.appUrl || `http://localhost:${existingWs.port}`,
4163
+ processIssues: issuesForSandbox(sandboxId).map(
4164
+ ({ role: _role, ...issue }) => issue
4165
+ ),
4166
+ warning: needsReview
4167
+ ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4168
+ : "Folder attached. Review apps if needed, then use Start Apps.",
4169
+ waitingForStart: !aiServerUp,
4170
+ needsReview,
4171
+ hostApps: ports.apps || existingWs.hostApps || [],
4172
+ reasons: ports.reasons || [],
4173
+ projectInfo: existingWs.projectInfo || null,
4174
+ ignorePaths: access.ignorePaths,
4175
+ };
4176
+ }
4177
+
3907
4178
  const client = configureClient({
3908
4179
  dir: resolved,
3909
4180
  port,
@@ -3916,23 +4187,22 @@ async function setupWorkspace(cfg, action) {
3916
4187
  const corsOrigin = client.corsOrigin || aiOrigin;
3917
4188
  const appUrl = client.appUrl || corsOrigin;
3918
4189
 
3919
- const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
3920
- ? config.aiIgnorePaths
3921
- : [];
3922
4190
  const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
3923
4191
 
3924
- cfg.workspaces = cfg.workspaces || [];
3925
- const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
3926
4192
  const entry = {
3927
4193
  sandboxId,
3928
4194
  folderPath: resolved,
4195
+ extraFolders: Array.isArray(existingWs?.extraFolders)
4196
+ ? existingWs.extraFolders.filter((folder) => !sameFolder(folder, resolved))
4197
+ : [],
3929
4198
  port,
3930
4199
  sandboxName: config.sandbox?.name,
3931
4200
  applicationName: config.sandbox?.applicationName,
3932
4201
  clientKind: client.kind,
3933
4202
  appUrl,
3934
4203
  sameOrigin: Boolean(client.sameOrigin),
3935
- appsRequested: false,
4204
+ appsRequested: existingWs?.appsRequested || false,
4205
+ hostApps: existingWs?.hostApps,
3936
4206
  store: {
3937
4207
  serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
3938
4208
  clientKey: String(
@@ -3942,7 +4212,7 @@ async function setupWorkspace(cfg, action) {
3942
4212
  ).trim(),
3943
4213
  },
3944
4214
  };
3945
- if (existing >= 0) cfg.workspaces[existing] = entry;
4215
+ if (existingIdx >= 0) cfg.workspaces[existingIdx] = { ...existingWs, ...entry };
3946
4216
  else cfg.workspaces.push(entry);
3947
4217
  saveConfig(cfg);
3948
4218
 
@@ -3994,7 +4264,9 @@ async function setupWorkspace(cfg, action) {
3994
4264
  const host = workspaceHostReport(entry);
3995
4265
  return {
3996
4266
  sandboxId,
3997
- folderPath: resolved,
4267
+ folderPath: entry.folderPath,
4268
+ extraFolders: entry.extraFolders || [],
4269
+ addFolder: false,
3998
4270
  port: entry.port,
3999
4271
  appUrl: host.appUrl || entry.appUrl || appUrl,
4000
4272
  origins: host.origins.length
@@ -4019,7 +4291,8 @@ async function setupWorkspace(cfg, action) {
4019
4291
  }
4020
4292
 
4021
4293
  async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4022
- const folder = path.resolve(ws.folderPath || "");
4294
+ const folders = workspaceFolders(ws);
4295
+ const folder = folders[0] || path.resolve(ws.folderPath || "");
4023
4296
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
4024
4297
  activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
4025
4298
  try {
@@ -4031,6 +4304,7 @@ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4031
4304
  }
4032
4305
  const proposal = await cli.proposeHostAppsFromConfig({
4033
4306
  workspaceDir: folder,
4307
+ workspaceDirs: folders,
4034
4308
  appName: ws.applicationName || ws.sandboxName,
4035
4309
  preferredAiPort: Number(ws.port) || 3100,
4036
4310
  allowAi: opts.allowAi !== false,
@@ -4145,12 +4419,17 @@ async function runActions(cfg, actions) {
4145
4419
  partnerIgnorePaths,
4146
4420
  ws.sandboxId
4147
4421
  );
4422
+ const extras = workspaceFolders(ws).slice(1).map((folder) =>
4423
+ applyAccessPolicy(folder, partnerIgnorePaths, ws.sandboxId)
4424
+ );
4148
4425
  log(
4149
- `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
4426
+ `access policy synced for ${workspaceFolders(ws).join(", ")} (${access.ignorePaths.length} ignore rules)`
4150
4427
  );
4151
4428
  result = {
4152
4429
  folderPath: path.resolve(ws.folderPath),
4430
+ extraFolders: workspaceFolders(ws).slice(1),
4153
4431
  ignorePaths: access.ignorePaths,
4432
+ extraIgnorePaths: extras.map((row) => row.ignorePaths),
4154
4433
  syncedAt: new Date().toISOString(),
4155
4434
  };
4156
4435
  }
@@ -4386,13 +4665,46 @@ async function runActions(cfg, actions) {
4386
4665
  const sandboxId = String(
4387
4666
  action.sandboxId || action.payload?.sandboxId || ""
4388
4667
  );
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`);
4668
+ if (action.payload?.keepWorkspace) {
4669
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4670
+ if (!ws) {
4671
+ ok = false;
4672
+ result = { error: "No folder is attached for this sandbox" };
4673
+ } else {
4674
+ const removed = String(action.payload.folderPath || "");
4675
+ const remaining = Array.isArray(action.payload.extraFolders)
4676
+ ? action.payload.extraFolders
4677
+ : workspaceFolders(ws).filter((folder) => !sameFolder(folder, removed));
4678
+ const primary =
4679
+ String(action.payload.folderPathRemaining || remaining[0] || ws.folderPath);
4680
+ ws.folderPath = primary;
4681
+ ws.extraFolders = remaining.filter((folder) => !sameFolder(folder, primary));
4682
+ ws.hostApps = (Array.isArray(ws.hostApps) ? ws.hostApps : []).filter(
4683
+ (app) =>
4684
+ !app ||
4685
+ app.role === "ai-server" ||
4686
+ !app.folderPath ||
4687
+ !sameFolder(app.folderPath, removed)
4688
+ );
4689
+ persistWorkspaceEntry(cfg, ws);
4690
+ result = {
4691
+ sandboxId,
4692
+ folderPath: ws.folderPath,
4693
+ extraFolders: ws.extraFolders,
4694
+ port: ws.port,
4695
+ hostApps: ws.hostApps,
4696
+ };
4697
+ log(`${label} removed extra folder ${removed}`);
4698
+ }
4699
+ } else {
4700
+ await forgetLaunch(sandboxId);
4701
+ cfg.workspaces = (cfg.workspaces || []).filter(
4702
+ (w) => w.sandboxId !== sandboxId
4703
+ );
4704
+ saveConfig(cfg);
4705
+ result = { removedSandboxId: sandboxId };
4706
+ log(`${label} removed workspace`);
4707
+ }
4396
4708
  } else {
4397
4709
  ok = false;
4398
4710
  result = { error: `Unknown action ${action.code}` };