create-interview-cockpit 0.11.0 → 0.13.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 +30 -1
- package/template/client/src/api.ts +119 -0
- package/template/client/src/components/CodeContextPanel.tsx +0 -622
- package/template/client/src/components/CodeRunnerModal.tsx +426 -240
- package/template/client/src/components/DeploymentLabModal.tsx +1941 -0
- package/template/client/src/components/LabsPanel.tsx +565 -0
- package/template/client/src/components/Sidebar.tsx +97 -55
- package/template/client/src/reactLab.ts +96 -31
- package/template/client/src/store.ts +52 -1
- package/template/client/src/types.ts +2 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +310 -1
- package/template/server/src/storage.ts +31 -0
|
@@ -684,6 +684,38 @@ app.delete(
|
|
|
684
684
|
},
|
|
685
685
|
);
|
|
686
686
|
|
|
687
|
+
// Detach a lab file from AI context without deleting it
|
|
688
|
+
app.post(
|
|
689
|
+
"/api/questions/:questionId/context-files/:fileId/detach",
|
|
690
|
+
async (req, res) => {
|
|
691
|
+
try {
|
|
692
|
+
const cf = await storage.detachQuestionContextFile(
|
|
693
|
+
req.params.questionId,
|
|
694
|
+
req.params.fileId,
|
|
695
|
+
);
|
|
696
|
+
res.json(cf);
|
|
697
|
+
} catch (err: any) {
|
|
698
|
+
res.status(500).json({ error: err?.message || "Failed to detach" });
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// Re-attach a previously detached lab file to AI context
|
|
704
|
+
app.post(
|
|
705
|
+
"/api/questions/:questionId/context-files/:fileId/attach",
|
|
706
|
+
async (req, res) => {
|
|
707
|
+
try {
|
|
708
|
+
const cf = await storage.attachQuestionContextFile(
|
|
709
|
+
req.params.questionId,
|
|
710
|
+
req.params.fileId,
|
|
711
|
+
);
|
|
712
|
+
res.json(cf);
|
|
713
|
+
} catch (err: any) {
|
|
714
|
+
res.status(500).json({ error: err?.message || "Failed to attach" });
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
|
|
687
719
|
// Save a code snippet (from Code Runner or AI response) as a question context file
|
|
688
720
|
app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
689
721
|
const { code, language, label, origin } = req.body as {
|
|
@@ -1376,7 +1408,9 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1376
1408
|
if (questionId) {
|
|
1377
1409
|
const question = await storage.getQuestion(questionId);
|
|
1378
1410
|
if (question?.contextFiles?.length) {
|
|
1379
|
-
for (const cf of question.contextFiles
|
|
1411
|
+
for (const cf of question.contextFiles.filter(
|
|
1412
|
+
(c) => c.inContext !== false,
|
|
1413
|
+
)) {
|
|
1380
1414
|
fileRegistry.set(cf.id, {
|
|
1381
1415
|
label: `[question] ${cf.originalName}`,
|
|
1382
1416
|
reader: () => storage.readContextFileContent(cf.id),
|
|
@@ -2458,6 +2492,42 @@ function isPathInside(root: string, target: string): boolean {
|
|
|
2458
2492
|
);
|
|
2459
2493
|
}
|
|
2460
2494
|
|
|
2495
|
+
// Permissive npm command parser for the React lab.
|
|
2496
|
+
// Allows any npm subcommand (install, uninstall, run, update, etc.)
|
|
2497
|
+
// but blocks shell operators and dangerous flags.
|
|
2498
|
+
function parseReactLabCommand(command: string): { args: string[] } {
|
|
2499
|
+
const tokens = splitShellLikeCommand(command);
|
|
2500
|
+
if (tokens.length === 0) {
|
|
2501
|
+
throw new Error("Command cannot be empty");
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
if (tokens[0] !== "npm") {
|
|
2505
|
+
throw new Error("Only npm commands are supported in the React lab console");
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
if (tokens.some((t) => MODULE_FEDERATION_SHELL_META_TOKENS.has(t))) {
|
|
2509
|
+
throw new Error(
|
|
2510
|
+
"Shell operators are not supported. Run one npm command at a time.",
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const dangerous = [
|
|
2515
|
+
"--prefix",
|
|
2516
|
+
"--workspaces",
|
|
2517
|
+
"-w",
|
|
2518
|
+
"--workspace",
|
|
2519
|
+
"--global",
|
|
2520
|
+
"-g",
|
|
2521
|
+
];
|
|
2522
|
+
if (
|
|
2523
|
+
tokens.some((t) => dangerous.some((d) => t === d || t.startsWith(d + "=")))
|
|
2524
|
+
) {
|
|
2525
|
+
throw new Error("That flag is not allowed in the React lab console.");
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
return { args: tokens.slice(1) };
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2461
2531
|
function parseModuleFederationCommand(command: string): {
|
|
2462
2532
|
args: string[];
|
|
2463
2533
|
displayCommand: string;
|
|
@@ -3330,6 +3400,245 @@ ${code}`;
|
|
|
3330
3400
|
res.end();
|
|
3331
3401
|
});
|
|
3332
3402
|
|
|
3403
|
+
// ─── React Lab (Vite) sandboxes ──────────────────────────────────────────────
|
|
3404
|
+
|
|
3405
|
+
interface ReactLabSandboxEntry {
|
|
3406
|
+
child: ReturnType<typeof spawn>;
|
|
3407
|
+
port: number;
|
|
3408
|
+
dir: string;
|
|
3409
|
+
logs: string[];
|
|
3410
|
+
ready: boolean;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
const reactLabSandboxes = new Map<string, ReactLabSandboxEntry>();
|
|
3414
|
+
const REACT_LAB_SANDBOX_BASE = path.join(
|
|
3415
|
+
os.tmpdir(),
|
|
3416
|
+
"interview-cockpit-react-lab",
|
|
3417
|
+
);
|
|
3418
|
+
|
|
3419
|
+
app.post("/api/react-lab/start", async (req, res) => {
|
|
3420
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
3421
|
+
if (!files || typeof files !== "object") {
|
|
3422
|
+
return res.status(400).json({ error: "files is required" });
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
const id = randomUUID();
|
|
3426
|
+
const dir = path.join(REACT_LAB_SANDBOX_BASE, id);
|
|
3427
|
+
const logs: string[] = [];
|
|
3428
|
+
|
|
3429
|
+
try {
|
|
3430
|
+
await fs.mkdir(dir, { recursive: true });
|
|
3431
|
+
|
|
3432
|
+
// Write all user files as-is
|
|
3433
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
3434
|
+
const fullPath = path.join(dir, filePath);
|
|
3435
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
3436
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// Auto-generate index.html if the user didn't provide one
|
|
3440
|
+
const hasIndex = await fs
|
|
3441
|
+
.access(path.join(dir, "index.html"))
|
|
3442
|
+
.then(() => true)
|
|
3443
|
+
.catch(() => false);
|
|
3444
|
+
if (!hasIndex) {
|
|
3445
|
+
await fs.writeFile(
|
|
3446
|
+
path.join(dir, "index.html"),
|
|
3447
|
+
`<!DOCTYPE html>
|
|
3448
|
+
<html lang="en">
|
|
3449
|
+
<head>
|
|
3450
|
+
<meta charset="UTF-8" />
|
|
3451
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3452
|
+
<title>React Lab</title>
|
|
3453
|
+
</head>
|
|
3454
|
+
<body>
|
|
3455
|
+
<div id="root"></div>
|
|
3456
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
3457
|
+
</body>
|
|
3458
|
+
</html>\n`,
|
|
3459
|
+
"utf8",
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// Install dependencies
|
|
3464
|
+
appendSandboxLog(logs, "Installing dependencies…\n");
|
|
3465
|
+
await runLoggedCommand(
|
|
3466
|
+
npmCommand(),
|
|
3467
|
+
["install", "--no-audit", "--no-fund", "--prefer-offline"],
|
|
3468
|
+
{
|
|
3469
|
+
cwd: dir,
|
|
3470
|
+
env: { ...process.env, npm_config_update_notifier: "false" },
|
|
3471
|
+
},
|
|
3472
|
+
logs,
|
|
3473
|
+
);
|
|
3474
|
+
|
|
3475
|
+
const port = await getFreePort();
|
|
3476
|
+
|
|
3477
|
+
const child = spawn(
|
|
3478
|
+
npmCommand(),
|
|
3479
|
+
["run", "dev", "--", "--port", String(port), "--host", "localhost"],
|
|
3480
|
+
{
|
|
3481
|
+
cwd: dir,
|
|
3482
|
+
env: {
|
|
3483
|
+
...process.env,
|
|
3484
|
+
npm_config_update_notifier: "false",
|
|
3485
|
+
NO_COLOR: "1",
|
|
3486
|
+
},
|
|
3487
|
+
},
|
|
3488
|
+
);
|
|
3489
|
+
|
|
3490
|
+
const entry: ReactLabSandboxEntry = {
|
|
3491
|
+
child,
|
|
3492
|
+
port,
|
|
3493
|
+
dir,
|
|
3494
|
+
logs,
|
|
3495
|
+
ready: false,
|
|
3496
|
+
};
|
|
3497
|
+
|
|
3498
|
+
const markReady = (text: string) => {
|
|
3499
|
+
if (!entry.ready && /Local:|ready in/i.test(text)) {
|
|
3500
|
+
entry.ready = true;
|
|
3501
|
+
}
|
|
3502
|
+
};
|
|
3503
|
+
|
|
3504
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
3505
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
3506
|
+
});
|
|
3507
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
3508
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
3509
|
+
});
|
|
3510
|
+
child.on("exit", () => {
|
|
3511
|
+
reactLabSandboxes.delete(id);
|
|
3512
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3513
|
+
});
|
|
3514
|
+
|
|
3515
|
+
reactLabSandboxes.set(id, entry);
|
|
3516
|
+
|
|
3517
|
+
const deadline = Date.now() + 60_000;
|
|
3518
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
3519
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
3520
|
+
if (!reactLabSandboxes.has(id)) {
|
|
3521
|
+
return res
|
|
3522
|
+
.status(500)
|
|
3523
|
+
.json({ error: logs.join("").trim() || "Vite server exited" });
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
if (!entry.ready) {
|
|
3528
|
+
return res
|
|
3529
|
+
.status(504)
|
|
3530
|
+
.json({ error: "Vite did not start in time", logs });
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
res.json({ id, port, url: `http://localhost:${port}` });
|
|
3534
|
+
} catch (error: any) {
|
|
3535
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3536
|
+
res.status(500).json({
|
|
3537
|
+
error:
|
|
3538
|
+
logs.join("").trim() || error?.message || "Failed to start React lab",
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
});
|
|
3542
|
+
|
|
3543
|
+
app.post("/api/react-lab/:id/update-files", async (req, res) => {
|
|
3544
|
+
const sb = reactLabSandboxes.get(req.params.id);
|
|
3545
|
+
if (!sb) return res.status(404).json({ error: "Sandbox not found" });
|
|
3546
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
3547
|
+
if (!files || typeof files !== "object")
|
|
3548
|
+
return res.status(400).json({ error: "files is required" });
|
|
3549
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
3550
|
+
const fullPath = path.join(sb.dir, filePath);
|
|
3551
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
3552
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
3553
|
+
}
|
|
3554
|
+
res.json({ ok: true });
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
app.post("/api/react-lab/:id/command-stream", async (req, res) => {
|
|
3558
|
+
const sb = reactLabSandboxes.get(req.params.id);
|
|
3559
|
+
|
|
3560
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
3561
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
3562
|
+
res.setHeader("Connection", "keep-alive");
|
|
3563
|
+
res.flushHeaders();
|
|
3564
|
+
|
|
3565
|
+
const send = (payload: unknown) => {
|
|
3566
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
3567
|
+
};
|
|
3568
|
+
|
|
3569
|
+
if (!sb) {
|
|
3570
|
+
send({ type: "error", error: "Sandbox not found" });
|
|
3571
|
+
res.end();
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
const { command } = req.body as { command?: string };
|
|
3576
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
3577
|
+
send({ type: "error", error: "command is required" });
|
|
3578
|
+
res.end();
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
try {
|
|
3583
|
+
const parsed = parseReactLabCommand(command);
|
|
3584
|
+
|
|
3585
|
+
send({ type: "output", kind: "info", text: `$ ${command.trim()}\n` });
|
|
3586
|
+
|
|
3587
|
+
await runStreamedCommand(
|
|
3588
|
+
npmCommand(),
|
|
3589
|
+
parsed.args,
|
|
3590
|
+
{
|
|
3591
|
+
cwd: sb.dir,
|
|
3592
|
+
env: { ...process.env, npm_config_update_notifier: "false" },
|
|
3593
|
+
},
|
|
3594
|
+
({ kind, text }) => send({ type: "output", kind, text }),
|
|
3595
|
+
);
|
|
3596
|
+
|
|
3597
|
+
send({ type: "complete" });
|
|
3598
|
+
} catch (error: any) {
|
|
3599
|
+
send({ type: "error", error: error?.message || "Command failed" });
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
res.end();
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
app.get("/api/react-lab/:id/read-file", async (req, res) => {
|
|
3606
|
+
const sb = reactLabSandboxes.get(req.params.id);
|
|
3607
|
+
if (!sb) return res.status(404).json({ error: "Sandbox not found" });
|
|
3608
|
+
|
|
3609
|
+
const filePath =
|
|
3610
|
+
typeof req.query.path === "string" ? req.query.path : undefined;
|
|
3611
|
+
if (!filePath) return res.status(400).json({ error: "path is required" });
|
|
3612
|
+
|
|
3613
|
+
// Restrict to safe relative paths only
|
|
3614
|
+
const normalized = path.normalize(filePath).replace(/^\//, "");
|
|
3615
|
+
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
3616
|
+
return res.status(400).json({ error: "Invalid path" });
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
const fullPath = path.join(sb.dir, normalized);
|
|
3620
|
+
if (!isPathInside(sb.dir, fullPath)) {
|
|
3621
|
+
return res.status(400).json({ error: "Path must stay inside the sandbox" });
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
try {
|
|
3625
|
+
const content = await fs.readFile(fullPath, "utf8");
|
|
3626
|
+
res.json({ path: normalized, content });
|
|
3627
|
+
} catch {
|
|
3628
|
+
res.status(404).json({ error: "File not found" });
|
|
3629
|
+
}
|
|
3630
|
+
});
|
|
3631
|
+
|
|
3632
|
+
app.delete("/api/react-lab/:id", async (req, res) => {
|
|
3633
|
+
const sb = reactLabSandboxes.get(req.params.id);
|
|
3634
|
+
if (sb) {
|
|
3635
|
+
sb.child.kill("SIGTERM");
|
|
3636
|
+
reactLabSandboxes.delete(req.params.id);
|
|
3637
|
+
await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
|
|
3638
|
+
}
|
|
3639
|
+
res.json({ ok: true });
|
|
3640
|
+
});
|
|
3641
|
+
|
|
3333
3642
|
// ─── Start ───────────────────────────────────────────────
|
|
3334
3643
|
|
|
3335
3644
|
(async () => {
|
|
@@ -76,6 +76,9 @@ export interface ContextFile {
|
|
|
76
76
|
language?: string;
|
|
77
77
|
/** Short display label for code snippets. */
|
|
78
78
|
label?: string;
|
|
79
|
+
/** When false, the file is saved but excluded from the AI prompt context.
|
|
80
|
+
* Used to detach lab files without permanently deleting them. */
|
|
81
|
+
inContext?: boolean;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
export interface Message {
|
|
@@ -802,6 +805,34 @@ export async function deleteQuestionContextFile(
|
|
|
802
805
|
}
|
|
803
806
|
}
|
|
804
807
|
|
|
808
|
+
/** Removes a lab file from the AI context without deleting it from disk. */
|
|
809
|
+
export async function detachQuestionContextFile(
|
|
810
|
+
questionId: string,
|
|
811
|
+
fileId: string,
|
|
812
|
+
): Promise<ContextFile> {
|
|
813
|
+
const q = await getQuestion(questionId);
|
|
814
|
+
if (!q) throw new Error("Question not found");
|
|
815
|
+
const cf = (q.contextFiles || []).find((f) => f.id === fileId);
|
|
816
|
+
if (!cf) throw new Error("Context file not found");
|
|
817
|
+
cf.inContext = false;
|
|
818
|
+
await saveQuestion(q);
|
|
819
|
+
return cf;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Re-attaches a detached lab file to the AI context. */
|
|
823
|
+
export async function attachQuestionContextFile(
|
|
824
|
+
questionId: string,
|
|
825
|
+
fileId: string,
|
|
826
|
+
): Promise<ContextFile> {
|
|
827
|
+
const q = await getQuestion(questionId);
|
|
828
|
+
if (!q) throw new Error("Question not found");
|
|
829
|
+
const cf = (q.contextFiles || []).find((f) => f.id === fileId);
|
|
830
|
+
if (!cf) throw new Error("Context file not found");
|
|
831
|
+
cf.inContext = true;
|
|
832
|
+
await saveQuestion(q);
|
|
833
|
+
return cf;
|
|
834
|
+
}
|
|
835
|
+
|
|
805
836
|
export async function updateQuestionMessages(
|
|
806
837
|
questionId: string,
|
|
807
838
|
messages: Message[],
|