create-interview-cockpit 0.17.3 → 0.19.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, listGhaRuns, getGhaRun } from "./gha-runner.js";
39
40
 
40
41
  const app = express();
41
42
  app.use(cors());
@@ -348,13 +349,63 @@ 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
  }
356
374
  });
357
375
 
376
+ app.post("/api/workspaces/:id/topics/:topicId/sync", async (req, res) => {
377
+ try {
378
+ // Pull only this topic's Drive folder into the selected workspace.
379
+ storage.setActiveWorkspaceId(req.params.id);
380
+ const result = await googleDrive.syncTopic(
381
+ req.params.id,
382
+ req.params.topicId,
383
+ extractText,
384
+ );
385
+ if (result.errors.some((error) => /\b403\b/.test(error))) {
386
+ return res.json({
387
+ ...result,
388
+ needsAuth: true,
389
+ authUrl: googleDrive.getExportAuthUrl(),
390
+ });
391
+ }
392
+ res.json(result);
393
+ } catch (err: any) {
394
+ if (err?.needsReauth || err?.message === "NEEDS_REAUTH") {
395
+ return res.json({
396
+ needsAuth: true,
397
+ authUrl: googleDrive.getExportAuthUrl(),
398
+ topicsUpserted: 0,
399
+ filesImported: 0,
400
+ filesSkipped: 0,
401
+ errors: ["Google Drive authorization is required to pull this topic."],
402
+ });
403
+ }
404
+ console.error("[sync-topic]", err);
405
+ res.status(500).json({ error: err?.message || "Topic sync failed" });
406
+ }
407
+ });
408
+
358
409
  app.get("/api/workspaces/:id/drive-subfolders", async (req, res) => {
359
410
  try {
360
411
  const folders = await googleDrive.listDriveSubfolders(req.params.id);
@@ -396,19 +447,23 @@ app.get("/api/drive/export-auth", (_req, res) => {
396
447
  res.redirect(googleDrive.getExportAuthUrl());
397
448
  });
398
449
 
399
- app.get("/api/drive/export-callback", async (req, res) => {
450
+ const handleDriveExportCallback = async (req: any, res: any) => {
400
451
  const code = req.query.code as string | undefined;
401
452
  if (!code) return res.status(400).send("Missing code parameter");
402
453
  try {
403
454
  await googleDrive.handleExportCallback(code);
404
- const clientUrl = process.env.CLIENT_URL ?? "http://localhost:5173";
455
+ const clientUrl =
456
+ process.env.CLIENT_URL ??
457
+ `http://localhost:${process.env.CLIENT_PORT || "5173"}`;
405
458
  res.redirect(`${clientUrl}/?export_authed=1`);
406
459
  } catch (err: any) {
407
460
  res
408
461
  .status(500)
409
462
  .send(`OAuth callback failed: ${err?.message || "unknown error"}`);
410
463
  }
411
- });
464
+ };
465
+
466
+ app.get("/api/drive/export-callback", handleDriveExportCallback);
412
467
 
413
468
  app.post("/api/workspaces/:id/export-drive", async (req, res) => {
414
469
  try {
@@ -430,6 +485,30 @@ app.post("/api/workspaces/:id/export-drive", async (req, res) => {
430
485
  }
431
486
  });
432
487
 
488
+ app.post(
489
+ "/api/workspaces/:id/topics/:topicId/export-drive",
490
+ async (req, res) => {
491
+ try {
492
+ if (!(await googleDrive.isExportAuthed())) {
493
+ return res.json({
494
+ needsAuth: true,
495
+ authUrl: googleDrive.getExportAuthUrl(),
496
+ });
497
+ }
498
+ const { targetFolderId } = req.body as { targetFolderId?: string };
499
+ const result = await googleDrive.exportTopic(
500
+ req.params.id,
501
+ req.params.topicId,
502
+ targetFolderId,
503
+ );
504
+ res.json(result);
505
+ } catch (err: any) {
506
+ console.error("[export-topic-drive]", err);
507
+ res.status(500).json({ error: err?.message || "Topic export failed" });
508
+ }
509
+ },
510
+ );
511
+
433
512
  app.post("/api/drive/repair-permissions", async (req, res) => {
434
513
  try {
435
514
  const { folderId } = req.body as { folderId: string };
@@ -678,6 +757,133 @@ app.post("/api/topics/:topicId/questions", async (req, res) => {
678
757
  res.json(question);
679
758
  });
680
759
 
760
+ app.post("/api/questions/:id/copy", async (req, res) => {
761
+ const source = await storage.getQuestion(req.params.id);
762
+ if (!source) return res.status(404).json({ error: "Not found" });
763
+
764
+ const targetTopicId =
765
+ typeof req.body.targetTopicId === "string" && req.body.targetTopicId.trim()
766
+ ? req.body.targetTopicId.trim()
767
+ : source.topicId;
768
+ const topics = await storage.getTopics();
769
+ if (!topics.some((topic) => topic.id === targetTopicId)) {
770
+ return res
771
+ .status(400)
772
+ .json({ error: "Selected target topic was not found" });
773
+ }
774
+
775
+ const allInSourceTopic = await storage.getQuestionsByTopic(source.topicId);
776
+ const allInTargetTopic =
777
+ targetTopicId === source.topicId
778
+ ? allInSourceTopic
779
+ : await storage.getQuestionsByTopic(targetTopicId);
780
+ const requestedParentId =
781
+ req.body.parentQuestionId === undefined
782
+ ? targetTopicId === source.topicId
783
+ ? (source.parentQuestionId ?? null)
784
+ : null
785
+ : (req.body.parentQuestionId as string | null);
786
+
787
+ if (requestedParentId !== null && typeof requestedParentId !== "string") {
788
+ return res.status(400).json({ error: "Invalid parent question" });
789
+ }
790
+
791
+ if (
792
+ requestedParentId &&
793
+ !allInTargetTopic.some((candidate) => candidate.id === requestedParentId)
794
+ ) {
795
+ return res
796
+ .status(400)
797
+ .json({ error: "Selected parent question was not found" });
798
+ }
799
+
800
+ const collectSubtree = (parent: storage.Question): storage.Question[] => {
801
+ const children = allInSourceTopic.filter(
802
+ (candidate) => candidate.parentQuestionId === parent.id,
803
+ );
804
+ return [parent, ...children.flatMap((child) => collectSubtree(child))];
805
+ };
806
+
807
+ const cloneJson = <T>(value: T): T =>
808
+ value === undefined ? value : JSON.parse(JSON.stringify(value));
809
+
810
+ const copyContextFiles = async (
811
+ files: storage.ContextFile[] = [],
812
+ ): Promise<storage.ContextFile[]> => {
813
+ const dir = storage.getContextFilesDir();
814
+ const copies: storage.ContextFile[] = [];
815
+
816
+ for (const file of files) {
817
+ const nextId = randomUUID();
818
+ try {
819
+ const buffer = await fs.readFile(path.join(dir, file.id));
820
+ await fs.writeFile(path.join(dir, nextId), buffer);
821
+ } catch {
822
+ // Skip stale metadata entries whose backing blob no longer exists.
823
+ continue;
824
+ }
825
+
826
+ const {
827
+ id: _oldId,
828
+ createdAt: _oldCreatedAt,
829
+ driveFileId: _oldDriveFileId,
830
+ ...rest
831
+ } = cloneJson(file);
832
+
833
+ copies.push({
834
+ ...rest,
835
+ id: nextId,
836
+ createdAt: new Date().toISOString(),
837
+ });
838
+ }
839
+
840
+ return copies;
841
+ };
842
+
843
+ const subtree = collectSubtree(source);
844
+ const idMap = new Map(subtree.map((question) => [question.id, randomUUID()]));
845
+ const copied: storage.Question[] = [];
846
+
847
+ for (const question of subtree) {
848
+ const nextId = idMap.get(question.id)!;
849
+ const parentQuestionId =
850
+ question.id === source.id
851
+ ? (requestedParentId ?? undefined)
852
+ : idMap.get(question.parentQuestionId || "") || undefined;
853
+ const linkedConversationIds = (question.linkedConversationIds || [])
854
+ .map(
855
+ (linkedId) =>
856
+ idMap.get(linkedId) ||
857
+ (targetTopicId === source.topicId ? linkedId : null),
858
+ )
859
+ .filter((linkedId): linkedId is string => Boolean(linkedId));
860
+
861
+ const copy: storage.Question = {
862
+ ...cloneJson(question),
863
+ id: nextId,
864
+ topicId: targetTopicId,
865
+ parentQuestionId,
866
+ title:
867
+ question.id === source.id ? `${question.title} (copy)` : question.title,
868
+ contextFiles: await copyContextFiles(question.contextFiles || []),
869
+ messages: cloneJson(question.messages || []),
870
+ annotations: cloneJson(question.annotations || []),
871
+ codeAnnotations: cloneJson(question.codeAnnotations || {}),
872
+ codeContextFiles: [...(question.codeContextFiles || [])],
873
+ linkedConversationIds,
874
+ createdAt: new Date().toISOString(),
875
+ };
876
+
877
+ if (!copy.parentQuestionId) delete copy.parentQuestionId;
878
+ if (linkedConversationIds.length === 0) delete copy.linkedConversationIds;
879
+
880
+ await storage.saveQuestion(copy);
881
+ copied.push(copy);
882
+ }
883
+
884
+ res.json(copied);
885
+ });
886
+
681
887
  app.get("/api/questions/:id", async (req, res) => {
682
888
  const q = await storage.getQuestion(req.params.id);
683
889
  if (!q) return res.status(404).json({ error: "Not found" });
@@ -868,7 +1074,9 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
868
1074
  | "infra"
869
1075
  | "react"
870
1076
  | "nextjs"
871
- | "module-federation";
1077
+ | "module-federation"
1078
+ | "canvas"
1079
+ | "github-actions";
872
1080
  };
873
1081
  if (typeof code !== "string" || !code.trim()) {
874
1082
  return res.status(400).json({ error: "code is required" });
@@ -882,11 +1090,12 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
882
1090
  origin !== "react" &&
883
1091
  origin !== "nextjs" &&
884
1092
  origin !== "module-federation" &&
885
- origin !== "canvas"
1093
+ origin !== "canvas" &&
1094
+ origin !== "github-actions"
886
1095
  ) {
887
1096
  return res.status(400).json({
888
1097
  error:
889
- "origin must be 'user', 'ai', 'sandbox', 'browser-security', 'infra', 'react', 'nextjs', 'module-federation', or 'canvas'",
1098
+ "origin must be 'user', 'ai', 'sandbox', 'browser-security', 'infra', 'react', 'nextjs', 'module-federation', 'canvas', or 'github-actions'",
890
1099
  });
891
1100
  }
892
1101
  try {
@@ -1073,6 +1282,18 @@ app.post("/api/infra/ask", async (req, res) => {
1073
1282
  const aiSettings = await storage.getAiSettings();
1074
1283
 
1075
1284
  // Build workspace context block
1285
+ const languageForInfraFile = (name: string) => {
1286
+ const lower = name.toLowerCase();
1287
+ if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "hcl";
1288
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
1289
+ if (lower.endsWith(".json")) return "json";
1290
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
1291
+ if (lower.endsWith(".ts")) return "typescript";
1292
+ if (lower.endsWith(".md")) return "markdown";
1293
+ if (lower.split("/").pop() === "dockerfile") return "dockerfile";
1294
+ return "text";
1295
+ };
1296
+
1076
1297
  let workspaceBlock = "";
1077
1298
  if (workspace && typeof workspace === "object") {
1078
1299
  const entries = Object.entries(workspace).filter(
@@ -1081,15 +1302,15 @@ app.post("/api/infra/ask", async (req, res) => {
1081
1302
  if (entries.length > 0) {
1082
1303
  workspaceBlock = "\n\n--- Workspace Files ---\n";
1083
1304
  for (const [name, content] of entries) {
1084
- workspaceBlock += `\n### ${name}\n\`\`\`hcl\n${content}\n\`\`\`\n`;
1305
+ workspaceBlock += `\n### ${name}\n\`\`\`${languageForInfraFile(name)}\n${content}\n\`\`\`\n`;
1085
1306
  }
1086
1307
  }
1087
1308
  }
1088
1309
 
1089
1310
  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` +
1311
+ `You are a senior infrastructure engineer and Terraform/Docker expert acting as a coding assistant inside an Infrastructure Lab.\n` +
1312
+ `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` +
1313
+ `When suggesting edits, use the appropriate fenced code language: hcl, dockerfile, yaml, javascript, json, or markdown.\n` +
1093
1314
  `Be concise unless the user asks for a deeper explanation.` +
1094
1315
  workspaceBlock;
1095
1316
 
@@ -1310,6 +1531,79 @@ app.get("/api/infra/runs/:runId", async (req, res) => {
1310
1531
  }
1311
1532
  });
1312
1533
 
1534
+ // ─── GitHub Actions Lab (act) ───────────────────────────────────────────
1535
+ app.post("/api/gha/run-stream", async (req, res) => {
1536
+ const { questionId, fileId, label, command, workspace } = req.body as {
1537
+ questionId?: string;
1538
+ fileId?: string;
1539
+ label?: string;
1540
+ command?: string;
1541
+ workspace?: unknown;
1542
+ };
1543
+
1544
+ res.setHeader("Content-Type", "text/event-stream");
1545
+ res.setHeader("Cache-Control", "no-cache");
1546
+ res.setHeader("Connection", "keep-alive");
1547
+ res.flushHeaders();
1548
+
1549
+ const send = (payload: unknown) => {
1550
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1551
+ };
1552
+
1553
+ if (typeof command !== "string" || !command.trim()) {
1554
+ send({ type: "error", error: "command is required" });
1555
+ res.end();
1556
+ return;
1557
+ }
1558
+
1559
+ try {
1560
+ await streamGhaCommand({
1561
+ questionId,
1562
+ fileId,
1563
+ label,
1564
+ command,
1565
+ workspace,
1566
+ onMessage: send,
1567
+ });
1568
+ } catch (err: any) {
1569
+ send({
1570
+ type: "error",
1571
+ error: err?.message || "Failed to run act command",
1572
+ });
1573
+ }
1574
+
1575
+ res.end();
1576
+ });
1577
+
1578
+ // List historical act runs, optionally scoped to a lab file or question.
1579
+ app.get("/api/gha/runs", async (req, res) => {
1580
+ try {
1581
+ const { questionId, fileId, limit } = req.query as {
1582
+ questionId?: string;
1583
+ fileId?: string;
1584
+ limit?: string;
1585
+ };
1586
+ const parsedLimit = limit ? Number(limit) : undefined;
1587
+ const runs = await listGhaRuns({
1588
+ questionId,
1589
+ fileId,
1590
+ ...(Number.isFinite(parsedLimit) ? { limit: parsedLimit } : {}),
1591
+ });
1592
+ res.json(runs);
1593
+ } catch (err: any) {
1594
+ res.status(500).json({ error: err?.message || "Failed to list gha runs" });
1595
+ }
1596
+ });
1597
+
1598
+ app.get("/api/gha/runs/:runId", async (req, res) => {
1599
+ try {
1600
+ const run = await getGhaRun(req.params.runId);
1601
+ res.json(run);
1602
+ } catch {
1603
+ res.status(404).json({ error: "GHA run not found" });
1604
+ }
1605
+ });
1606
+
1313
1607
  // Link an existing file to a topic without re-uploading
1314
1608
  app.post("/api/topics/:topicId/context-files/link", async (req, res) => {
1315
1609
  const { fileId, originalName } = req.body as {
@@ -3983,4 +4277,27 @@ app.delete("/api/react-lab/:id", async (req, res) => {
3983
4277
  app.listen(PORT, () => {
3984
4278
  console.log(`\n ✈ Interview Cockpit server → http://localhost:${PORT}\n`);
3985
4279
  });
4280
+
4281
+ const callbackBridgePort = Number(
4282
+ process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001",
4283
+ );
4284
+ const mainPort = Number(PORT);
4285
+ if (
4286
+ !process.env.GOOGLE_EXPORT_REDIRECT_URI &&
4287
+ callbackBridgePort !== mainPort
4288
+ ) {
4289
+ const callbackApp = express();
4290
+ callbackApp.get("/api/drive/export-callback", handleDriveExportCallback);
4291
+ callbackApp
4292
+ .listen(callbackBridgePort, () => {
4293
+ console.log(
4294
+ ` ↪ Google Drive OAuth callback bridge → http://localhost:${callbackBridgePort}/api/drive/export-callback\n`,
4295
+ );
4296
+ })
4297
+ .on("error", (err: any) => {
4298
+ console.warn(
4299
+ `Google Drive OAuth callback bridge could not listen on ${callbackBridgePort}: ${err?.message || err}`,
4300
+ );
4301
+ });
4302
+ }
3986
4303
  })();