create-interview-cockpit 0.17.3 → 0.18.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.
@@ -36,6 +36,7 @@ import {
36
36
  runInfraAction,
37
37
  streamInfraCommand,
38
38
  } from "./infra-runner.js";
39
+ import { streamGhaCommand } from "./gha-runner.js";
39
40
 
40
41
  const app = express();
41
42
  app.use(cors());
@@ -348,8 +349,25 @@ app.post("/api/workspaces/:id/sync", async (req, res) => {
348
349
  // that reset the in-memory _activeWorkspaceId).
349
350
  storage.setActiveWorkspaceId(req.params.id);
350
351
  const result = await googleDrive.syncWorkspace(req.params.id, extractText);
352
+ if (result.errors.some((error) => /\b403\b/.test(error))) {
353
+ return res.json({
354
+ ...result,
355
+ needsAuth: true,
356
+ authUrl: googleDrive.getExportAuthUrl(),
357
+ });
358
+ }
351
359
  res.json(result);
352
360
  } catch (err: any) {
361
+ if (err?.needsReauth || err?.message === "NEEDS_REAUTH") {
362
+ return res.json({
363
+ needsAuth: true,
364
+ authUrl: googleDrive.getExportAuthUrl(),
365
+ topicsUpserted: 0,
366
+ filesImported: 0,
367
+ filesSkipped: 0,
368
+ errors: ["Google Drive authorization is required to pull this folder."],
369
+ });
370
+ }
353
371
  console.error("[sync]", err);
354
372
  res.status(500).json({ error: err?.message || "Sync failed" });
355
373
  }
@@ -396,19 +414,23 @@ app.get("/api/drive/export-auth", (_req, res) => {
396
414
  res.redirect(googleDrive.getExportAuthUrl());
397
415
  });
398
416
 
399
- app.get("/api/drive/export-callback", async (req, res) => {
417
+ const handleDriveExportCallback = async (req: any, res: any) => {
400
418
  const code = req.query.code as string | undefined;
401
419
  if (!code) return res.status(400).send("Missing code parameter");
402
420
  try {
403
421
  await googleDrive.handleExportCallback(code);
404
- const clientUrl = process.env.CLIENT_URL ?? "http://localhost:5173";
422
+ const clientUrl =
423
+ process.env.CLIENT_URL ??
424
+ `http://localhost:${process.env.CLIENT_PORT || "5173"}`;
405
425
  res.redirect(`${clientUrl}/?export_authed=1`);
406
426
  } catch (err: any) {
407
427
  res
408
428
  .status(500)
409
429
  .send(`OAuth callback failed: ${err?.message || "unknown error"}`);
410
430
  }
411
- });
431
+ };
432
+
433
+ app.get("/api/drive/export-callback", handleDriveExportCallback);
412
434
 
413
435
  app.post("/api/workspaces/:id/export-drive", async (req, res) => {
414
436
  try {
@@ -678,6 +700,133 @@ app.post("/api/topics/:topicId/questions", async (req, res) => {
678
700
  res.json(question);
679
701
  });
680
702
 
703
+ app.post("/api/questions/:id/copy", async (req, res) => {
704
+ const source = await storage.getQuestion(req.params.id);
705
+ if (!source) return res.status(404).json({ error: "Not found" });
706
+
707
+ const targetTopicId =
708
+ typeof req.body.targetTopicId === "string" && req.body.targetTopicId.trim()
709
+ ? req.body.targetTopicId.trim()
710
+ : source.topicId;
711
+ const topics = await storage.getTopics();
712
+ if (!topics.some((topic) => topic.id === targetTopicId)) {
713
+ return res
714
+ .status(400)
715
+ .json({ error: "Selected target topic was not found" });
716
+ }
717
+
718
+ const allInSourceTopic = await storage.getQuestionsByTopic(source.topicId);
719
+ const allInTargetTopic =
720
+ targetTopicId === source.topicId
721
+ ? allInSourceTopic
722
+ : await storage.getQuestionsByTopic(targetTopicId);
723
+ const requestedParentId =
724
+ req.body.parentQuestionId === undefined
725
+ ? targetTopicId === source.topicId
726
+ ? (source.parentQuestionId ?? null)
727
+ : null
728
+ : (req.body.parentQuestionId as string | null);
729
+
730
+ if (requestedParentId !== null && typeof requestedParentId !== "string") {
731
+ return res.status(400).json({ error: "Invalid parent question" });
732
+ }
733
+
734
+ if (
735
+ requestedParentId &&
736
+ !allInTargetTopic.some((candidate) => candidate.id === requestedParentId)
737
+ ) {
738
+ return res
739
+ .status(400)
740
+ .json({ error: "Selected parent question was not found" });
741
+ }
742
+
743
+ const collectSubtree = (parent: storage.Question): storage.Question[] => {
744
+ const children = allInSourceTopic.filter(
745
+ (candidate) => candidate.parentQuestionId === parent.id,
746
+ );
747
+ return [parent, ...children.flatMap((child) => collectSubtree(child))];
748
+ };
749
+
750
+ const cloneJson = <T>(value: T): T =>
751
+ value === undefined ? value : JSON.parse(JSON.stringify(value));
752
+
753
+ const copyContextFiles = async (
754
+ files: storage.ContextFile[] = [],
755
+ ): Promise<storage.ContextFile[]> => {
756
+ const dir = storage.getContextFilesDir();
757
+ const copies: storage.ContextFile[] = [];
758
+
759
+ for (const file of files) {
760
+ const nextId = randomUUID();
761
+ try {
762
+ const buffer = await fs.readFile(path.join(dir, file.id));
763
+ await fs.writeFile(path.join(dir, nextId), buffer);
764
+ } catch {
765
+ // Skip stale metadata entries whose backing blob no longer exists.
766
+ continue;
767
+ }
768
+
769
+ const {
770
+ id: _oldId,
771
+ createdAt: _oldCreatedAt,
772
+ driveFileId: _oldDriveFileId,
773
+ ...rest
774
+ } = cloneJson(file);
775
+
776
+ copies.push({
777
+ ...rest,
778
+ id: nextId,
779
+ createdAt: new Date().toISOString(),
780
+ });
781
+ }
782
+
783
+ return copies;
784
+ };
785
+
786
+ const subtree = collectSubtree(source);
787
+ const idMap = new Map(subtree.map((question) => [question.id, randomUUID()]));
788
+ const copied: storage.Question[] = [];
789
+
790
+ for (const question of subtree) {
791
+ const nextId = idMap.get(question.id)!;
792
+ const parentQuestionId =
793
+ question.id === source.id
794
+ ? (requestedParentId ?? undefined)
795
+ : idMap.get(question.parentQuestionId || "") || undefined;
796
+ const linkedConversationIds = (question.linkedConversationIds || [])
797
+ .map(
798
+ (linkedId) =>
799
+ idMap.get(linkedId) ||
800
+ (targetTopicId === source.topicId ? linkedId : null),
801
+ )
802
+ .filter((linkedId): linkedId is string => Boolean(linkedId));
803
+
804
+ const copy: storage.Question = {
805
+ ...cloneJson(question),
806
+ id: nextId,
807
+ topicId: targetTopicId,
808
+ parentQuestionId,
809
+ title:
810
+ question.id === source.id ? `${question.title} (copy)` : question.title,
811
+ contextFiles: await copyContextFiles(question.contextFiles || []),
812
+ messages: cloneJson(question.messages || []),
813
+ annotations: cloneJson(question.annotations || []),
814
+ codeAnnotations: cloneJson(question.codeAnnotations || {}),
815
+ codeContextFiles: [...(question.codeContextFiles || [])],
816
+ linkedConversationIds,
817
+ createdAt: new Date().toISOString(),
818
+ };
819
+
820
+ if (!copy.parentQuestionId) delete copy.parentQuestionId;
821
+ if (linkedConversationIds.length === 0) delete copy.linkedConversationIds;
822
+
823
+ await storage.saveQuestion(copy);
824
+ copied.push(copy);
825
+ }
826
+
827
+ res.json(copied);
828
+ });
829
+
681
830
  app.get("/api/questions/:id", async (req, res) => {
682
831
  const q = await storage.getQuestion(req.params.id);
683
832
  if (!q) return res.status(404).json({ error: "Not found" });
@@ -868,7 +1017,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
868
1017
  | "infra"
869
1018
  | "react"
870
1019
  | "nextjs"
871
- | "module-federation";
1020
+ | "module-federation"
1021
+ | "canvas"
1022
+ | "github-actions";
872
1023
  };
873
1024
  if (typeof code !== "string" || !code.trim()) {
874
1025
  return res.status(400).json({ error: "code is required" });
@@ -882,11 +1033,12 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
882
1033
  origin !== "react" &&
883
1034
  origin !== "nextjs" &&
884
1035
  origin !== "module-federation" &&
885
- origin !== "canvas"
1036
+ origin !== "canvas" &&
1037
+ origin !== "github-actions"
886
1038
  ) {
887
1039
  return res.status(400).json({
888
1040
  error:
889
- "origin must be 'user', 'ai', 'sandbox', 'browser-security', 'infra', 'react', 'nextjs', 'module-federation', or 'canvas'",
1041
+ "origin must be 'user', 'ai', 'sandbox', 'browser-security', 'infra', 'react', 'nextjs', 'module-federation', 'canvas', or 'github-actions'",
890
1042
  });
891
1043
  }
892
1044
  try {
@@ -1073,6 +1225,18 @@ app.post("/api/infra/ask", async (req, res) => {
1073
1225
  const aiSettings = await storage.getAiSettings();
1074
1226
 
1075
1227
  // Build workspace context block
1228
+ const languageForInfraFile = (name: string) => {
1229
+ const lower = name.toLowerCase();
1230
+ if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "hcl";
1231
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
1232
+ if (lower.endsWith(".json")) return "json";
1233
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
1234
+ if (lower.endsWith(".ts")) return "typescript";
1235
+ if (lower.endsWith(".md")) return "markdown";
1236
+ if (lower.split("/").pop() === "dockerfile") return "dockerfile";
1237
+ return "text";
1238
+ };
1239
+
1076
1240
  let workspaceBlock = "";
1077
1241
  if (workspace && typeof workspace === "object") {
1078
1242
  const entries = Object.entries(workspace).filter(
@@ -1081,15 +1245,15 @@ app.post("/api/infra/ask", async (req, res) => {
1081
1245
  if (entries.length > 0) {
1082
1246
  workspaceBlock = "\n\n--- Workspace Files ---\n";
1083
1247
  for (const [name, content] of entries) {
1084
- workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
1248
+ workspaceBlock += `\n### ${name}\n\`\`\`${languageForInfraFile(name)}\n${content}\n\`\`\`\n`;
1085
1249
  }
1086
1250
  }
1087
1251
  }
1088
1252
 
1089
1253
  const system =
1090
- `You are a senior infrastructure engineer and Terraform expert acting as a coding assistant inside an Infrastructure Lab.\n` +
1091
- `The user is editing a Terraform workspace. Answer questions about their code clearly and accurately.\n` +
1092
- `When suggesting edits, show the changed HCL as a fenced \`\`\`hcl code block.\n` +
1254
+ `You are a senior infrastructure engineer and Terraform/Docker expert acting as a coding assistant inside an Infrastructure Lab.\n` +
1255
+ `The user is editing an infrastructure workspace that may include Terraform, Docker, and local app files. Answer questions about their code and local Docker workflow clearly and accurately.\n` +
1256
+ `When suggesting edits, use the appropriate fenced code language: hcl, dockerfile, yaml, javascript, json, or markdown.\n` +
1093
1257
  `Be concise unless the user asks for a deeper explanation.` +
1094
1258
  workspaceBlock;
1095
1259
 
@@ -1310,6 +1474,50 @@ app.get("/api/infra/runs/:runId", async (req, res) => {
1310
1474
  }
1311
1475
  });
1312
1476
 
1477
+ // ─── GitHub Actions Lab (act) ───────────────────────────────────────────
1478
+ app.post("/api/gha/run-stream", async (req, res) => {
1479
+ const { questionId, fileId, label, command, workspace } = req.body as {
1480
+ questionId?: string;
1481
+ fileId?: string;
1482
+ label?: string;
1483
+ command?: string;
1484
+ workspace?: unknown;
1485
+ };
1486
+
1487
+ res.setHeader("Content-Type", "text/event-stream");
1488
+ res.setHeader("Cache-Control", "no-cache");
1489
+ res.setHeader("Connection", "keep-alive");
1490
+ res.flushHeaders();
1491
+
1492
+ const send = (payload: unknown) => {
1493
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1494
+ };
1495
+
1496
+ if (typeof command !== "string" || !command.trim()) {
1497
+ send({ type: "error", error: "command is required" });
1498
+ res.end();
1499
+ return;
1500
+ }
1501
+
1502
+ try {
1503
+ await streamGhaCommand({
1504
+ questionId,
1505
+ fileId,
1506
+ label,
1507
+ command,
1508
+ workspace,
1509
+ onMessage: send,
1510
+ });
1511
+ } catch (err: any) {
1512
+ send({
1513
+ type: "error",
1514
+ error: err?.message || "Failed to run act command",
1515
+ });
1516
+ }
1517
+
1518
+ res.end();
1519
+ });
1520
+
1313
1521
  // Link an existing file to a topic without re-uploading
1314
1522
  app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
1315
1523
  const { fileId, originalName } = req.body as {
@@ -3983,4 +4191,27 @@ app.delete("/api/react-lab/:id", async (req, res) => {
3983
4191
  app.listen(PORT, () => {
3984
4192
  console.log(`\n ✈ Interview Cockpit server → http://localhost:${PORT}\n`);
3985
4193
  });
4194
+
4195
+ const callbackBridgePort = Number(
4196
+ process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001",
4197
+ );
4198
+ const mainPort = Number(PORT);
4199
+ if (
4200
+ !process.env.GOOGLE_EXPORT_REDIRECT_URI &&
4201
+ callbackBridgePort !== mainPort
4202
+ ) {
4203
+ const callbackApp = express();
4204
+ callbackApp.get("/api/drive/export-callback", handleDriveExportCallback);
4205
+ callbackApp
4206
+ .listen(callbackBridgePort, () => {
4207
+ console.log(
4208
+ ` ↪ Google Drive OAuth callback bridge → http://localhost:${callbackBridgePort}/api/drive/export-callback\n`,
4209
+ );
4210
+ })
4211
+ .on("error", (err: any) => {
4212
+ console.warn(
4213
+ `Google Drive OAuth callback bridge could not listen on ${callbackBridgePort}: ${err?.message || err}`,
4214
+ );
4215
+ });
4216
+ }
3986
4217
  })();