@maintainer-pro/ai-cli 0.1.5 → 0.1.7

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