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.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +83 -8
- package/template/client/src/components/GithubActionsLabModal.tsx +746 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +400 -14
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +287 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +83 -10
- package/template/client/src/types.ts +27 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +468 -0
- package/template/server/src/google-drive.ts +35 -24
- package/template/server/src/index.ts +241 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -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
|
-
|
|
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 =
|
|
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 '
|
|
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
|
|
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
|
|
1092
|
-
`When suggesting edits,
|
|
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
|
})();
|