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.
@@ -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[],