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.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +184 -8
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1048 -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 +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -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, 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
|
-
|
|
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 =
|
|
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 '
|
|
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
|
|
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
|
|
1092
|
-
`When suggesting edits,
|
|
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
|
})();
|