@maintainer-pro/ai-cli 0.1.5 → 0.1.6

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/dist/index.js CHANGED
@@ -109,7 +109,7 @@ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths,
109
109
  );
110
110
  const attachmentsSection = wrapSection(
111
111
  PROMPT_SECTION.attachments,
112
- attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files):
112
+ attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files; they are stored under ~/.maintainer-pro for this project, not in the host app, and you are allowed to read them):
113
113
  ${attachmentPaths.map((p) => `- ${p}`).join("\n")}` : "",
114
114
  "user attachments"
115
115
  );
@@ -380,8 +380,8 @@ async function resolveCommandPath(command) {
380
380
  }
381
381
  const result = await execa2("which", [command], { reject: false });
382
382
  if (result.exitCode !== 0) return null;
383
- const path6 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
384
- return path6 || null;
383
+ const path9 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
384
+ return path9 || null;
385
385
  } catch {
386
386
  return null;
387
387
  }
@@ -528,7 +528,7 @@ Only use those runtime tools for live state. Never invent runtime tools.` : "";
528
528
  - You may only read or edit files inside the current project workspace directory.
529
529
  - Never read, write, move, or delete files outside that workspace.
530
530
  - Never touch paths that match this ignore list (relative to the workspace):
531
- ${input.ignorePaths.length ? input.ignorePaths.map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)"}
531
+ ${(input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).length ? (input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)"}
532
532
  - If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.` : "";
533
533
  return `You are a helpful product assistant for an app the user is looking at right now.
534
534
 
@@ -654,10 +654,11 @@ function isPathAllowed(workspaceDir, targetPath, ignorePaths) {
654
654
  return !isIgnoredRelative(rel, ignorePaths);
655
655
  }
656
656
  function formatAccessPolicyPromptSection(input) {
657
- const ignores = input.ignorePaths.length ? input.ignorePaths.map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)";
657
+ const ignores = input.ignorePaths.filter((p) => !p.startsWith("!")).length ? input.ignorePaths.filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)";
658
658
  return `## File access boundaries (mandatory)
659
659
  - You may only read or edit files inside the current project workspace directory.
660
- - Never read, write, move, or delete files outside that workspace.
660
+ - Never write, move, or delete files outside that workspace.
661
+ - You may read screenshot files listed in the attachments section (those live in the Maintainer Pro data folder, not in the project).
661
662
  - Never touch paths that match this ignore list (relative to the workspace):
662
663
  ${ignores}
663
664
  - If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.`;
@@ -741,13 +742,73 @@ function previewText(text, max = 160) {
741
742
  }
742
743
 
743
744
  // src/http/attachments.ts
744
- import fs2 from "fs/promises";
745
+ import fs3 from "fs/promises";
746
+ import path4 from "path";
747
+
748
+ // src/project-data.ts
749
+ import { createHash } from "crypto";
750
+ import fs2 from "fs";
751
+ import os from "os";
745
752
  import path3 from "path";
753
+ var MAINTAINER_PRO_HOME_DIR = ".maintainer-pro";
754
+ var HOST_APPS_FILE = "apps.json";
755
+ function maintainerProHome() {
756
+ const override = process.env.MAINTAINER_PRO_HOME?.trim();
757
+ if (override) return path3.resolve(override);
758
+ return path3.join(os.homedir(), MAINTAINER_PRO_HOME_DIR);
759
+ }
760
+ function projectIdForFolder(folder) {
761
+ const resolved = path3.resolve(folder || "");
762
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
763
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
764
+ }
765
+ function sanitizeProjectId(id) {
766
+ return String(id || "").trim().replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
767
+ }
768
+ function resolveProjectDataDir(input) {
769
+ const override = String(input.dataDir || "").trim();
770
+ if (override) return path3.resolve(override);
771
+ const sandbox = sanitizeProjectId(String(input.sandboxId || ""));
772
+ const id = sandbox || projectIdForFolder(input.workspaceDir);
773
+ return path3.join(maintainerProHome(), "projects", id);
774
+ }
775
+ function projectUploadsDir(input) {
776
+ return path3.join(resolveProjectDataDir(input), "uploads");
777
+ }
778
+ function hostAppsCachePath(input) {
779
+ return path3.join(resolveProjectDataDir(input), HOST_APPS_FILE);
780
+ }
781
+ function ensureProjectDataDir(input) {
782
+ const dir = resolveProjectDataDir(input);
783
+ fs2.mkdirSync(dir, { recursive: true });
784
+ fs2.mkdirSync(path3.join(dir, "uploads"), { recursive: true });
785
+ try {
786
+ fs2.writeFileSync(
787
+ path3.join(dir, "workspace.json"),
788
+ `${JSON.stringify(
789
+ {
790
+ workspaceDir: path3.resolve(input.workspaceDir || ""),
791
+ sandboxId: input.sandboxId ? String(input.sandboxId) : null,
792
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
793
+ },
794
+ null,
795
+ 2
796
+ )}
797
+ `,
798
+ "utf8"
799
+ );
800
+ } catch {
801
+ }
802
+ return dir;
803
+ }
804
+
805
+ // src/http/attachments.ts
746
806
  var MAX_ATTACHMENTS = 5;
747
807
  var MAX_BYTES = 4 * 1024 * 1024;
748
808
  var ALLOWED = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
749
809
  function extForMime(mime) {
750
- switch (mime) {
810
+ const normalized = mime.split(";")[0]?.trim().toLowerCase() || "";
811
+ switch (normalized) {
751
812
  case "image/jpeg":
752
813
  case "image/jpg":
753
814
  return ".jpg";
@@ -759,11 +820,31 @@ function extForMime(mime) {
759
820
  return ".png";
760
821
  }
761
822
  }
762
- async function saveChatAttachments(attachments, workspaceDir) {
823
+ function attachmentIdFromRef(ref) {
824
+ const trimmed = String(ref || "").trim();
825
+ if (!trimmed) return null;
826
+ const m = /^maintainer-pro:\/\/(.+)$/i.exec(trimmed);
827
+ if (m?.[1]) return m[1];
828
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
829
+ trimmed
830
+ )) {
831
+ return trimmed;
832
+ }
833
+ return null;
834
+ }
835
+ function uploadsRoot(input) {
836
+ ensureProjectDataDir(input);
837
+ return projectUploadsDir(input);
838
+ }
839
+ async function saveChatAttachments(attachments, workspaceDir, extra) {
763
840
  if (!attachments?.length) return [];
764
841
  const selected = attachments.slice(0, MAX_ATTACHMENTS);
765
- const dir = path3.join(workspaceDir, ".maintainer-pro", "uploads");
766
- await fs2.mkdir(dir, { recursive: true });
842
+ const loc = {
843
+ workspaceDir,
844
+ dataDir: extra?.dataDir,
845
+ sandboxId: extra?.sandboxId
846
+ };
847
+ const dir = uploadsRoot(loc);
767
848
  const paths = [];
768
849
  const stamp = Date.now();
769
850
  for (let i = 0; i < selected.length; i++) {
@@ -781,11 +862,11 @@ async function saveChatAttachments(attachments, workspaceDir) {
781
862
  }
782
863
  const safeBase = (item.name || `screenshot-${i + 1}`).replace(/[^\w.\-]+/g, "_").slice(0, 64);
783
864
  const fileName = `${stamp}-${i + 1}-${safeBase}${extForMime(mime)}`;
784
- const filePath = path3.resolve(dir, fileName);
785
- if (!isInsideWorkspace(workspaceDir, filePath)) {
786
- throw new Error("Attachment path escaped the project workspace");
865
+ const filePath = path4.resolve(dir, fileName);
866
+ if (!isInsideWorkspace(dir, filePath)) {
867
+ throw new Error("Attachment path escaped the project data folder");
787
868
  }
788
- await fs2.writeFile(filePath, buffer);
869
+ await fs3.writeFile(filePath, buffer);
789
870
  paths.push(filePath);
790
871
  }
791
872
  return paths;
@@ -936,6 +1017,8 @@ function createChatHandler(options) {
936
1017
  const logger = options.logger ?? createLogger("ai-cli:chat");
937
1018
  const validate = options.tools ? createToolValidator(options.tools) : null;
938
1019
  const workspaceDir = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
1020
+ const dataDir = options.dataDir;
1021
+ const sandboxId = options.sandboxId;
939
1022
  const baseCallOptions = {
940
1023
  systemPrompt: options.systemPrompt,
941
1024
  workspaceDir,
@@ -1092,20 +1175,37 @@ function createChatHandler(options) {
1092
1175
  }
1093
1176
  let attachmentPaths = [];
1094
1177
  let persistAttachmentPaths = [];
1095
- if (options.db?.uploadAttachments) {
1178
+ if (options.db?.uploadAttachments && attachments?.length) {
1096
1179
  const uploaded = await options.db.uploadAttachments(
1097
1180
  conversationId,
1098
1181
  attachments
1099
1182
  );
1100
1183
  attachmentPaths = uploaded.localPaths;
1101
1184
  persistAttachmentPaths = uploaded.refs;
1102
- } else {
1185
+ } else if (attachments?.length) {
1103
1186
  attachmentPaths = await saveChatAttachments(
1104
1187
  attachments,
1105
- workspaceDir
1188
+ workspaceDir,
1189
+ { dataDir, sandboxId }
1106
1190
  );
1107
1191
  persistAttachmentPaths = attachmentPaths;
1108
1192
  }
1193
+ const storedUserId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
1194
+ if (!attachmentPaths.length && conversationId && options.db?.materializeMessageAttachments) {
1195
+ attachmentPaths = await options.db.materializeMessageAttachments(
1196
+ conversationId,
1197
+ storedUserId,
1198
+ workspaceDir
1199
+ );
1200
+ }
1201
+ logger.debug(
1202
+ {
1203
+ conversationId,
1204
+ userMessageId: storedUserId,
1205
+ attachmentCount: attachmentPaths.length
1206
+ },
1207
+ "cli attachments"
1208
+ );
1109
1209
  const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
1110
1210
  trackedTurn = turn;
1111
1211
  const signal = request.signal;
@@ -1200,6 +1300,7 @@ function createChatHandler(options) {
1200
1300
  turn,
1201
1301
  parentUserMessageId,
1202
1302
  historyLength: messages.length,
1303
+ attachmentCount: attachmentPaths.length,
1203
1304
  hasParentChain: Boolean(parentChainContext),
1204
1305
  preview: previewText(
1205
1306
  [...messages].reverse().find((m) => m.role === "user")?.content
@@ -1358,8 +1459,8 @@ function toNextRoute(handlers) {
1358
1459
  }
1359
1460
 
1360
1461
  // src/http/local-store.ts
1361
- import fs3 from "fs/promises";
1362
- import path4 from "path";
1462
+ import fs4 from "fs/promises";
1463
+ import path5 from "path";
1363
1464
  import { randomUUID as randomUUID2 } from "crypto";
1364
1465
  function userHasAiReply(rows, workingId) {
1365
1466
  return rows.some(
@@ -1368,7 +1469,7 @@ function userHasAiReply(rows, workingId) {
1368
1469
  }
1369
1470
  async function readConversation(filePath) {
1370
1471
  try {
1371
- const raw = await fs3.readFile(filePath, "utf8");
1472
+ const raw = await fs4.readFile(filePath, "utf8");
1372
1473
  return JSON.parse(raw);
1373
1474
  } catch (err) {
1374
1475
  if (err.code === "ENOENT") return null;
@@ -1376,11 +1477,11 @@ async function readConversation(filePath) {
1376
1477
  }
1377
1478
  }
1378
1479
  async function writeConversation(filePath, data) {
1379
- await fs3.mkdir(path4.dirname(filePath), { recursive: true });
1380
- await fs3.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
1480
+ await fs4.mkdir(path5.dirname(filePath), { recursive: true });
1481
+ await fs4.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
1381
1482
  }
1382
1483
  function createLocalDirectoryStore(baseDir) {
1383
- const fileFor = (id) => path4.join(baseDir, `${id}.json`);
1484
+ const fileFor = (id) => path5.join(baseDir, `${id}.json`);
1384
1485
  return {
1385
1486
  async ensureConversation(id) {
1386
1487
  const existing = await readConversation(fileFor(id));
@@ -1681,12 +1782,12 @@ function createLocalDirectoryStore(baseDir) {
1681
1782
  return existing?.messages ?? [];
1682
1783
  },
1683
1784
  async listConversations() {
1684
- await fs3.mkdir(baseDir, { recursive: true });
1685
- const entries = await fs3.readdir(baseDir);
1785
+ await fs4.mkdir(baseDir, { recursive: true });
1786
+ const entries = await fs4.readdir(baseDir);
1686
1787
  const conversations = [];
1687
1788
  for (const entry of entries) {
1688
1789
  if (!entry.endsWith(".json")) continue;
1689
- const filePath = path4.join(baseDir, entry);
1790
+ const filePath = path5.join(baseDir, entry);
1690
1791
  const existing = await readConversation(filePath);
1691
1792
  if (!existing?.id) continue;
1692
1793
  conversations.push({
@@ -1703,7 +1804,7 @@ function createLocalDirectoryStore(baseDir) {
1703
1804
  if (events.length === 0) return;
1704
1805
  const existing = await readConversation(fileFor(conversationId));
1705
1806
  if (!existing) return;
1706
- const toolFile = path4.join(baseDir, `${conversationId}.tools.jsonl`);
1807
+ const toolFile = path5.join(baseDir, `${conversationId}.tools.jsonl`);
1707
1808
  const lines = events.map(
1708
1809
  (e) => JSON.stringify({
1709
1810
  conversationId,
@@ -1711,16 +1812,15 @@ function createLocalDirectoryStore(baseDir) {
1711
1812
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1712
1813
  })
1713
1814
  ).join("\n");
1714
- await fs3.appendFile(toolFile, `${lines}
1815
+ await fs4.appendFile(toolFile, `${lines}
1715
1816
  `, "utf8");
1716
1817
  }
1717
1818
  };
1718
1819
  }
1719
1820
 
1720
1821
  // src/http/maintainer-pro-store.ts
1721
- import fs4 from "fs/promises";
1722
- import os from "os";
1723
- import path5 from "path";
1822
+ import fs5 from "fs/promises";
1823
+ import path6 from "path";
1724
1824
 
1725
1825
  // src/http/synced-store.ts
1726
1826
  function userHasAiReply2(rows, workingId) {
@@ -1747,7 +1847,7 @@ function asCachedMessage(raw) {
1747
1847
  content: typeof raw.content === "string" ? raw.content : "",
1748
1848
  provider: typeof raw.provider === "string" ? raw.provider : null,
1749
1849
  createdAt: typeof raw.createdAt === "string" ? raw.createdAt : void 0,
1750
- attachmentPaths: Array.isArray(raw.attachmentPaths) ? raw.attachmentPaths.filter((p) => typeof p === "string") : void 0,
1850
+ attachmentPaths: Array.isArray(raw.attachmentPaths) ? raw.attachmentPaths.filter((p) => typeof p === "string") : Array.isArray(raw.attachmentIds) ? raw.attachmentIds.filter((id2) => typeof id2 === "string").map((id2) => `maintainer-pro://${id2}`) : void 0,
1751
1851
  senderType: raw.senderType === "client" || raw.senderType === "developer" || raw.senderType === "ai" ? raw.senderType : null,
1752
1852
  senderName: typeof raw.senderName === "string" ? raw.senderName : null,
1753
1853
  parentMessageId: typeof raw.parentMessageId === "string" ? raw.parentMessageId : null,
@@ -2061,6 +2161,14 @@ function createSyncedChatStore(options) {
2061
2161
  return { refs: [], localPaths: [] };
2062
2162
  }
2063
2163
  return remote.uploadAttachments(conversationId, attachments);
2164
+ },
2165
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2166
+ if (!remote.materializeMessageAttachments) return [];
2167
+ return remote.materializeMessageAttachments(
2168
+ conversationId,
2169
+ messageId,
2170
+ workspaceDir
2171
+ );
2064
2172
  }
2065
2173
  };
2066
2174
  return store;
@@ -2093,7 +2201,8 @@ function createMaintainerProStore(options) {
2093
2201
  const baseUrl = options.baseUrl;
2094
2202
  const apiKey = options.apiKey;
2095
2203
  const fetchImpl = options.fetchImpl ?? fetch;
2096
- const tempDir = options.tempDir ?? path5.join(os.tmpdir(), "maintainer-pro-store");
2204
+ const logger = options.logger;
2205
+ const tempDir = options.tempDir ?? projectUploadsDir({ workspaceDir: process.cwd() });
2097
2206
  const store = {
2098
2207
  async ensureConversation(id) {
2099
2208
  await mpFetch(
@@ -2221,7 +2330,7 @@ function createMaintainerProStore(options) {
2221
2330
  if (!attachments?.length) {
2222
2331
  return { refs: [], localPaths: [], attachmentIds: [] };
2223
2332
  }
2224
- await fs4.mkdir(tempDir, { recursive: true });
2333
+ await fs5.mkdir(tempDir, { recursive: true });
2225
2334
  const refs = [];
2226
2335
  const localPaths = [];
2227
2336
  const attachmentIds = [];
@@ -2244,15 +2353,89 @@ function createMaintainerProStore(options) {
2244
2353
  const id = data.attachment.id;
2245
2354
  attachmentIds.push(id);
2246
2355
  refs.push(data.attachment.ref || `maintainer-pro://${id}`);
2247
- const ext = item.mimeType.includes("jpeg") || item.mimeType.includes("jpg") ? ".jpg" : item.mimeType.includes("webp") ? ".webp" : item.mimeType.includes("gif") ? ".gif" : ".png";
2248
- const localPath = path5.join(
2356
+ const localPath = path6.join(
2249
2357
  tempDir,
2250
- `${conversationId}-${id}${ext}`
2358
+ `${conversationId}-${id}${extForMime(item.mimeType)}`
2251
2359
  );
2252
- await fs4.writeFile(localPath, Buffer.from(item.data, "base64"));
2360
+ await fs5.mkdir(path6.dirname(localPath), { recursive: true });
2361
+ await fs5.writeFile(localPath, Buffer.from(item.data, "base64"));
2253
2362
  localPaths.push(localPath);
2254
2363
  }
2255
2364
  return { refs, localPaths, attachmentIds };
2365
+ },
2366
+ async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
2367
+ if (!conversationId || !workspaceDir) return [];
2368
+ const data = await mpFetch(
2369
+ baseUrl,
2370
+ apiKey,
2371
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages`,
2372
+ { method: "GET" },
2373
+ fetchImpl
2374
+ );
2375
+ const messages = data.messages ?? [];
2376
+ const byId = new Map(messages.map((row) => [row.id, row]));
2377
+ const refs = [];
2378
+ const fromMessageIds = [];
2379
+ const seenMsg = /* @__PURE__ */ new Set();
2380
+ const pushRow = (row) => {
2381
+ if (!row || seenMsg.has(row.id)) return;
2382
+ seenMsg.add(row.id);
2383
+ const before = refs.length;
2384
+ refs.push(...row.attachmentPaths ?? []);
2385
+ refs.push(
2386
+ ...(row.attachmentIds ?? []).map((id) => `maintainer-pro://${id}`)
2387
+ );
2388
+ if (refs.length > before) fromMessageIds.push(row.id);
2389
+ };
2390
+ let current = messageId ? byId.get(messageId) : void 0;
2391
+ while (current && !seenMsg.has(current.id)) {
2392
+ pushRow(current);
2393
+ const parentId = current.parentMessageId?.trim();
2394
+ current = parentId ? byId.get(parentId) : void 0;
2395
+ }
2396
+ for (const row of messages) {
2397
+ if (row.role && row.role !== "user") continue;
2398
+ pushRow(row);
2399
+ }
2400
+ const seen = /* @__PURE__ */ new Set();
2401
+ const ids = [];
2402
+ for (const ref of refs) {
2403
+ const id = attachmentIdFromRef(ref);
2404
+ if (!id || seen.has(id)) continue;
2405
+ seen.add(id);
2406
+ ids.push(id);
2407
+ }
2408
+ const localPaths = [];
2409
+ for (const id of ids.slice(0, 5)) {
2410
+ const url = `${baseUrl.replace(/\/$/, "")}/api/v1/store/attachments/${encodeURIComponent(id)}`;
2411
+ const res = await fetchImpl(url, {
2412
+ headers: { Authorization: `Bearer ${apiKey}` }
2413
+ });
2414
+ if (!res.ok) {
2415
+ continue;
2416
+ }
2417
+ const mime = res.headers.get("content-type") || "image/png";
2418
+ const buffer = Buffer.from(await res.arrayBuffer());
2419
+ if (!buffer.byteLength) continue;
2420
+ const localPath = path6.join(
2421
+ tempDir,
2422
+ `${id}${extForMime(mime)}`
2423
+ );
2424
+ await fs5.mkdir(tempDir, { recursive: true });
2425
+ await fs5.writeFile(localPath, buffer);
2426
+ localPaths.push(localPath);
2427
+ }
2428
+ logger?.debug(
2429
+ {
2430
+ conversationId,
2431
+ messageId,
2432
+ fromMessageIds,
2433
+ stored: ids.length,
2434
+ written: localPaths.length
2435
+ },
2436
+ "materialize attachments"
2437
+ );
2438
+ return localPaths;
2256
2439
  }
2257
2440
  };
2258
2441
  if (options.sync === false) {
@@ -2278,6 +2461,11 @@ function createMaintainerProStoreFromEnv(options) {
2278
2461
  return createMaintainerProStore({
2279
2462
  baseUrl,
2280
2463
  apiKey,
2464
+ tempDir: projectUploadsDir({
2465
+ workspaceDir: process.env.AI_CLI_WORKSPACE || process.cwd(),
2466
+ sandboxId: process.env.MAINTAINER_PRO_SANDBOX_ID,
2467
+ dataDir: process.env.MAINTAINER_PRO_DATA_DIR
2468
+ }),
2281
2469
  logger: options?.logger,
2282
2470
  log: options?.log,
2283
2471
  sync: options?.sync,
@@ -2286,7 +2474,10 @@ function createMaintainerProStoreFromEnv(options) {
2286
2474
  }
2287
2475
 
2288
2476
  // src/workspace-inspect.ts
2477
+ import fs6 from "fs";
2478
+ import path7 from "path";
2289
2479
  import { z } from "zod";
2480
+ var CONFIG_INSPECT_TIMEOUT_MS = 18e3;
2290
2481
  var inspectJsonSchema = z.object({
2291
2482
  kind: z.enum(["next", "vite", "html", "empty", "other"]).optional(),
2292
2483
  name: z.string().max(200).optional(),
@@ -2356,6 +2547,154 @@ ${input.extraContext ? `${input.extraContext.trim()}
2356
2547
 
2357
2548
  ` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
2358
2549
  }
2550
+ function readSnippet(file, max = 4e3) {
2551
+ try {
2552
+ const text = fs6.readFileSync(file, "utf8");
2553
+ return text.length > max ? `${text.slice(0, max)}
2554
+ \u2026` : text;
2555
+ } catch {
2556
+ return null;
2557
+ }
2558
+ }
2559
+ function envKeysOnly(file) {
2560
+ try {
2561
+ const keys = fs6.readFileSync(file, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).map((line) => line.slice(0, line.indexOf("=")).trim()).filter(Boolean);
2562
+ return keys.length ? keys.join("\n") : null;
2563
+ } catch {
2564
+ return null;
2565
+ }
2566
+ }
2567
+ function gatherConfigSnippets(workspaceDir) {
2568
+ const folder = path7.resolve(workspaceDir);
2569
+ const parts = [];
2570
+ const pkg = readSnippet(path7.join(folder, "package.json"));
2571
+ if (pkg) parts.push(`### package.json
2572
+ \`\`\`json
2573
+ ${pkg}
2574
+ \`\`\``);
2575
+ for (const lock of [
2576
+ "package-lock.json",
2577
+ "pnpm-lock.yaml",
2578
+ "yarn.lock",
2579
+ "bun.lockb"
2580
+ ]) {
2581
+ if (fs6.existsSync(path7.join(folder, lock))) {
2582
+ parts.push(`### lockfile
2583
+ ${lock}`);
2584
+ break;
2585
+ }
2586
+ }
2587
+ for (const rel of [
2588
+ "vite.config.ts",
2589
+ "vite.config.js",
2590
+ "vite.config.mjs",
2591
+ "next.config.js",
2592
+ "next.config.mjs",
2593
+ "next.config.ts"
2594
+ ]) {
2595
+ const body = readSnippet(path7.join(folder, rel), 2500);
2596
+ if (body) parts.push(`### ${rel}
2597
+ \`\`\`
2598
+ ${body}
2599
+ \`\`\``);
2600
+ }
2601
+ for (const rel of [".env.example", ".env", ".env.local", ".env.development"]) {
2602
+ const keys = envKeysOnly(path7.join(folder, rel));
2603
+ if (keys) parts.push(`### ${rel} keys (values omitted)
2604
+ \`\`\`
2605
+ ${keys}
2606
+ \`\`\``);
2607
+ }
2608
+ return parts.join("\n\n") || "(no config files found)";
2609
+ }
2610
+ function configOnlySystemPrompt(input) {
2611
+ return `You are Maintainer Pro's read-only setup advisor.
2612
+
2613
+ Workspace: ${input.workspaceDir}
2614
+ App name: ${input.appName || "the app"}
2615
+
2616
+ You are given configuration file excerpts only.
2617
+ DO NOT edit, create, or delete any files.
2618
+ DO NOT run shell commands, install packages, or start servers.
2619
+ Only infer how this project runs locally from the excerpts.
2620
+
2621
+ Script names you report MUST appear in package.json scripts. Omit chat/ai-server scripts.
2622
+
2623
+ Reply with a short human summary AND one \`\`\`json fence with:
2624
+
2625
+ {
2626
+ "kind": "next" | "vite" | "html" | "empty" | "other",
2627
+ "name": "package or folder name",
2628
+ "summary": "one sentence about this project",
2629
+ "scripts": { "ui": "dev:client", "backend": "dev:server" },
2630
+ "ports": { "ui": 5173, "backend": 4100 },
2631
+ "issues": ["uncertainties"],
2632
+ "fixes": [],
2633
+ "ready": true
2634
+ }`;
2635
+ }
2636
+ function configOnlyUserMessage(input, snippets) {
2637
+ return `${input.extraContext ? `${input.extraContext.trim()}
2638
+
2639
+ ` : ""}Analyze these config excerpts and suggest local apps/ports/scripts. Do not change anything on disk.
2640
+
2641
+ ${snippets}`;
2642
+ }
2643
+ function toInspectResult(input, text, providerId) {
2644
+ const parsed = extractInspectJson(text);
2645
+ return {
2646
+ kind: parsed?.kind ?? "other",
2647
+ name: parsed?.name?.trim() || input.appName || "",
2648
+ summary: parsed?.summary?.trim() || text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
2649
+ scripts: parsed?.scripts ?? {},
2650
+ ports: parsed?.ports ?? {},
2651
+ issues: parsed?.issues ?? [],
2652
+ fixes: parsed?.fixes ?? [],
2653
+ ready: parsed?.ready ?? parsed?.issues?.length === 0,
2654
+ provider: providerId,
2655
+ rawText: text
2656
+ };
2657
+ }
2658
+ async function inspectConfigOnly(input, opts = {}) {
2659
+ const workspaceDir = path7.resolve(input.workspaceDir);
2660
+ const timeoutMs = opts.timeoutMs ?? CONFIG_INSPECT_TIMEOUT_MS;
2661
+ const provider = await resolveProvider({ preference: "auto" });
2662
+ const snippets = gatherConfigSnippets(workspaceDir);
2663
+ const call = callAi(
2664
+ [{ role: "user", content: configOnlyUserMessage(input, snippets) }],
2665
+ {
2666
+ route: "/local-setup-propose",
2667
+ pageTitle: input.appName || "Setup suggestions",
2668
+ relevantFiles: ["package.json", ".env.example"],
2669
+ data: {
2670
+ workspaceDir,
2671
+ readOnly: true
2672
+ }
2673
+ },
2674
+ {
2675
+ systemPrompt: configOnlySystemPrompt(input),
2676
+ workspaceDir,
2677
+ technical: true
2678
+ }
2679
+ );
2680
+ const timed = await Promise.race([
2681
+ call.then((response) => ({ ok: true, response })),
2682
+ new Promise(
2683
+ (resolve) => setTimeout(
2684
+ () => resolve({ ok: false, error: `Config inspect timed out after ${timeoutMs}ms` }),
2685
+ timeoutMs
2686
+ )
2687
+ )
2688
+ ]);
2689
+ if (!timed.ok) {
2690
+ throw new Error(timed.error);
2691
+ }
2692
+ return toInspectResult(
2693
+ input,
2694
+ timed.response.text,
2695
+ timed.response.provider || provider.id
2696
+ );
2697
+ }
2359
2698
  async function inspectAndRepairWorkspace(input) {
2360
2699
  const workspaceDir = input.workspaceDir;
2361
2700
  const provider = await resolveProvider({ preference: "auto" });
@@ -2376,24 +2715,670 @@ async function inspectAndRepairWorkspace(input) {
2376
2715
  technical: true
2377
2716
  }
2378
2717
  );
2379
- const parsed = extractInspectJson(response.text);
2718
+ return toInspectResult(
2719
+ input,
2720
+ response.text,
2721
+ response.provider || provider.id
2722
+ );
2723
+ }
2724
+
2725
+ // src/host-apps.ts
2726
+ import fs7 from "fs";
2727
+ import path8 from "path";
2728
+ import { createHash as createHash2 } from "crypto";
2729
+ var COLLABORATER_DIR = ".collaborater";
2730
+ var AI_SERVER_APP_ID = "ai-server";
2731
+ var AI_SERVER_DEFAULT_PORT = 3100;
2732
+ var ENV_FILES = [".env", ".env.local", ".env.development", ".env.example"];
2733
+ function isPort(value) {
2734
+ return Number.isInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
2735
+ }
2736
+ function parsePort(value, fallback = 0) {
2737
+ if (typeof value === "number" && isPort(value)) return value;
2738
+ const text = String(value ?? "").trim();
2739
+ if (!text) return fallback;
2740
+ if (/^\d{2,5}$/.test(text)) {
2741
+ const n = Number(text);
2742
+ return isPort(n) ? n : fallback;
2743
+ }
2744
+ try {
2745
+ const port = Number(new URL(text).port);
2746
+ return isPort(port) ? port : fallback;
2747
+ } catch {
2748
+ const match = text.match(/--port(?:\s+|=)(\d{2,5})/i) || text.match(/\bPORT[=:]\s*(\d{2,5})/i) || text.match(/:(\d{2,5})\b/);
2749
+ const n = match ? Number(match[1]) : 0;
2750
+ return isPort(n) ? n : fallback;
2751
+ }
2752
+ }
2753
+ function dataInput(folder, extra) {
2380
2754
  return {
2381
- kind: parsed?.kind ?? "other",
2382
- name: parsed?.name?.trim() || input.appName || "",
2383
- summary: parsed?.summary?.trim() || response.text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
2384
- scripts: parsed?.scripts ?? {},
2385
- ports: parsed?.ports ?? {},
2386
- issues: parsed?.issues ?? [],
2387
- fixes: parsed?.fixes ?? [],
2388
- ready: parsed?.ready ?? parsed?.issues?.length === 0,
2389
- provider: response.provider || provider.id,
2390
- rawText: response.text
2755
+ workspaceDir: folder,
2756
+ sandboxId: extra?.sandboxId,
2757
+ dataDir: extra?.dataDir
2758
+ };
2759
+ }
2760
+ function hostAppsCachePath2(folder, extra) {
2761
+ return hostAppsCachePath(dataInput(folder, extra));
2762
+ }
2763
+ function defaultAiServerApp(port = AI_SERVER_DEFAULT_PORT) {
2764
+ return {
2765
+ id: AI_SERVER_APP_ID,
2766
+ name: "AI server",
2767
+ role: "ai-server",
2768
+ port: parsePort(port, AI_SERVER_DEFAULT_PORT),
2769
+ startCommand: null,
2770
+ source: "default",
2771
+ locked: true
2772
+ };
2773
+ }
2774
+ function normalizeHostApp(raw) {
2775
+ if (!raw || typeof raw !== "object") return null;
2776
+ const row = raw;
2777
+ const port = parsePort(row.port);
2778
+ if (!port) return null;
2779
+ const role = String(row.role || "custom");
2780
+ const allowed = ["ai-server", "ui", "backend", "app", "custom"];
2781
+ const id = String(row.id || "").trim() || (role === "ai-server" ? AI_SERVER_APP_ID : "");
2782
+ if (!id) return null;
2783
+ return {
2784
+ id: id.slice(0, 64),
2785
+ name: String(row.name || id).trim().slice(0, 80) || id,
2786
+ role: allowed.includes(role) ? role : "custom",
2787
+ port,
2788
+ startCommand: typeof row.startCommand === "string" && row.startCommand.trim() ? row.startCommand.trim().slice(0, 200) : null,
2789
+ source: ["default", "env", "package", "ai", "manual"].includes(
2790
+ row.source
2791
+ ) ? row.source : "manual",
2792
+ locked: row.locked === true || id === AI_SERVER_APP_ID || role === "ai-server",
2793
+ host: role !== "ai-server" && id !== AI_SERVER_APP_ID && row.host === true,
2794
+ envMaps: normalizeEnvMaps(row.envMaps)
2795
+ };
2796
+ }
2797
+ function normalizeEnvMaps(raw) {
2798
+ if (!Array.isArray(raw)) return [];
2799
+ const seen = /* @__PURE__ */ new Set();
2800
+ const out = [];
2801
+ for (const item of raw) {
2802
+ if (!item || typeof item !== "object") continue;
2803
+ const row = item;
2804
+ const key = String(row.key || "").trim();
2805
+ const sourceAppId = String(row.sourceAppId || "").trim().slice(0, 64);
2806
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.length > 80) continue;
2807
+ if (!sourceAppId || seen.has(key)) continue;
2808
+ seen.add(key);
2809
+ out.push({ key, sourceAppId });
2810
+ }
2811
+ return out.slice(0, 30);
2812
+ }
2813
+ function ensureSingleHost(apps) {
2814
+ const next = apps.map((app) => ({
2815
+ ...app,
2816
+ host: app.role !== "ai-server" && app.id !== AI_SERVER_APP_ID && app.host === true
2817
+ }));
2818
+ const marked = next.filter((app) => app.host);
2819
+ if (marked.length === 1) return next;
2820
+ if (marked.length > 1) {
2821
+ let kept = false;
2822
+ return next.map((app) => {
2823
+ if (!app.host) return app;
2824
+ if (kept) return { ...app, host: false };
2825
+ kept = true;
2826
+ return app;
2827
+ });
2828
+ }
2829
+ const fallback = next.find((app) => app.role === "ui" || app.role === "app") || next.find((app) => app.role !== "ai-server");
2830
+ return next.map(
2831
+ (app) => fallback && app.id === fallback.id ? { ...app, host: true } : app
2832
+ );
2833
+ }
2834
+ function normalizeHostApps(raw) {
2835
+ if (!Array.isArray(raw)) return [];
2836
+ const seen = /* @__PURE__ */ new Set();
2837
+ const apps = [];
2838
+ for (const item of raw) {
2839
+ const app = normalizeHostApp(item);
2840
+ if (!app || seen.has(app.id)) continue;
2841
+ seen.add(app.id);
2842
+ apps.push(app);
2843
+ }
2844
+ return apps;
2845
+ }
2846
+ function ensureAiServerApp(apps, preferredPort = AI_SERVER_DEFAULT_PORT) {
2847
+ const next = normalizeHostApps(apps);
2848
+ const existing = next.find((app) => app.id === AI_SERVER_APP_ID || app.role === "ai-server");
2849
+ if (existing) {
2850
+ existing.id = AI_SERVER_APP_ID;
2851
+ existing.role = "ai-server";
2852
+ existing.locked = true;
2853
+ existing.host = false;
2854
+ existing.name = existing.name || "AI server";
2855
+ return ensureSingleHost([existing, ...next.filter((app) => app !== existing)]);
2856
+ }
2857
+ return ensureSingleHost([defaultAiServerApp(preferredPort), ...next]);
2858
+ }
2859
+ function readText(file) {
2860
+ try {
2861
+ return fs7.readFileSync(file, "utf8");
2862
+ } catch {
2863
+ return null;
2864
+ }
2865
+ }
2866
+ function readEnvFileMap(file) {
2867
+ const text = readText(file);
2868
+ if (!text) return {};
2869
+ const map = {};
2870
+ for (const raw of text.split(/\r?\n/)) {
2871
+ const line = raw.trim();
2872
+ if (!line || line.startsWith("#")) continue;
2873
+ const eq = line.indexOf("=");
2874
+ if (eq < 1) continue;
2875
+ const key = line.slice(0, eq).trim();
2876
+ let value = line.slice(eq + 1).trim();
2877
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2878
+ value = value.slice(1, -1);
2879
+ }
2880
+ if (key) map[key] = value;
2881
+ }
2882
+ return map;
2883
+ }
2884
+ function readProjectEnvLayers(folder) {
2885
+ const resolved = path8.resolve(folder);
2886
+ const layers = [];
2887
+ const merged = {};
2888
+ for (const name of ENV_FILES) {
2889
+ const file = path8.join(resolved, name);
2890
+ const values = readEnvFileMap(file);
2891
+ if (!Object.keys(values).length) continue;
2892
+ layers.push({ file: name, values });
2893
+ if (name === ".env.example") continue;
2894
+ Object.assign(merged, values);
2895
+ }
2896
+ return { merged, layers };
2897
+ }
2898
+ function readPackageJson(folder) {
2899
+ try {
2900
+ return JSON.parse(fs7.readFileSync(path8.join(folder, "package.json"), "utf8"));
2901
+ } catch {
2902
+ return null;
2903
+ }
2904
+ }
2905
+ function isChatScript(name, command) {
2906
+ return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(`${name} ${command}`);
2907
+ }
2908
+ function isUiCommand(command) {
2909
+ return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
2910
+ command
2911
+ );
2912
+ }
2913
+ function configPort(folder, files) {
2914
+ for (const rel of files) {
2915
+ const text = readText(path8.join(folder, rel));
2916
+ if (!text) continue;
2917
+ const match = text.match(/\bport\s*[:=]\s*(\d{2,5})/i) || text.match(/--port(?:\s+|=)(\d{2,5})/i);
2918
+ const port = parsePort(match?.[1]);
2919
+ if (port) return port;
2920
+ }
2921
+ return 0;
2922
+ }
2923
+ function hostAppsFingerprint(folder) {
2924
+ const resolved = path8.resolve(folder);
2925
+ const parts = [];
2926
+ for (const rel of [
2927
+ "package.json",
2928
+ ...ENV_FILES,
2929
+ "vite.config.ts",
2930
+ "vite.config.js",
2931
+ "vite.config.mjs",
2932
+ "next.config.js",
2933
+ "next.config.mjs",
2934
+ "next.config.ts"
2935
+ ]) {
2936
+ const file = path8.join(resolved, rel);
2937
+ if (!fs7.existsSync(file)) continue;
2938
+ try {
2939
+ const st = fs7.statSync(file);
2940
+ parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
2941
+ } catch {
2942
+ }
2943
+ }
2944
+ return createHash2("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
2945
+ }
2946
+ function pickScript(scripts, names) {
2947
+ return names.find((name) => scripts[name] && !isChatScript(name, scripts[name])) || null;
2948
+ }
2949
+ function detectHostAppsFromFiles(folder, opts = {}) {
2950
+ const resolved = path8.resolve(folder);
2951
+ const reasons = [];
2952
+ const { merged, layers } = readProjectEnvLayers(resolved);
2953
+ const pkg = readPackageJson(resolved);
2954
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
2955
+ const apps = [];
2956
+ const envAi = parsePort(merged.AI_SERVER_PORT) || parsePort(merged.AI_SERVER_URL);
2957
+ const aiPort = envAi || parsePort(opts.preferredAiPort, AI_SERVER_DEFAULT_PORT);
2958
+ apps.push({
2959
+ ...defaultAiServerApp(aiPort),
2960
+ source: envAi ? "env" : "default"
2961
+ });
2962
+ const envPorts = {
2963
+ PORT: layers.filter((layer) => layer.file !== ".env.example").map((layer) => ({ file: layer.file, port: parsePort(layer.values.PORT) })).filter((row) => row.port),
2964
+ APP_URL: parsePort(merged.APP_URL || merged.NEXT_PUBLIC_APP_URL || merged.PUBLIC_URL),
2965
+ API_PORT: parsePort(merged.API_PORT || merged.API_URL || merged.VITE_API_URL)
2966
+ };
2967
+ const portValues = [...new Set(envPorts.PORT.map((row) => row.port))];
2968
+ if (portValues.length > 1) {
2969
+ reasons.push(
2970
+ `PORT differs across env files: ${envPorts.PORT.map((row) => `${row.file}=${row.port}`).join(", ")}`
2971
+ );
2972
+ }
2973
+ if (envPorts.PORT[0] && envPorts.APP_URL && envPorts.PORT[0].port !== envPorts.APP_URL) {
2974
+ reasons.push(
2975
+ `PORT=${envPorts.PORT[0].port} does not match APP_URL port ${envPorts.APP_URL}`
2976
+ );
2977
+ }
2978
+ const uiScript = pickScript(scripts, [
2979
+ "dev:client",
2980
+ "dev:ui",
2981
+ "dev:web",
2982
+ "dev:frontend",
2983
+ "client",
2984
+ "start:client"
2985
+ ]) || (scripts.dev && !isChatScript("dev", scripts.dev) ? "dev" : null);
2986
+ const apiScript = pickScript(scripts, [
2987
+ "dev:server",
2988
+ "dev:backend",
2989
+ "dev:api",
2990
+ "server",
2991
+ "start:server"
2992
+ ]);
2993
+ const vitePort = configPort(resolved, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) || 5173;
2994
+ const nextPort = configPort(resolved, ["next.config.js", "next.config.mjs", "next.config.ts"]) || 3e3;
2995
+ const portFromEnv = parsePort(merged.PORT);
2996
+ const hostFromEnv = parsePort(merged.HOST_PORT) || parsePort(merged.HOST_URL) || parsePort(merged.CORS_ORIGIN);
2997
+ const uiFromEnv = hostFromEnv || envPorts.APP_URL || (portFromEnv && portFromEnv !== aiPort ? portFromEnv : 0);
2998
+ if (uiScript) {
2999
+ const command = scripts[uiScript] || "";
3000
+ const scriptPort = parsePort(command);
3001
+ const fallback = isUiCommand(command) && /vite/i.test(command) ? vitePort : nextPort;
3002
+ const port = uiFromEnv || scriptPort || fallback;
3003
+ apps.push({
3004
+ id: "ui",
3005
+ name: opts.appName || pkg?.name || "App",
3006
+ role: isUiCommand(command) ? "ui" : "app",
3007
+ port,
3008
+ startCommand: `npm run ${uiScript}`,
3009
+ source: uiFromEnv ? "env" : scriptPort ? "package" : "package",
3010
+ host: true
3011
+ });
3012
+ } else if (uiFromEnv) {
3013
+ apps.push({
3014
+ id: "ui",
3015
+ name: opts.appName || pkg?.name || "App",
3016
+ role: "ui",
3017
+ port: uiFromEnv,
3018
+ startCommand: scripts.dev ? "npm run dev" : null,
3019
+ source: "env",
3020
+ host: true
3021
+ });
3022
+ }
3023
+ if (apiScript && apiScript !== uiScript) {
3024
+ const command = scripts[apiScript] || "";
3025
+ const port = envPorts.API_PORT || parsePort(command) || configPort(resolved, []) || 4100;
3026
+ apps.push({
3027
+ id: "backend",
3028
+ name: "Backend",
3029
+ role: "backend",
3030
+ port,
3031
+ startCommand: `npm run ${apiScript}`,
3032
+ source: envPorts.API_PORT ? "env" : "package"
3033
+ });
3034
+ }
3035
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3036
+ if (pkg && Object.keys(scripts).length && hostApps.length === 0) {
3037
+ reasons.push("Found package.json scripts but no host app port in env or config");
3038
+ }
3039
+ return {
3040
+ apps: ensureAiServerApp(apps, aiPort),
3041
+ reasons,
3042
+ confused: reasons.length > 0
3043
+ };
3044
+ }
3045
+ function readHostAppsCache(folder, extra) {
3046
+ try {
3047
+ const raw = JSON.parse(
3048
+ fs7.readFileSync(hostAppsCachePath2(folder, extra), "utf8")
3049
+ );
3050
+ const apps = ensureAiServerApp(normalizeHostApps(raw.apps));
3051
+ if (!apps.length) return null;
3052
+ return {
3053
+ apps,
3054
+ fingerprint: typeof raw.fingerprint === "string" ? raw.fingerprint : void 0,
3055
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : void 0
3056
+ };
3057
+ } catch {
3058
+ return null;
3059
+ }
3060
+ }
3061
+ var readCollaboraterApps = readHostAppsCache;
3062
+ function writeHostAppsCache(folder, apps, extra = {}, loc) {
3063
+ const resolved = path8.resolve(folder);
3064
+ ensureProjectDataDir(dataInput(resolved, loc));
3065
+ const payload = {
3066
+ version: 1,
3067
+ fingerprint: hostAppsFingerprint(resolved),
3068
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3069
+ apps: ensureAiServerApp(apps),
3070
+ ...extra
3071
+ };
3072
+ fs7.writeFileSync(
3073
+ hostAppsCachePath2(resolved, loc),
3074
+ `${JSON.stringify(payload, null, 2)}
3075
+ `,
3076
+ "utf8"
3077
+ );
3078
+ }
3079
+ var writeCollaboraterApps = writeHostAppsCache;
3080
+ function mergeDesiredHostApps(desired, detected) {
3081
+ const byId = new Map(detected.map((app) => [app.id, app]));
3082
+ const byRole = new Map(detected.map((app) => [app.role, app]));
3083
+ const merged = desired.map((app) => {
3084
+ const local = byId.get(app.id) || (app.role !== "custom" ? byRole.get(app.role) : void 0);
3085
+ return {
3086
+ ...local,
3087
+ ...app,
3088
+ startCommand: app.startCommand || local?.startCommand || null,
3089
+ locked: app.locked || app.id === AI_SERVER_APP_ID || app.role === "ai-server",
3090
+ host: app.role === "ai-server" || app.id === AI_SERVER_APP_ID ? false : app.host === true || app.host !== false && local?.host === true,
3091
+ envMaps: app.envMaps && app.envMaps.length ? app.envMaps : local?.envMaps || []
3092
+ };
3093
+ });
3094
+ return ensureAiServerApp(merged);
3095
+ }
3096
+ function appsFromInspectPorts(ports, scripts, name) {
3097
+ const apps = [];
3098
+ if (ports.ui || scripts.ui) {
3099
+ apps.push({
3100
+ id: "ui",
3101
+ name,
3102
+ role: "ui",
3103
+ port: parsePort(ports.ui, 5173),
3104
+ startCommand: scripts.ui ? `npm run ${scripts.ui}` : null,
3105
+ source: "ai"
3106
+ });
3107
+ }
3108
+ if (ports.backend || scripts.backend) {
3109
+ apps.push({
3110
+ id: "backend",
3111
+ name: "Backend",
3112
+ role: "backend",
3113
+ port: parsePort(ports.backend, 4100),
3114
+ startCommand: scripts.backend ? `npm run ${scripts.backend}` : null,
3115
+ source: "ai"
3116
+ });
3117
+ }
3118
+ if (!apps.length && (ports.app || scripts.app)) {
3119
+ apps.push({
3120
+ id: "app",
3121
+ name,
3122
+ role: "app",
3123
+ port: parsePort(ports.app, 3e3),
3124
+ startCommand: scripts.app ? `npm run ${scripts.app}` : null,
3125
+ source: "ai"
3126
+ });
3127
+ }
3128
+ return apps;
3129
+ }
3130
+ function collectScriptAlternatives(folder, primary, opts = {}) {
3131
+ const pkg = readPackageJson(folder);
3132
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
3133
+ const usedCommands = new Set(
3134
+ primary.map((app) => app.startCommand?.trim()).filter((cmd) => Boolean(cmd))
3135
+ );
3136
+ const alternatives = [];
3137
+ const hostApp = primary.find((app) => app.role === "ui" || app.role === "app") || primary.find((app) => app.role !== "ai-server");
3138
+ const backendApp = primary.find((app) => app.role === "backend");
3139
+ for (const [name, command] of Object.entries(scripts)) {
3140
+ if (isChatScript(name, command)) continue;
3141
+ if (!/^(dev|start|serve)/i.test(name) && !isUiCommand(command)) continue;
3142
+ const startCommand = `npm run ${name}`;
3143
+ if (usedCommands.has(startCommand)) continue;
3144
+ const scriptPort = parsePort(command);
3145
+ const looksBackend = /\b(server|api|backend)\b/i.test(`${name} ${command}`) && !isUiCommand(command);
3146
+ const target = looksBackend ? backendApp || hostApp : hostApp;
3147
+ if (!target) continue;
3148
+ const fallbackPort = scriptPort || (looksBackend ? 4100 : isUiCommand(command) && /vite/i.test(command) ? 5173 : 3e3);
3149
+ alternatives.push({
3150
+ appId: target.id,
3151
+ port: fallbackPort,
3152
+ startCommand,
3153
+ label: `${name} \u2192 ${fallbackPort}`,
3154
+ source: "package"
3155
+ });
3156
+ if (alternatives.length >= 8) break;
3157
+ }
3158
+ const { layers } = readProjectEnvLayers(folder);
3159
+ if (hostApp) {
3160
+ for (const layer of layers) {
3161
+ if (layer.file === ".env.example") continue;
3162
+ const port = parsePort(layer.values.PORT);
3163
+ if (!port || port === hostApp.port) continue;
3164
+ if (alternatives.some(
3165
+ (alt) => alt.appId === hostApp.id && alt.port === port && !alt.startCommand
3166
+ )) {
3167
+ continue;
3168
+ }
3169
+ alternatives.push({
3170
+ appId: hostApp.id,
3171
+ port,
3172
+ startCommand: hostApp.startCommand || null,
3173
+ label: `${layer.file} PORT=${port}`,
3174
+ source: "env"
3175
+ });
3176
+ }
3177
+ }
3178
+ void opts;
3179
+ return alternatives;
3180
+ }
3181
+ function proposalConfidence(apps, reasons, usedAi) {
3182
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3183
+ if (!hostApps.length) return "low";
3184
+ if (reasons.length === 0 && hostApps.every((app) => app.startCommand)) {
3185
+ return usedAi ? "medium" : "high";
3186
+ }
3187
+ if (reasons.some((reason) => /differ|does not match|no host app/i.test(reason))) {
3188
+ return "low";
3189
+ }
3190
+ return "medium";
3191
+ }
3192
+ function projectSummaryFromDetect(folder, apps, opts = {}) {
3193
+ const pkg = readPackageJson(folder);
3194
+ const name = opts.appName || pkg?.name || path8.basename(path8.resolve(folder));
3195
+ const hostApps = apps.filter((app) => app.role !== "ai-server");
3196
+ if (!hostApps.length) {
3197
+ return `${name}: no UI/backend ports detected from config yet.`;
3198
+ }
3199
+ return `${name}: ${hostApps.map((app) => `${app.name} on ${app.port}${app.startCommand ? ` (${app.startCommand})` : ""}`).join(", ")}.`;
3200
+ }
3201
+ async function proposeHostAppsFromConfig(input) {
3202
+ const folder = path8.resolve(input.workspaceDir);
3203
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3204
+ const fingerprint = hostAppsFingerprint(folder);
3205
+ const allowAi = input.allowAi !== false;
3206
+ const detected = detectHostAppsFromFiles(folder, {
3207
+ preferredAiPort: preferredAi,
3208
+ appName: input.appName
3209
+ });
3210
+ let apps = ensureAiServerApp(detected.apps, preferredAi);
3211
+ let reasons = [...detected.reasons];
3212
+ let usedAi = false;
3213
+ let projectSummary = projectSummaryFromDetect(folder, apps, {
3214
+ appName: input.appName
3215
+ });
3216
+ const hostCount = apps.filter((app) => app.role !== "ai-server").length;
3217
+ const needsAi = allowAi && (detected.confused || hostCount === 0 || reasons.some((reason) => /differ|does not match/i.test(reason)));
3218
+ if (needsAi) {
3219
+ try {
3220
+ const inspected = await inspectConfigOnly({
3221
+ workspaceDir: folder,
3222
+ appName: input.appName,
3223
+ extraContext: [
3224
+ "File-based detection was uncertain or incomplete.",
3225
+ ...reasons
3226
+ ].join("\n")
3227
+ });
3228
+ usedAi = true;
3229
+ const fromAi = appsFromInspectPorts(
3230
+ inspected.ports,
3231
+ inspected.scripts,
3232
+ inspected.name || input.appName || "App"
3233
+ );
3234
+ if (fromAi.length) {
3235
+ apps = ensureAiServerApp(
3236
+ [defaultAiServerApp(preferredAi), ...fromAi],
3237
+ preferredAi
3238
+ );
3239
+ }
3240
+ if (inspected.summary) projectSummary = inspected.summary;
3241
+ if (inspected.issues?.length) {
3242
+ reasons = [...reasons, ...inspected.issues];
3243
+ }
3244
+ } catch (err) {
3245
+ reasons.push(
3246
+ `AI suggest skipped: ${err instanceof Error ? err.message : String(err)}`
3247
+ );
3248
+ }
3249
+ }
3250
+ const alternatives = collectScriptAlternatives(folder, apps, {
3251
+ appName: input.appName
3252
+ });
3253
+ const confidence = proposalConfidence(apps, reasons, usedAi);
3254
+ const needsReview = confidence !== "high" || detected.confused || apps.filter((app) => app.role !== "ai-server").length === 0;
3255
+ return {
3256
+ apps,
3257
+ alternatives,
3258
+ reasons,
3259
+ confidence,
3260
+ projectSummary,
3261
+ usedAi,
3262
+ needsReview,
3263
+ fingerprint
3264
+ };
3265
+ }
3266
+ async function resolveHostApps(input) {
3267
+ const folder = path8.resolve(input.workspaceDir);
3268
+ const loc = { sandboxId: input.sandboxId, dataDir: input.dataDir };
3269
+ const fingerprint = hostAppsFingerprint(folder);
3270
+ const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
3271
+ const detected = detectHostAppsFromFiles(folder, {
3272
+ preferredAiPort: preferredAi,
3273
+ appName: input.appName
3274
+ });
3275
+ const desired = normalizeHostApps(input.desired);
3276
+ if (desired.length) {
3277
+ const apps2 = mergeDesiredHostApps(desired, detected.apps);
3278
+ try {
3279
+ writeHostAppsCache(folder, apps2, { source: "desired" }, loc);
3280
+ } catch {
3281
+ }
3282
+ return {
3283
+ apps: apps2,
3284
+ source: "desired",
3285
+ cached: false,
3286
+ usedAi: false,
3287
+ confused: false,
3288
+ reasons: [],
3289
+ fingerprint
3290
+ };
3291
+ }
3292
+ if (!input.force) {
3293
+ const cached = readHostAppsCache(folder, loc);
3294
+ if (cached && cached.fingerprint === fingerprint) {
3295
+ return {
3296
+ apps: ensureAiServerApp(cached.apps, preferredAi),
3297
+ source: "cache",
3298
+ cached: true,
3299
+ usedAi: false,
3300
+ confused: false,
3301
+ reasons: [],
3302
+ fingerprint
3303
+ };
3304
+ }
3305
+ }
3306
+ const needsAi = Boolean(input.allowAi) && (Boolean(input.force && detected.confused) || detected.confused && detected.apps.filter((app) => app.role !== "ai-server").length === 0 || detected.reasons.some((reason) => /differ|does not match/i.test(reason)));
3307
+ if (needsAi) {
3308
+ try {
3309
+ const inspected = await inspectConfigOnly({
3310
+ workspaceDir: folder,
3311
+ appName: input.appName,
3312
+ extraContext: `Port detection was uncertain:
3313
+ ${detected.reasons.join("\n")}
3314
+ Return the real local ports and npm script names.`
3315
+ });
3316
+ const fromAi = appsFromInspectPorts(
3317
+ inspected.ports,
3318
+ inspected.scripts,
3319
+ inspected.name || input.appName || "App"
3320
+ );
3321
+ const apps2 = ensureAiServerApp(
3322
+ [defaultAiServerApp(preferredAi), ...fromAi],
3323
+ preferredAi
3324
+ );
3325
+ writeHostAppsCache(
3326
+ folder,
3327
+ apps2,
3328
+ {
3329
+ source: "ai",
3330
+ reasons: detected.reasons,
3331
+ summary: inspected.summary
3332
+ },
3333
+ loc
3334
+ );
3335
+ return {
3336
+ apps: apps2,
3337
+ source: "ai",
3338
+ cached: false,
3339
+ usedAi: true,
3340
+ confused: detected.confused,
3341
+ reasons: detected.reasons,
3342
+ fingerprint
3343
+ };
3344
+ } catch (err) {
3345
+ detected.reasons.push(
3346
+ `AI inspect skipped: ${err instanceof Error ? err.message : String(err)}`
3347
+ );
3348
+ }
3349
+ }
3350
+ const apps = ensureAiServerApp(detected.apps, preferredAi);
3351
+ try {
3352
+ writeHostAppsCache(
3353
+ folder,
3354
+ apps,
3355
+ {
3356
+ source: detected.confused ? "env" : "env",
3357
+ reasons: detected.reasons
3358
+ },
3359
+ loc
3360
+ );
3361
+ } catch {
3362
+ }
3363
+ return {
3364
+ apps,
3365
+ source: apps.some((app) => app.source === "env") ? "env" : "default",
3366
+ cached: false,
3367
+ usedAi: false,
3368
+ confused: detected.confused,
3369
+ reasons: detected.reasons,
3370
+ fingerprint
2391
3371
  };
2392
3372
  }
2393
3373
  export {
2394
3374
  ACCESS_IGNORE_BEGIN,
2395
3375
  ACCESS_IGNORE_END,
3376
+ AI_SERVER_APP_ID,
3377
+ AI_SERVER_DEFAULT_PORT,
3378
+ COLLABORATER_DIR,
2396
3379
  DEFAULT_AI_IGNORE_PATHS,
3380
+ HOST_APPS_FILE,
3381
+ MAINTAINER_PRO_HOME_DIR,
2397
3382
  PROMPT_SECTION,
2398
3383
  WORKING_PROVIDER,
2399
3384
  buildClaudeUserPrompt,
@@ -2416,27 +3401,53 @@ export {
2416
3401
  createMaintainerProStoreFromEnv,
2417
3402
  createSyncedChatStore,
2418
3403
  createToolValidator,
3404
+ defaultAiServerApp,
3405
+ detectHostAppsFromFiles,
3406
+ ensureAiServerApp,
3407
+ ensureProjectDataDir,
3408
+ ensureSingleHost,
2419
3409
  formatAccessPolicyPromptSection,
2420
3410
  formatClientContext,
2421
3411
  formatParentChainContext,
2422
3412
  getProviderPreference,
3413
+ hostAppsCachePath2 as hostAppsCachePath,
3414
+ hostAppsFingerprint,
2423
3415
  inspectAndRepairWorkspace,
3416
+ inspectConfigOnly,
2424
3417
  isDevMode,
2425
3418
  isIgnoredRelative,
2426
3419
  isInsideWorkspace,
2427
3420
  isPathAllowed,
3421
+ maintainerProHome,
3422
+ mergeDesiredHostApps,
3423
+ normalizeEnvMaps,
3424
+ normalizeHostApp,
3425
+ normalizeHostApps,
2428
3426
  normalizeIgnorePaths,
2429
3427
  parentChainForTurn,
2430
3428
  parseAiResponse,
2431
3429
  parseIgnorePathsEnv,
3430
+ parsePort,
2432
3431
  previewText,
3432
+ hostAppsCachePath as projectHostAppsPath,
3433
+ projectIdForFolder,
3434
+ projectUploadsDir,
3435
+ proposeHostAppsFromConfig,
2433
3436
  providerLabel,
3437
+ readCollaboraterApps,
3438
+ readHostAppsCache,
3439
+ readProjectEnvLayers,
2434
3440
  renderManagedIgnoreBlock,
2435
3441
  resolveCliBinary,
3442
+ resolveHostApps,
2436
3443
  resolveIgnorePaths,
2437
3444
  resolveLogLevel,
3445
+ resolveProjectDataDir,
2438
3446
  resolveProvider,
3447
+ sanitizeProjectId,
2439
3448
  saveChatAttachments,
2440
3449
  toNextRoute,
2441
- upsertManagedIgnoreFile
3450
+ upsertManagedIgnoreFile,
3451
+ writeCollaboraterApps,
3452
+ writeHostAppsCache
2442
3453
  };