create-interview-cockpit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +384 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +530 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +960 -0
  23. package/template/client/src/store.ts +250 -6
  24. package/template/client/src/types.ts +36 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/cockpit.json +1 -1
  27. package/template/server/src/google-drive.ts +39 -3
  28. package/template/server/src/index.ts +954 -52
  29. package/template/server/src/infra-runner.ts +1104 -0
  30. package/template/server/src/storage.ts +22 -3
@@ -30,6 +30,12 @@ import net from "net";
30
30
  import ts from "typescript";
31
31
  import * as storage from "./storage.js";
32
32
  import * as googleDrive from "./google-drive.js";
33
+ import {
34
+ getInfraRun,
35
+ listInfraRuns,
36
+ runInfraAction,
37
+ streamInfraCommand,
38
+ } from "./infra-runner.js";
33
39
 
34
40
  const app = express();
35
41
  app.use(cors());
@@ -38,14 +44,14 @@ app.use(express.json({ limit: "25mb" }));
38
44
  const upload = multer({
39
45
  limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
40
46
  fileFilter: (_req, file, cb) => {
41
- const allowedExt =
42
- /\.(txt|md|ts|tsx|js|jsx|json|css|scss|html|xml|yaml|yml|csv|py|java|cs|go|rs|sql|sh|env|cfg|conf|toml|ini|log|pdf|docx|png|jpg|jpeg|gif|webp|svg)$/i;
43
- const allowedMime =
44
- file.mimetype.startsWith("text/") || file.mimetype.startsWith("image/");
45
- if (allowedExt.test(file.originalname) || allowedMime) {
46
- cb(null, true);
47
+ // Block only clearly non-text binary formats (executables, archives, etc.)
48
+ const blockedExt =
49
+ /\.(exe|dll|so|dylib|bin|zip|tar|gz|bz2|rar|7z|dmg|iso|mp3|mp4|avi|mov|wmv|flac|wav|class|pyc|wasm)$/i;
50
+ if (blockedExt.test(file.originalname)) {
51
+ cb(new Error(`Unsupported file type: ${file.originalname}`));
47
52
  } else {
48
- cb(new Error("Unsupported file type"));
53
+ // Accept everything else — extractText handles unknown extensions gracefully
54
+ cb(null, true);
49
55
  }
50
56
  },
51
57
  });
@@ -226,12 +232,24 @@ app.post("/api/workspaces/:id/drive-subfolders", async (req, res) => {
226
232
  const { name } = req.body as { name?: string };
227
233
  if (!name?.trim()) return res.status(400).json({ error: "name required" });
228
234
  try {
235
+ if (!(await googleDrive.isExportAuthed())) {
236
+ return res.json({
237
+ needsAuth: true,
238
+ authUrl: googleDrive.getExportAuthUrl(),
239
+ });
240
+ }
229
241
  const folder = await googleDrive.createDriveSubfolder(
230
242
  req.params.id,
231
243
  name.trim(),
232
244
  );
233
245
  res.json(folder);
234
246
  } catch (err: any) {
247
+ if (err?.needsReauth) {
248
+ return res.json({
249
+ needsAuth: true,
250
+ authUrl: googleDrive.getExportAuthUrl(),
251
+ });
252
+ }
235
253
  res.status(500).json({ error: err?.message || "Failed to create folder" });
236
254
  }
237
255
  });
@@ -363,6 +381,8 @@ app.patch("/api/topics/:id", async (req, res) => {
363
381
  const topic = topics.find((t) => t.id === req.params.id);
364
382
  if (!topic) return res.status(404).json({ error: "Not found" });
365
383
  if (typeof req.body.name === "string") topic.name = req.body.name;
384
+ if (typeof req.body.systemContext === "string")
385
+ topic.systemContext = req.body.systemContext;
366
386
  await storage.saveTopic(topic);
367
387
  res.json(topic);
368
388
  });
@@ -374,33 +394,29 @@ app.get("/api/workspace/context-files", async (_req, res) => {
374
394
  res.json(files);
375
395
  });
376
396
 
377
- app.post(
378
- "/api/workspace/context-files",
379
- upload.array("files", 20),
380
- async (req, res) => {
381
- try {
382
- const files = req.files as Express.Multer.File[];
383
- if (!files?.length) return res.status(400).json({ error: "No files" });
384
- const results: storage.ContextFile[] = [];
385
- for (const file of files) {
386
- const id = randomUUID();
387
- const text = await extractText(file.buffer, file.originalname);
388
- // Store original bytes for download; extracted text for the LLM
389
- await storage.writeOriginalBlob(id, file.buffer);
390
- const cf = await storage.saveWorkspaceContextFile(
391
- id,
392
- file.originalname,
393
- Buffer.from(text, "utf-8"),
394
- );
395
- results.push(cf);
396
- }
397
- res.json(results);
398
- } catch (err: any) {
399
- console.error("workspace upload error:", err?.message || err);
400
- res.status(500).json({ error: err?.message || "Upload failed" });
397
+ app.post("/api/workspace/context-files", upload.any(), async (req, res) => {
398
+ try {
399
+ const files = req.files as Express.Multer.File[];
400
+ if (!files?.length) return res.status(400).json({ error: "No files" });
401
+ const results: storage.ContextFile[] = [];
402
+ for (const file of files) {
403
+ const id = randomUUID();
404
+ const text = await extractText(file.buffer, file.originalname);
405
+ // Store original bytes for download; extracted text for the LLM
406
+ await storage.writeOriginalBlob(id, file.buffer);
407
+ const cf = await storage.saveWorkspaceContextFile(
408
+ id,
409
+ file.originalname,
410
+ Buffer.from(text, "utf-8"),
411
+ );
412
+ results.push(cf);
401
413
  }
402
- },
403
- );
414
+ res.json(results);
415
+ } catch (err: any) {
416
+ console.error("workspace upload error:", err?.message || err);
417
+ res.status(500).json({ error: err?.message || "Upload failed" });
418
+ }
419
+ });
404
420
 
405
421
  app.delete("/api/workspace/context-files/:fileId", async (req, res) => {
406
422
  await storage.deleteWorkspaceContextFile(req.params.fileId);
@@ -440,7 +456,7 @@ app.get("/api/workspace/context-files/:fileId/download", async (req, res) => {
440
456
 
441
457
  app.post(
442
458
  "/api/topics/:topicId/context-files",
443
- upload.array("files", 20),
459
+ upload.any(),
444
460
  async (req, res) => {
445
461
  try {
446
462
  const files = req.files as Express.Multer.File[];
@@ -538,12 +554,45 @@ app.patch("/api/questions/:id", async (req, res) => {
538
554
  if (req.body.systemContext !== undefined)
539
555
  q.systemContext = req.body.systemContext;
540
556
  if (req.body.title !== undefined) q.title = req.body.title;
541
- if (req.body.parentQuestionId !== undefined)
542
- q.parentQuestionId = req.body.parentQuestionId;
557
+ if (req.body.parentQuestionId !== undefined) {
558
+ const nextParentId = req.body.parentQuestionId as string | null;
559
+ if (nextParentId === q.id) {
560
+ return res.status(400).json({ error: "A question cannot parent itself" });
561
+ }
562
+ if (nextParentId != null) {
563
+ const allInTopic = await storage.getQuestionsByTopic(q.topicId);
564
+ const parentExists = allInTopic.some(
565
+ (candidate) => candidate.id === nextParentId,
566
+ );
567
+ if (!parentExists) {
568
+ return res
569
+ .status(400)
570
+ .json({ error: "Selected parent question was not found" });
571
+ }
572
+ const descendantIds = new Set<string>();
573
+ const visit = (parentId: string) => {
574
+ for (const candidate of allInTopic) {
575
+ if (candidate.parentQuestionId !== parentId) continue;
576
+ if (descendantIds.has(candidate.id)) continue;
577
+ descendantIds.add(candidate.id);
578
+ visit(candidate.id);
579
+ }
580
+ };
581
+ visit(q.id);
582
+ if (descendantIds.has(nextParentId)) {
583
+ return res.status(400).json({
584
+ error: "A question cannot be moved under one of its descendants",
585
+ });
586
+ }
587
+ }
588
+ q.parentQuestionId = nextParentId ?? undefined;
589
+ }
543
590
  if (req.body.messages !== undefined) q.messages = req.body.messages;
544
591
  if (req.body.annotations !== undefined) q.annotations = req.body.annotations;
545
592
  if (req.body.readingBookmark !== undefined)
546
593
  q.readingBookmark = req.body.readingBookmark;
594
+ if (req.body.linkedConversationIds !== undefined)
595
+ q.linkedConversationIds = req.body.linkedConversationIds;
547
596
  await storage.saveQuestion(q);
548
597
  res.json(q);
549
598
  });
@@ -598,7 +647,7 @@ app.patch("/api/questions/:id/code-annotations", async (req, res) => {
598
647
 
599
648
  app.post(
600
649
  "/api/questions/:questionId/context-files",
601
- upload.array("files", 20),
650
+ upload.any(),
602
651
  async (req, res) => {
603
652
  try {
604
653
  const files = req.files as Express.Multer.File[];
@@ -641,15 +690,31 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
641
690
  code: string;
642
691
  language: string;
643
692
  label: string;
644
- origin: "user" | "ai" | "sandbox";
693
+ origin:
694
+ | "user"
695
+ | "ai"
696
+ | "sandbox"
697
+ | "infra"
698
+ | "react"
699
+ | "nextjs"
700
+ | "module-federation";
645
701
  };
646
702
  if (typeof code !== "string" || !code.trim()) {
647
703
  return res.status(400).json({ error: "code is required" });
648
704
  }
649
- if (origin !== "user" && origin !== "ai" && origin !== "sandbox") {
650
- return res
651
- .status(400)
652
- .json({ error: "origin must be 'user', 'ai', or 'sandbox'" });
705
+ if (
706
+ origin !== "user" &&
707
+ origin !== "ai" &&
708
+ origin !== "sandbox" &&
709
+ origin !== "infra" &&
710
+ origin !== "react" &&
711
+ origin !== "nextjs" &&
712
+ origin !== "module-federation"
713
+ ) {
714
+ return res.status(400).json({
715
+ error:
716
+ "origin must be 'user', 'ai', 'sandbox', 'infra', 'react', 'nextjs', or 'module-federation'",
717
+ });
653
718
  }
654
719
  try {
655
720
  const id = randomUUID();
@@ -659,7 +724,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
659
724
  ? "ts"
660
725
  : language === "javascript"
661
726
  ? "js"
662
- : "txt";
727
+ : language === "infra"
728
+ ? "infra.json"
729
+ : "txt";
663
730
  const fileName = `${safeLabel}.${ext}`;
664
731
  const cf = await storage.saveQuestionContextFile(
665
732
  req.params.questionId,
@@ -784,6 +851,292 @@ app.get("/api/context-files/all", async (_req, res) => {
784
851
  res.json(deduped);
785
852
  });
786
853
 
854
+ app.post("/api/infra/run", async (req, res) => {
855
+ const { questionId, fileId, label, action, workspace } = req.body as {
856
+ questionId?: string;
857
+ fileId?: string;
858
+ label?: string;
859
+ action?: "validate" | "plan";
860
+ workspace?: unknown;
861
+ };
862
+
863
+ if (action !== "validate" && action !== "plan") {
864
+ return res
865
+ .status(400)
866
+ .json({ error: "action must be 'validate' or 'plan'" });
867
+ }
868
+
869
+ try {
870
+ const run = await runInfraAction({
871
+ questionId,
872
+ fileId,
873
+ label,
874
+ action,
875
+ workspace,
876
+ });
877
+ res.json(run);
878
+ } catch (err: any) {
879
+ res
880
+ .status(400)
881
+ .json({ error: err?.message || "Failed to run infra action" });
882
+ }
883
+ });
884
+
885
+ // ─── Infra Lab AI Ask (streaming) ───────────────────────────────────────────
886
+ app.post("/api/infra/ask", async (req, res) => {
887
+ const { messages, workspace, questionId } = req.body as {
888
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
889
+ workspace?: Record<string, string>; // { "main.tf": "..." }
890
+ questionId?: string;
891
+ };
892
+
893
+ if (!Array.isArray(messages) || messages.length === 0) {
894
+ return res.status(400).json({ error: "messages is required" });
895
+ }
896
+
897
+ const isGoogle = ["google", "gemini"].includes(
898
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
899
+ );
900
+ const aiSettings = await storage.getAiSettings();
901
+
902
+ // Build workspace context block
903
+ let workspaceBlock = "";
904
+ if (workspace && typeof workspace === "object") {
905
+ const entries = Object.entries(workspace).filter(
906
+ ([k, v]) => typeof k === "string" && typeof v === "string",
907
+ );
908
+ if (entries.length > 0) {
909
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
910
+ for (const [name, content] of entries) {
911
+ workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
912
+ }
913
+ }
914
+ }
915
+
916
+ const system =
917
+ `You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
918
+ `The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
919
+ `When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
920
+ `Be concise unless the user asks for a deeper explanation.` +
921
+ workspaceBlock;
922
+
923
+ try {
924
+ const result = streamText({
925
+ model: getModel(),
926
+ maxOutputTokens: 2000,
927
+ ...(isGoogle && {
928
+ providerOptions: {
929
+ google: {
930
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
931
+ },
932
+ },
933
+ }),
934
+ system,
935
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
936
+ });
937
+
938
+ result.pipeUIMessageStreamToResponse(res);
939
+ } catch (err: any) {
940
+ console.error("infra/ask error:", err?.message || err);
941
+ if (!res.headersSent) {
942
+ res
943
+ .status(500)
944
+ .json({ error: err?.message || "Failed to generate response" });
945
+ }
946
+ }
947
+ });
948
+
949
+ app.post("/api/notes/ask", async (req, res) => {
950
+ const { messages, noteContent, noteName } = req.body as {
951
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
952
+ noteContent?: string;
953
+ noteName?: string;
954
+ };
955
+
956
+ if (!Array.isArray(messages) || messages.length === 0) {
957
+ return res.status(400).json({ error: "messages is required" });
958
+ }
959
+
960
+ const isGoogle = ["google", "gemini"].includes(
961
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
962
+ );
963
+ const aiSettings = await storage.getAiSettings();
964
+
965
+ const noteBlock = noteContent?.trim()
966
+ ? `\n\n--- Note: ${noteName || "Untitled"} ---\n${noteContent}\n---`
967
+ : "";
968
+
969
+ const system =
970
+ `You are a knowledgeable study assistant helping the user understand and improve their notes.\n` +
971
+ `Answer questions about the note content clearly. Help the user expand on ideas, clarify concepts, or drill deeper.\n` +
972
+ `Be concise unless the user asks for a deeper explanation.` +
973
+ noteBlock;
974
+
975
+ try {
976
+ const result = streamText({
977
+ model: getModel(),
978
+ maxOutputTokens: 2000,
979
+ ...(isGoogle && {
980
+ providerOptions: {
981
+ google: {
982
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
983
+ },
984
+ },
985
+ }),
986
+ system,
987
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
988
+ });
989
+
990
+ result.pipeUIMessageStreamToResponse(res);
991
+ } catch (err: any) {
992
+ console.error("notes/ask error:", err?.message || err);
993
+ if (!res.headersSent) {
994
+ res
995
+ .status(500)
996
+ .json({ error: err?.message || "Failed to generate response" });
997
+ }
998
+ }
999
+ });
1000
+
1001
+ app.post("/api/frontend-lab/ask", async (req, res) => {
1002
+ const { messages, workspace, labType, questionId } = req.body as {
1003
+ messages?: Array<{ role: "user" | "assistant"; content: string }>;
1004
+ workspace?: Record<string, string>;
1005
+ labType?: "react" | "nextjs" | "module-federation";
1006
+ questionId?: string;
1007
+ };
1008
+
1009
+ if (!Array.isArray(messages) || messages.length === 0) {
1010
+ return res.status(400).json({ error: "messages is required" });
1011
+ }
1012
+
1013
+ const isGoogle = ["google", "gemini"].includes(
1014
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
1015
+ );
1016
+ const aiSettings = await storage.getAiSettings();
1017
+
1018
+ const typeLabel =
1019
+ labType === "nextjs"
1020
+ ? "Next.js App Router"
1021
+ : labType === "module-federation"
1022
+ ? "Webpack Module Federation"
1023
+ : "React + TypeScript";
1024
+
1025
+ let workspaceBlock = "";
1026
+ if (workspace && typeof workspace === "object") {
1027
+ const entries = Object.entries(workspace).filter(
1028
+ ([k, v]) => typeof k === "string" && typeof v === "string",
1029
+ );
1030
+ if (entries.length > 0) {
1031
+ workspaceBlock = "\n\n--- Workspace Files ---\n";
1032
+ for (const [name, content] of entries) {
1033
+ workspaceBlock += `\n### ${name}\n\`\`\`tsx\n${content}\n\`\`\`\n`;
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ const system =
1039
+ `You are an expert ${typeLabel} tutor acting as a coding assistant inside a hands-on lab.\n` +
1040
+ `The user is practising ${typeLabel} concepts. Help them understand their code, fix bugs, and learn best practices.\n` +
1041
+ `When suggesting code changes, use fenced TypeScript/TSX code blocks.\n` +
1042
+ `Be concise unless the user asks for deeper explanation.` +
1043
+ workspaceBlock;
1044
+
1045
+ try {
1046
+ const result = streamText({
1047
+ model: getModel(),
1048
+ maxOutputTokens: 2000,
1049
+ ...(isGoogle && {
1050
+ providerOptions: {
1051
+ google: {
1052
+ thinkingConfig: { thinkingBudget: aiSettings.thinkingBudget ?? 0 },
1053
+ },
1054
+ },
1055
+ }),
1056
+ system,
1057
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
1058
+ });
1059
+
1060
+ result.pipeUIMessageStreamToResponse(res);
1061
+ } catch (err: any) {
1062
+ console.error("frontend-lab/ask error:", err?.message || err);
1063
+ if (!res.headersSent) {
1064
+ res
1065
+ .status(500)
1066
+ .json({ error: err?.message || "Failed to generate response" });
1067
+ }
1068
+ }
1069
+ });
1070
+
1071
+ app.post("/api/infra/command-stream", async (req, res) => {
1072
+ const { questionId, fileId, label, command, workspace } = req.body as {
1073
+ questionId?: string;
1074
+ fileId?: string;
1075
+ label?: string;
1076
+ command?: string;
1077
+ workspace?: unknown;
1078
+ };
1079
+
1080
+ res.setHeader("Content-Type", "text/event-stream");
1081
+ res.setHeader("Cache-Control", "no-cache");
1082
+ res.setHeader("Connection", "keep-alive");
1083
+ res.flushHeaders();
1084
+
1085
+ const send = (payload: unknown) => {
1086
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1087
+ };
1088
+
1089
+ if (typeof command !== "string" || !command.trim()) {
1090
+ send({ type: "error", error: "command is required" });
1091
+ res.end();
1092
+ return;
1093
+ }
1094
+
1095
+ try {
1096
+ const run = await streamInfraCommand({
1097
+ questionId,
1098
+ fileId,
1099
+ label,
1100
+ command,
1101
+ workspace,
1102
+ onMessage: send,
1103
+ });
1104
+ send({ type: "complete", run });
1105
+ } catch (err: any) {
1106
+ send({
1107
+ type: "error",
1108
+ error: err?.message || "Failed to run infra command",
1109
+ });
1110
+ }
1111
+
1112
+ res.end();
1113
+ });
1114
+
1115
+ app.get("/api/infra/runs", async (req, res) => {
1116
+ const fileId =
1117
+ typeof req.query.fileId === "string" ? req.query.fileId : undefined;
1118
+ const questionId =
1119
+ typeof req.query.questionId === "string" ? req.query.questionId : undefined;
1120
+
1121
+ try {
1122
+ const runs = await listInfraRuns({ fileId, questionId });
1123
+ res.json(runs);
1124
+ } catch (err: any) {
1125
+ res
1126
+ .status(500)
1127
+ .json({ error: err?.message || "Failed to list infra runs" });
1128
+ }
1129
+ });
1130
+
1131
+ app.get("/api/infra/runs/:runId", async (req, res) => {
1132
+ try {
1133
+ const run = await getInfraRun(req.params.runId);
1134
+ res.json(run);
1135
+ } catch {
1136
+ res.status(404).json({ error: "Infra run not found" });
1137
+ }
1138
+ });
1139
+
787
1140
  // Link an existing file to a topic without re-uploading
788
1141
  app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
789
1142
  const { fileId, originalName } = req.body as {
@@ -900,6 +1253,10 @@ app.patch("/api/settings", async (req, res) => {
900
1253
  typeof req.body.vizGuide === "string"
901
1254
  ? req.body.vizGuide
902
1255
  : current.vizGuide,
1256
+ plotGuide:
1257
+ typeof req.body.plotGuide === "string"
1258
+ ? req.body.plotGuide
1259
+ : current.plotGuide,
903
1260
  promptGroups:
904
1261
  req.body.promptGroups != null
905
1262
  ? req.body.promptGroups
@@ -934,10 +1291,11 @@ app.post("/api/chat", async (req, res) => {
934
1291
  codeSnippets,
935
1292
  systemContext,
936
1293
  responseLength,
1294
+ linkedConversationIds,
937
1295
  } = req.body;
938
1296
 
939
1297
  const aiSettings = await storage.getAiSettings();
940
- const { responseProfiles, vizGuide } = aiSettings;
1298
+ const { responseProfiles, vizGuide, plotGuide } = aiSettings;
941
1299
  const selectedResponseProfile = responseProfiles[responseLength] ??
942
1300
  responseProfiles["normal"] ?? { maxOutputTokens: 3000, maxSteps: 5 };
943
1301
 
@@ -952,6 +1310,34 @@ app.post("/api/chat", async (req, res) => {
952
1310
  if (systemContext) {
953
1311
  system += `\n\n--- Additional Context ---\n${systemContext}`;
954
1312
  }
1313
+ if (
1314
+ Array.isArray(linkedConversationIds) &&
1315
+ linkedConversationIds.length > 0
1316
+ ) {
1317
+ const linkedQuestions = await Promise.all(
1318
+ linkedConversationIds.map((id: string) => storage.getQuestion(id)),
1319
+ );
1320
+ const valid = linkedQuestions.filter(
1321
+ (q) => q && q.messages && q.messages.length > 0,
1322
+ ) as storage.Question[];
1323
+ if (valid.length > 0) {
1324
+ system += `\n\n--- Linked Conversation${valid.length > 1 ? "s" : ""} (read-only reference) ---\nThe user has linked the following conversation${valid.length > 1 ? "s" : ""} from this topic as background context. Do not treat them as the current conversation — they are reference material only.\n`;
1325
+ for (const lq of valid) {
1326
+ system += `\n### "${lq.title}"\n`;
1327
+ for (const msg of lq.messages) {
1328
+ const role = msg.role === "user" ? "User" : "Assistant";
1329
+ const text =
1330
+ typeof msg.content === "string"
1331
+ ? msg.content
1332
+ : (msg.parts
1333
+ ?.filter((p: any) => p.type === "text")
1334
+ .map((p: any) => p.text)
1335
+ .join("") ?? "");
1336
+ system += `**${role}:** ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}\n\n`;
1337
+ }
1338
+ }
1339
+ }
1340
+ }
955
1341
 
956
1342
  // Build a file registry: id → { label, reader }
957
1343
  // The model sees the list of file names and can call readFile(id) for any of them.
@@ -969,10 +1355,13 @@ app.post("/api/chat", async (req, res) => {
969
1355
  });
970
1356
  }
971
1357
 
972
- // Topic-level uploaded files
1358
+ // Topic-level uploaded files + topic-wide system prompt
973
1359
  if (topicId) {
974
1360
  const topics = await storage.getTopics();
975
1361
  const topic = topics.find((t) => t.id === topicId);
1362
+ if (topic?.systemContext?.trim()) {
1363
+ system += `\n\n--- Topic-Wide System Context ---\n${topic.systemContext.trim()}`;
1364
+ }
976
1365
  if (topic?.contextFiles?.length) {
977
1366
  for (const cf of topic.contextFiles) {
978
1367
  fileRegistry.set(cf.id, {
@@ -1147,6 +1536,12 @@ Examples (illustrative only — use real ids and names from the list above):
1147
1536
  inputSchema: z.object({}),
1148
1537
  execute: async () => ({ guide: vizGuide }),
1149
1538
  }),
1539
+ getPlotGuide: tool({
1540
+ description:
1541
+ "Get the full plotting spec reference. Call this before writing a ```plot block so you use the supported schema for graphs, curves, and charts.",
1542
+ inputSchema: z.object({}),
1543
+ execute: async () => ({ guide: plotGuide }),
1544
+ }),
1150
1545
  ...(fileRegistry.size > 0
1151
1546
  ? {
1152
1547
  readFile: tool({
@@ -1620,13 +2015,13 @@ app.post("/api/fix-viz", async (req, res) => {
1620
2015
 
1621
2016
  const { text } = await generateText({
1622
2017
  model: getModel(),
1623
- maxOutputTokens: 2500,
2018
+ maxOutputTokens: 8000,
1624
2019
  ...(isGoogle && {
1625
2020
  providerOptions: {
1626
2021
  google: { thinkingConfig: { thinkingBudget: 0 } },
1627
2022
  },
1628
2023
  }),
1629
- prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section. If you want to show data flowing from B to A but only have the edge A→B, either flip the chain to [A, B] (showing the reverse path) or add a new reverse edge. Never use a chain direction that has no matching directed edge.\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- Typography: replace en-dashes (–, U+2013) and em-dashes (—, U+2014) with a plain ASCII hyphen (-). Replace curly/smart quotes with straight ASCII quotes. These typographic Unicode characters cause the YAML parser to fail with "Missing closing quote" errors.\n- YAML string quoting: if a label or value contains special characters (hyphens like --, colons, brackets, slashes, or leading/trailing spaces), wrap the ENTIRE value in double quotes. Do NOT use single quotes single-quoted YAML strings must be self-contained; trailing words after the closing quote cause parse errors. Example of wrong: label: 'update-index --skip-worktree' changes file metadata Example of correct: label: "update-index --skip-worktree changes file metadata"\n- Never split a label across multiple YAML lines unless using a literal block scalar (| or >)\n\nReturn ONLY the corrected YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.\n\nFailed spec:\n${spec}`,
2024
+ prompt: `The following viz diagram spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}\n\nFix the spec so it renders correctly. Common issues to check:\n- x/y coordinates must be numbers, not strings\n- node ids must be kebab-case with no spaces\n- Truncated spec: if the spec looks cut off mid-label or mid-step, complete all missing steps before returning — output the full corrected spec.\n- Signal chain direction: a chain [A, B] animates along the edge FROM A TO B. That directed edge (from: A, to: B) MUST exist in the edges section.\n- chain arrays in autoSignals/signals must reference valid node ids\n- YAML flow sequences ([a, b]) must be correctly indented and closed\n- Do NOT mix autoSignals and steps in the same spec\n- Typography: replace en-dashes (–, U+2013) and em-dashes (—, U+2014) with a plain ASCII hyphen (-). Replace curly/smart quotes with straight ASCII quotes.\n- YAML string quoting: ALWAYS use double quotes for ALL string values — NEVER single quotes. Single-quoted YAML values (e.g. fill: '#fff') cause "Flow map must end with a }" parse errors in yaml v2. Every string in the spec must use double quotes. Wrong: fill: '#1e40af' Correct: fill: "#1e40af"\n- Inline flow maps must be single-line: any node written as { id: foo, x: 1, ... } MUST have its opening { and closing } on the SAME line with no line breaks inside. The yaml v2 parser throws "Flow map must end with a }" when a flow map spans multiple lines. If a flow map is too long to fit on one line, convert it to block style (indented key: value pairs), but NEVER split a single { ... } across lines.\n- Never split a label across multiple YAML lines unless using a literal block scalar (| or >)\n\nReturn ONLY the corrected YAML (or JSON) spec with no explanation and no markdown fences — just the raw spec.\n\nFailed spec:\n${spec}`,
1630
2025
  });
1631
2026
 
1632
2027
  // Strip any fences the model might have added
@@ -1642,6 +2037,43 @@ app.post("/api/fix-viz", async (req, res) => {
1642
2037
  }
1643
2038
  });
1644
2039
 
2040
+ // ─── Fix Plot ───────────────────────────────────────────
2041
+
2042
+ app.post("/api/fix-plot", async (req, res) => {
2043
+ const { spec, error: renderError } = req.body;
2044
+ if (typeof spec !== "string" || !spec.trim()) {
2045
+ return res.status(400).json({ error: "spec is required" });
2046
+ }
2047
+
2048
+ try {
2049
+ const { plotGuide } = await storage.getAiSettings();
2050
+ const isGoogle = ["google", "gemini"].includes(
2051
+ (process.env.AI_PROVIDER || "openai").toLowerCase(),
2052
+ );
2053
+
2054
+ const { text } = await generateText({
2055
+ model: getModel(),
2056
+ maxOutputTokens: 4000,
2057
+ ...(isGoogle && {
2058
+ providerOptions: {
2059
+ google: { thinkingConfig: { thinkingBudget: 0 } },
2060
+ },
2061
+ }),
2062
+ prompt: `The following plot spec failed to render.${renderError ? `\n\nRender error:\n${renderError}` : ""}${plotGuide ? `\n\nSupported plotting guide:\n${plotGuide}` : ""}\n\nFix the spec so it renders correctly as a Vega-Lite plot.\nRules:\n- Return ONLY the corrected JSON or YAML spec with no explanation and no markdown fences\n- The spec must describe a valid Vega-Lite chart\n- Prefer simple, valid encodings over clever transforms\n- Ensure x and y encodings include valid field/type definitions when needed\n- If the chart represents a mathematical curve, provide concrete sampled data points instead of symbolic expressions\n- Keep the dark-theme friendly defaults implicit; do not add custom JS callbacks or unsupported runtime code\n\nFailed spec:\n${spec}`,
2063
+ });
2064
+
2065
+ const fixed = text
2066
+ .replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
2067
+ .replace(/```\s*$/, "")
2068
+ .trim();
2069
+
2070
+ res.json({ spec: fixed });
2071
+ } catch (err: any) {
2072
+ console.error("fix-plot error:", err?.message || err);
2073
+ res.status(500).json({ error: err?.message || "Failed to fix plot" });
2074
+ }
2075
+ });
2076
+
1645
2077
  // ─── Refine Viz ─────────────────────────────────────────
1646
2078
 
1647
2079
  app.post("/api/refine-viz", async (req, res) => {
@@ -1685,7 +2117,7 @@ Rules:
1685
2117
 
1686
2118
  const { text } = await generateText({
1687
2119
  model: getModel(),
1688
- maxOutputTokens: 1600,
2120
+ maxOutputTokens: 8000,
1689
2121
  ...(isGoogle && {
1690
2122
  providerOptions: {
1691
2123
  google: { thinkingConfig: { thinkingBudget: 0 } },
@@ -1809,10 +2241,10 @@ app.post("/api/run-code", async (req, res) => {
1809
2241
  }, RUN_TIMEOUT_MS);
1810
2242
 
1811
2243
  child.stdout.on("data", (chunk: Buffer) => {
1812
- stdoutLines.push(chunk.toString());
2244
+ stdoutLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
1813
2245
  });
1814
2246
  child.stderr.on("data", (chunk: Buffer) => {
1815
- stderrLines.push(chunk.toString());
2247
+ stderrLines.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
1816
2248
  });
1817
2249
  child.on("close", () => {
1818
2250
  clearTimeout(killTimer);
@@ -1843,6 +2275,472 @@ interface SandboxEntry {
1843
2275
  const sandboxes = new Map<string, SandboxEntry>();
1844
2276
  const SANDBOX_DIR = path.join(__dirname, "..", ".sandbox-tmp");
1845
2277
 
2278
+ // ── Next.js real-server sandboxes ────────────────────────────────────────────
2279
+ // We symlink node_modules from the pre-installed npx cache so there's
2280
+ // zero npm-install cost per sandbox.
2281
+ interface NextSandboxEntry {
2282
+ child: ReturnType<typeof spawn>;
2283
+ port: number;
2284
+ dir: string;
2285
+ logs: string[];
2286
+ ready: boolean;
2287
+ }
2288
+ const nextSandboxes = new Map<string, NextSandboxEntry>();
2289
+ const NEXT_NPX_DIR = path.join(os.homedir(), ".npm/_npx/8b377f6eec906bc4");
2290
+ const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
2291
+ // Sandboxes live INSIDE the npx cache dir so Next.js finds node_modules by
2292
+ // walking up the directory tree — no symlinks needed, no Turbopack restrictions.
2293
+ const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
2294
+
2295
+ interface ModuleFederationSandboxEntry {
2296
+ child: ReturnType<typeof spawn>;
2297
+ dir: string;
2298
+ hostUrl: string;
2299
+ appUrls: Record<string, string>;
2300
+ workspaceFiles: Set<string>;
2301
+ logs: string[];
2302
+ ready: boolean;
2303
+ }
2304
+
2305
+ const moduleFederationSandboxes = new Map<
2306
+ string,
2307
+ ModuleFederationSandboxEntry
2308
+ >();
2309
+ const MODULE_FEDERATION_SANDBOX_BASE = path.join(
2310
+ os.tmpdir(),
2311
+ "interview-cockpit-module-federation",
2312
+ );
2313
+
2314
+ function appendSandboxLog(logs: string[], text: string): string {
2315
+ const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2316
+ logs.push(clean);
2317
+ if (logs.length > 400) {
2318
+ logs.splice(0, logs.length - 400);
2319
+ }
2320
+ return clean;
2321
+ }
2322
+
2323
+ function npmCommand(): string {
2324
+ return process.platform === "win32" ? "npm.cmd" : "npm";
2325
+ }
2326
+
2327
+ async function writeModuleFederationSandboxFiles(
2328
+ dir: string,
2329
+ files: Record<string, string>,
2330
+ ) {
2331
+ for (const [filePath, content] of Object.entries(files)) {
2332
+ const fullPath = path.join(dir, filePath);
2333
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2334
+ await fs.writeFile(fullPath, content, "utf8");
2335
+ }
2336
+ }
2337
+
2338
+ async function runLoggedCommand(
2339
+ command: string,
2340
+ args: string[],
2341
+ options: { cwd: string; env?: NodeJS.ProcessEnv },
2342
+ logs: string[],
2343
+ ): Promise<void> {
2344
+ await new Promise<void>((resolve, reject) => {
2345
+ const child = spawn(command, args, {
2346
+ cwd: options.cwd,
2347
+ env: options.env,
2348
+ });
2349
+
2350
+ child.stdout.on("data", (chunk: Buffer) => {
2351
+ appendSandboxLog(logs, chunk.toString());
2352
+ });
2353
+ child.stderr.on("data", (chunk: Buffer) => {
2354
+ appendSandboxLog(logs, chunk.toString());
2355
+ });
2356
+ child.on("error", reject);
2357
+ child.on("exit", (code) => {
2358
+ if (code === 0) {
2359
+ resolve();
2360
+ return;
2361
+ }
2362
+ reject(
2363
+ new Error(
2364
+ logs.join("").trim() ||
2365
+ `${command} ${args.join(" ")} exited with code ${String(code)}`,
2366
+ ),
2367
+ );
2368
+ });
2369
+ });
2370
+ }
2371
+
2372
+ async function getDistinctPorts(count: number): Promise<number[]> {
2373
+ const ports = new Set<number>();
2374
+ while (ports.size < count) {
2375
+ ports.add(await getFreePort());
2376
+ }
2377
+ return Array.from(ports);
2378
+ }
2379
+
2380
+ /** Write all user files and necessary config into a sandbox directory. */
2381
+ async function writeNextSandboxFiles(
2382
+ dir: string,
2383
+ files: Record<string, string>,
2384
+ ) {
2385
+ // package.json — needed so Next.js can resolve its own packages
2386
+ await fs.writeFile(
2387
+ path.join(dir, "package.json"),
2388
+ JSON.stringify(
2389
+ {
2390
+ name: "nextjs-lab",
2391
+ private: true,
2392
+ type: "module",
2393
+ dependencies: {
2394
+ next: "^16.2.4",
2395
+ react: "^19.0.0",
2396
+ "react-dom": "^19.0.0",
2397
+ },
2398
+ },
2399
+ null,
2400
+ 2,
2401
+ ),
2402
+ );
2403
+ // next.config.ts — set turbopack.root to the NPX cache dir (parent of both
2404
+ // node_modules and .sandboxes). This lets Turbopack find next/package.json
2405
+ // in node_modules while watching files within the sandbox subdirectory.
2406
+ // Setting it to the sandbox dir itself would block node_modules resolution
2407
+ // since node_modules sits above the sandbox root boundary.
2408
+ await fs.writeFile(
2409
+ path.join(dir, "next.config.ts"),
2410
+ `import type { NextConfig } from "next";
2411
+ const c: NextConfig = {
2412
+ turbopack: { root: ${JSON.stringify(NEXT_NPX_DIR)} },
2413
+ async headers() {
2414
+ return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "ALLOWALL" }] }];
2415
+ },
2416
+ };
2417
+ export default c;\n`,
2418
+ );
2419
+ // tsconfig so Next.js TypeScript support works
2420
+ await fs.writeFile(
2421
+ path.join(dir, "tsconfig.json"),
2422
+ JSON.stringify(
2423
+ {
2424
+ compilerOptions: {
2425
+ lib: ["dom", "dom.iterable", "esnext"],
2426
+ allowJs: true,
2427
+ skipLibCheck: true,
2428
+ strict: false,
2429
+ noEmit: true,
2430
+ esModuleInterop: true,
2431
+ module: "esnext",
2432
+ moduleResolution: "bundler",
2433
+ resolveJsonModule: true,
2434
+ isolatedModules: true,
2435
+ jsx: "preserve",
2436
+ incremental: true,
2437
+ plugins: [{ name: "next" }],
2438
+ baseUrl: ".",
2439
+ },
2440
+ include: ["**/*.ts", "**/*.tsx"],
2441
+ exclude: ["node_modules"],
2442
+ },
2443
+ null,
2444
+ 2,
2445
+ ),
2446
+ );
2447
+ // User files
2448
+ for (const [filePath, content] of Object.entries(files)) {
2449
+ const fullPath = path.join(dir, filePath);
2450
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2451
+ await fs.writeFile(fullPath, content, "utf8");
2452
+ }
2453
+ }
2454
+
2455
+ app.post("/api/nextjs/start", async (req, res) => {
2456
+ const { files } = req.body as { files: Record<string, string> };
2457
+ if (!files || typeof files !== "object") {
2458
+ return res.status(400).json({ error: "files is required" });
2459
+ }
2460
+
2461
+ // Use the actual file path (not the .bin symlink) so Node's __filename resolves
2462
+ // correctly and internal require("../server/require-hook") works.
2463
+ const nextBin = path.join(NEXT_MODULES_DIR, "next", "dist", "bin", "next");
2464
+ const nextExists = await fs
2465
+ .access(nextBin)
2466
+ .then(() => true)
2467
+ .catch(() => false);
2468
+ if (!nextExists) {
2469
+ return res.status(503).json({
2470
+ error:
2471
+ "Next.js is not available in the npx cache. Run `npx next --version` once to install it.",
2472
+ });
2473
+ }
2474
+
2475
+ const id = randomUUID();
2476
+ const dir = path.join(NEXT_SANDBOX_BASE, id);
2477
+ await fs.mkdir(dir, { recursive: true });
2478
+
2479
+ await writeNextSandboxFiles(dir, files);
2480
+
2481
+ const port = await getFreePort();
2482
+ const logs: string[] = [];
2483
+ // Run via `node <actual-bin>` — sandbox dir is inside the npx cache so
2484
+ // node_modules is resolved naturally two levels up; no symlinks required.
2485
+ const child = spawn(
2486
+ process.execPath,
2487
+ [nextBin, "dev", "--port", String(port)],
2488
+ {
2489
+ cwd: dir,
2490
+ env: {
2491
+ ...process.env,
2492
+ NEXT_TELEMETRY_DISABLED: "1",
2493
+ NODE_NO_WARNINGS: "1",
2494
+ },
2495
+ },
2496
+ );
2497
+
2498
+ const entry: NextSandboxEntry = {
2499
+ child,
2500
+ port,
2501
+ dir,
2502
+ logs,
2503
+ ready: false,
2504
+ };
2505
+ nextSandboxes.set(id, entry);
2506
+
2507
+ const markReady = (text: string) => {
2508
+ if (!entry.ready && /ready|started server on|Local:/i.test(text)) {
2509
+ entry.ready = true;
2510
+ }
2511
+ };
2512
+ child.stdout.on("data", (chunk: Buffer) => {
2513
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2514
+ logs.push(t);
2515
+ markReady(t);
2516
+ });
2517
+ child.stderr.on("data", (chunk: Buffer) => {
2518
+ const t = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
2519
+ logs.push(t);
2520
+ markReady(t);
2521
+ });
2522
+ child.on("exit", () => {
2523
+ nextSandboxes.delete(id);
2524
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
2525
+ });
2526
+
2527
+ // Wait up to 30 s for Next.js to be ready
2528
+ const deadline = Date.now() + 30_000;
2529
+ while (!entry.ready && Date.now() < deadline) {
2530
+ await new Promise((r) => setTimeout(r, 400));
2531
+ if (!nextSandboxes.has(id)) {
2532
+ return res
2533
+ .status(500)
2534
+ .json({ error: logs.join("").trim() || "Next.js server exited" });
2535
+ }
2536
+ }
2537
+ if (!entry.ready) {
2538
+ return res
2539
+ .status(504)
2540
+ .json({ error: "Next.js did not start in time", logs });
2541
+ }
2542
+
2543
+ res.json({ id, port, url: `http://localhost:${port}` });
2544
+ });
2545
+
2546
+ app.post("/api/nextjs/:id/update-files", async (req, res) => {
2547
+ const sb = nextSandboxes.get(req.params.id);
2548
+ if (!sb) return res.status(404).json({ error: "Sandbox not found" });
2549
+ const { files } = req.body as { files: Record<string, string> };
2550
+ if (!files) return res.status(400).json({ error: "files is required" });
2551
+ for (const [filePath, content] of Object.entries(files)) {
2552
+ const fullPath = path.join(sb.dir, filePath);
2553
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2554
+ await fs.writeFile(fullPath, content, "utf8");
2555
+ }
2556
+ res.json({ ok: true });
2557
+ });
2558
+
2559
+ app.get("/api/nextjs/:id/status", (req, res) => {
2560
+ const sb = nextSandboxes.get(req.params.id);
2561
+ if (!sb) return res.json({ running: false });
2562
+ res.json({
2563
+ running: true,
2564
+ ready: sb.ready,
2565
+ port: sb.port,
2566
+ logs: sb.logs.slice(-30),
2567
+ });
2568
+ });
2569
+
2570
+ app.delete("/api/nextjs/:id", async (req, res) => {
2571
+ const sb = nextSandboxes.get(req.params.id);
2572
+ if (sb) {
2573
+ sb.child.kill("SIGTERM");
2574
+ nextSandboxes.delete(req.params.id);
2575
+ await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
2576
+ }
2577
+ res.json({ ok: true });
2578
+ });
2579
+
2580
+ app.post("/api/module-federation/start", async (req, res) => {
2581
+ const { files } = req.body as { files?: Record<string, string> };
2582
+ if (!files || typeof files !== "object") {
2583
+ return res.status(400).json({ error: "files is required" });
2584
+ }
2585
+ if (typeof files["package.json"] !== "string") {
2586
+ return res.status(400).json({ error: "package.json is required" });
2587
+ }
2588
+
2589
+ const id = randomUUID();
2590
+ const dir = path.join(MODULE_FEDERATION_SANDBOX_BASE, id);
2591
+ const logs: string[] = [];
2592
+
2593
+ try {
2594
+ await fs.mkdir(dir, { recursive: true });
2595
+ await writeModuleFederationSandboxFiles(dir, files);
2596
+
2597
+ await runLoggedCommand(
2598
+ npmCommand(),
2599
+ ["install", "--no-audit", "--no-fund", "--prefer-offline"],
2600
+ {
2601
+ cwd: dir,
2602
+ env: {
2603
+ ...process.env,
2604
+ npm_config_update_notifier: "false",
2605
+ },
2606
+ },
2607
+ logs,
2608
+ );
2609
+
2610
+ const [hostPort, profilePort, checkoutPort] = await getDistinctPorts(3);
2611
+ const appUrls = {
2612
+ host: `http://localhost:${hostPort}`,
2613
+ profile: `http://localhost:${profilePort}`,
2614
+ checkout: `http://localhost:${checkoutPort}`,
2615
+ };
2616
+ const readyPorts = new Set<string>();
2617
+
2618
+ const child = spawn(npmCommand(), ["run", "dev"], {
2619
+ cwd: dir,
2620
+ env: {
2621
+ ...process.env,
2622
+ HOST_PORT: String(hostPort),
2623
+ PROFILE_PORT: String(profilePort),
2624
+ CHECKOUT_PORT: String(checkoutPort),
2625
+ npm_config_update_notifier: "false",
2626
+ },
2627
+ });
2628
+
2629
+ const entry: ModuleFederationSandboxEntry = {
2630
+ child,
2631
+ dir,
2632
+ hostUrl: appUrls.host,
2633
+ appUrls,
2634
+ workspaceFiles: new Set(Object.keys(files)),
2635
+ logs,
2636
+ ready: false,
2637
+ };
2638
+
2639
+ const markReady = (text: string) => {
2640
+ if (text.includes(`localhost:${hostPort}`)) readyPorts.add("host");
2641
+ if (text.includes(`localhost:${profilePort}`)) readyPorts.add("profile");
2642
+ if (text.includes(`localhost:${checkoutPort}`))
2643
+ readyPorts.add("checkout");
2644
+ if (readyPorts.size === 3) {
2645
+ entry.ready = true;
2646
+ }
2647
+ };
2648
+
2649
+ child.stdout.on("data", (chunk: Buffer) => {
2650
+ markReady(appendSandboxLog(logs, chunk.toString()));
2651
+ });
2652
+ child.stderr.on("data", (chunk: Buffer) => {
2653
+ markReady(appendSandboxLog(logs, chunk.toString()));
2654
+ });
2655
+ child.on("exit", () => {
2656
+ moduleFederationSandboxes.delete(id);
2657
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
2658
+ });
2659
+
2660
+ moduleFederationSandboxes.set(id, entry);
2661
+
2662
+ const deadline = Date.now() + 90_000;
2663
+ while (!entry.ready && Date.now() < deadline) {
2664
+ await new Promise((resolve) => setTimeout(resolve, 500));
2665
+ if (!moduleFederationSandboxes.has(id)) {
2666
+ return res.status(500).json({
2667
+ error: logs.join("").trim() || "Webpack module federation lab exited",
2668
+ });
2669
+ }
2670
+ }
2671
+
2672
+ if (!entry.ready) {
2673
+ return res.status(504).json({
2674
+ error: "Webpack module federation lab did not start in time",
2675
+ logs,
2676
+ });
2677
+ }
2678
+
2679
+ res.json({
2680
+ id,
2681
+ hostUrl: entry.hostUrl,
2682
+ appUrls: entry.appUrls,
2683
+ });
2684
+ } catch (error: any) {
2685
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
2686
+ res.status(500).json({
2687
+ error:
2688
+ logs.join("").trim() ||
2689
+ error?.message ||
2690
+ "Failed to start webpack module federation lab",
2691
+ });
2692
+ }
2693
+ });
2694
+
2695
+ app.post("/api/module-federation/:id/update-files", async (req, res) => {
2696
+ const sandbox = moduleFederationSandboxes.get(req.params.id);
2697
+ if (!sandbox) return res.status(404).json({ error: "Sandbox not found" });
2698
+
2699
+ const { files } = req.body as { files?: Record<string, string> };
2700
+ if (!files || typeof files !== "object") {
2701
+ return res.status(400).json({ error: "files is required" });
2702
+ }
2703
+
2704
+ const nextFiles = new Set(Object.keys(files));
2705
+ await Promise.all(
2706
+ Array.from(sandbox.workspaceFiles)
2707
+ .filter((filePath) => !nextFiles.has(filePath))
2708
+ .map((filePath) =>
2709
+ fs
2710
+ .rm(path.join(sandbox.dir, filePath), { force: true })
2711
+ .catch(() => {}),
2712
+ ),
2713
+ );
2714
+ await writeModuleFederationSandboxFiles(sandbox.dir, files);
2715
+ sandbox.workspaceFiles = nextFiles;
2716
+ res.json({ ok: true });
2717
+ });
2718
+
2719
+ app.get("/api/module-federation/:id/status", (req, res) => {
2720
+ const sandbox = moduleFederationSandboxes.get(req.params.id);
2721
+ if (!sandbox) {
2722
+ return res.json({ running: false });
2723
+ }
2724
+
2725
+ res.json({
2726
+ running: true,
2727
+ ready: sandbox.ready,
2728
+ hostUrl: sandbox.hostUrl,
2729
+ appUrls: sandbox.appUrls,
2730
+ logs: sandbox.logs.slice(-80),
2731
+ });
2732
+ });
2733
+
2734
+ app.delete("/api/module-federation/:id", async (req, res) => {
2735
+ const sandbox = moduleFederationSandboxes.get(req.params.id);
2736
+ if (sandbox) {
2737
+ sandbox.child.kill("SIGTERM");
2738
+ moduleFederationSandboxes.delete(req.params.id);
2739
+ await fs.rm(sandbox.dir, { recursive: true, force: true }).catch(() => {});
2740
+ }
2741
+ res.json({ ok: true });
2742
+ });
2743
+
1846
2744
  async function getFreePort(): Promise<number> {
1847
2745
  return new Promise((resolve, reject) => {
1848
2746
  const srv = net.createServer();
@@ -1893,8 +2791,12 @@ app.post("/api/sandbox/start", async (req, res) => {
1893
2791
  const child = spawn(process.execPath, [tmpFile], {
1894
2792
  env: { ...process.env, PORT: String(port), NODE_NO_WARNINGS: "1" },
1895
2793
  });
1896
- child.stdout.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
1897
- child.stderr.on("data", (chunk: Buffer) => logs.push(chunk.toString()));
2794
+ child.stdout.on("data", (chunk: Buffer) =>
2795
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2796
+ );
2797
+ child.stderr.on("data", (chunk: Buffer) =>
2798
+ logs.push(chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")),
2799
+ );
1898
2800
  child.on("exit", () => {
1899
2801
  sandboxes.delete(id);
1900
2802
  fs.unlink(tmpFile).catch(() => {});