create-interview-cockpit 0.11.0 → 0.12.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.
@@ -5,7 +5,65 @@ export type FrontendLabType = FrontendLabWorkspace["type"];
5
5
  // ── Default file contents ────────────────────────────────────────────────────
6
6
 
7
7
  const REACT_DEFAULT_FILES: Record<string, string> = {
8
- "App.tsx": `import { useState } from "react";
8
+ "package.json": `{
9
+ "name": "react-lab",
10
+ "private": true,
11
+ "version": "0.0.0",
12
+ "type": "module",
13
+ "scripts": {
14
+ "dev": "vite",
15
+ "build": "tsc -b && vite build",
16
+ "preview": "vite preview"
17
+ },
18
+ "dependencies": {
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "^18.3.1",
24
+ "@types/react-dom": "^18.3.1",
25
+ "@vitejs/plugin-react": "^4.3.4",
26
+ "typescript": "^5.6.2",
27
+ "vite": "^6.0.3"
28
+ }
29
+ }
30
+ `,
31
+ "vite.config.ts": `import { defineConfig } from "vite";
32
+ import react from "@vitejs/plugin-react";
33
+
34
+ export default defineConfig({
35
+ plugins: [react()],
36
+ server: {
37
+ headers: {
38
+ "X-Frame-Options": "ALLOWALL",
39
+ },
40
+ },
41
+ });
42
+ `,
43
+ "index.html": `<!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
48
+ <title>React Lab</title>
49
+ </head>
50
+ <body>
51
+ <div id="root"></div>
52
+ <script type="module" src="/src/main.tsx"></script>
53
+ </body>
54
+ </html>
55
+ `,
56
+ "src/main.tsx": `import React from "react";
57
+ import ReactDOM from "react-dom/client";
58
+ import App from "./App";
59
+
60
+ ReactDOM.createRoot(document.getElementById("root")!).render(
61
+ <React.StrictMode>
62
+ <App />
63
+ </React.StrictMode>,
64
+ );
65
+ `,
66
+ "src/App.tsx": `import { useState } from "react";
9
67
  import { Counter } from "./Counter";
10
68
  import type { User } from "./types";
11
69
 
@@ -30,7 +88,7 @@ export default function App() {
30
88
  );
31
89
  }
32
90
  `,
33
- "Counter.tsx": `import { useState, useCallback } from "react";
91
+ "src/Counter.tsx": `import { useState, useCallback } from "react";
34
92
  import type { CounterProps } from "./types";
35
93
 
36
94
  // Stateful child component — receives props from App
@@ -88,7 +146,7 @@ export function Counter({ initialCount = 0, onCountChange }: CounterProps) {
88
146
  );
89
147
  }
90
148
  `,
91
- "types.ts": `// Type definitions — shared across components
149
+ "src/types.ts": `// Type definitions — shared across components
92
150
 
93
151
  export interface User {
94
152
  name: string;
@@ -1065,7 +1123,7 @@ export const DEFAULT_REACT_LAB: FrontendLabWorkspace = {
1065
1123
  version: 1,
1066
1124
  label: "React Lab",
1067
1125
  type: "react",
1068
- activeFile: "App.tsx",
1126
+ activeFile: "src/App.tsx",
1069
1127
  files: REACT_DEFAULT_FILES,
1070
1128
  };
1071
1129
 
@@ -1175,9 +1233,11 @@ export function getEntryFile(workspace: FrontendLabWorkspace): string {
1175
1233
  ? "apps/host/src/App.jsx"
1176
1234
  : Object.keys(workspace.files)[0];
1177
1235
  }
1178
- return workspace.files["App.tsx"]
1179
- ? "App.tsx"
1180
- : Object.keys(workspace.files)[0];
1236
+ return workspace.files["main.tsx"]
1237
+ ? "main.tsx"
1238
+ : workspace.files["App.tsx"]
1239
+ ? "App.tsx"
1240
+ : Object.keys(workspace.files)[0];
1181
1241
  }
1182
1242
 
1183
1243
  /** Preferred display order for the file tree. */
@@ -1228,7 +1288,8 @@ export function resolveNextjsEntry(
1228
1288
  *
1229
1289
  * Approach: loads React 18 UMD + Babel standalone from CDN, runs a
1230
1290
  * custom module system built on top of Babel's CJS transform plugin,
1231
- * then renders the default export from `entryFile`.
1291
+ * then either runs a bootstrap entry such as main.tsx or renders the
1292
+ * default export from `entryFile`.
1232
1293
  *
1233
1294
  * CDN URLs are version-pinned so the preview is reproducible.
1234
1295
  */
@@ -1242,9 +1303,6 @@ export function generatePreviewHTML(
1242
1303
  const entryJSON = JSON.stringify(entryFile);
1243
1304
  const sandboxJSON = JSON.stringify(sandboxUrl ?? "");
1244
1305
  const isNextjsJSON = isNextjs ? "true" : "false";
1245
- // _i breaks up the 'import' keyword so Vite/Babel doesn't misparse
1246
- // the template literal below as containing real module import declarations
1247
- const _i = "import";
1248
1306
 
1249
1307
  return `<!DOCTYPE html>
1250
1308
  <html>
@@ -1252,6 +1310,8 @@ export function generatePreviewHTML(
1252
1310
  <meta charset="utf-8">
1253
1311
  <meta name="viewport" content="width=device-width, initial-scale=1">
1254
1312
  <script>window.__F__=${filesJSON};window.__E__=${entryJSON};window.SANDBOX_URL=${sandboxJSON};window.__NX__=${isNextjsJSON};</script>
1313
+ <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
1314
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
1255
1315
  <script src="https://unpkg.com/@babel/standalone@7.26.10/babel.min.js"></script>
1256
1316
  <style>
1257
1317
  *{box-sizing:border-box}
@@ -1262,11 +1322,7 @@ body{margin:0;background:#fff;font-family:system-ui,sans-serif}
1262
1322
  <body>
1263
1323
  <div id="root"></div>
1264
1324
  <div id="__err"></div>
1265
- <script type="module">
1266
- ${_i} React from 'https://esm.sh/react@19.1.0';
1267
- ${_i} * as ReactDOM from 'https://esm.sh/react-dom@19.1.0/client?deps=react@19.1.0';
1268
- window.React = React;
1269
- window.ReactDOM = ReactDOM;
1325
+ <script>
1270
1326
  (function(){
1271
1327
  var files=window.__F__,entry=window.__E__,reg={};
1272
1328
  function norm(from,id){
@@ -1329,27 +1385,36 @@ window.ReactDOM = ReactDOM;
1329
1385
  window.addEventListener('unhandledrejection',function(e){showErr(e.reason&&e.reason.message?e.reason.message:String(e.reason));});
1330
1386
  try{
1331
1387
  order.forEach(loadMod);
1332
- var em=reg[entry];
1333
- if(!em)throw new Error('Entry not found: '+entry);
1334
- var Comp=em.exports.default;
1335
- if(typeof Comp!=='function')throw new Error('No default export (function/component) in '+entry);
1336
1388
  // Expose a navigate helper so in-preview code can trigger URL bar changes:
1337
1389
  // window.__nxNavigate('/dashboard')
1338
1390
  window.__nxNavigate=function(to){try{parent.postMessage({type:'rlab-nav',to:to},'*');}catch(e){}};
1339
- var pageEl=React.createElement(Comp,null);
1340
- // In Next.js mode: wrap the page in app/layout.tsx if it exists
1341
- if(window.__NX__){
1342
- var lk=null;
1343
- for(var _le of['app/layout.tsx','app/layout.ts','app/layout.jsx','app/layout.js']){
1344
- if(reg[_le]){lk=_le;break;}
1391
+ var em=reg[entry];
1392
+ if(!em)throw new Error('Entry not found: '+entry);
1393
+ var isBootstrapEntry=/(^|\\/)(main|index)\\.(tsx|ts|jsx|js)$/.test(entry);
1394
+ if(isBootstrapEntry){
1395
+ if(typeof em.exports.mount==='function'){
1396
+ var mountRoot=document.getElementById('root');
1397
+ if(!mountRoot)throw new Error('Root element #root not found');
1398
+ em.exports.mount(mountRoot);
1345
1399
  }
1346
- if(lk&&typeof reg[lk].exports.default==='function'){
1347
- pageEl=React.createElement(reg[lk].exports.default,null,pageEl);
1400
+ }else{
1401
+ var Comp=em.exports.default;
1402
+ if(typeof Comp!=='function')throw new Error('No default export (function/component) in '+entry);
1403
+ var pageEl=React.createElement(Comp,null);
1404
+ // In Next.js mode: wrap the page in app/layout.tsx if it exists
1405
+ if(window.__NX__){
1406
+ var lk=null;
1407
+ for(var _le of['app/layout.tsx','app/layout.ts','app/layout.jsx','app/layout.js']){
1408
+ if(reg[_le]){lk=_le;break;}
1409
+ }
1410
+ if(lk&&typeof reg[lk].exports.default==='function'){
1411
+ pageEl=React.createElement(reg[lk].exports.default,null,pageEl);
1412
+ }
1348
1413
  }
1414
+ ReactDOM.createRoot(document.getElementById('root')).render(
1415
+ React.createElement(React.StrictMode,null,pageEl)
1416
+ );
1349
1417
  }
1350
- ReactDOM.createRoot(document.getElementById('root')).render(
1351
- React.createElement(React.StrictMode,null,pageEl)
1352
- );
1353
1418
  try{parent.postMessage({type:'rlab-ready'},'*');}catch(e){}
1354
1419
  }catch(err){showErr(err.message+(err.stack?'\\n\\n'+err.stack:''));}
1355
1420
  })();
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.9.0"
2
+ "version": "0.10.0"
3
3
  }
@@ -2458,6 +2458,42 @@ function isPathInside(root: string, target: string): boolean {
2458
2458
  );
2459
2459
  }
2460
2460
 
2461
+ // Permissive npm command parser for the React lab.
2462
+ // Allows any npm subcommand (install, uninstall, run, update, etc.)
2463
+ // but blocks shell operators and dangerous flags.
2464
+ function parseReactLabCommand(command: string): { args: string[] } {
2465
+ const tokens = splitShellLikeCommand(command);
2466
+ if (tokens.length === 0) {
2467
+ throw new Error("Command cannot be empty");
2468
+ }
2469
+
2470
+ if (tokens[0] !== "npm") {
2471
+ throw new Error("Only npm commands are supported in the React lab console");
2472
+ }
2473
+
2474
+ if (tokens.some((t) => MODULE_FEDERATION_SHELL_META_TOKENS.has(t))) {
2475
+ throw new Error(
2476
+ "Shell operators are not supported. Run one npm command at a time.",
2477
+ );
2478
+ }
2479
+
2480
+ const dangerous = [
2481
+ "--prefix",
2482
+ "--workspaces",
2483
+ "-w",
2484
+ "--workspace",
2485
+ "--global",
2486
+ "-g",
2487
+ ];
2488
+ if (
2489
+ tokens.some((t) => dangerous.some((d) => t === d || t.startsWith(d + "=")))
2490
+ ) {
2491
+ throw new Error("That flag is not allowed in the React lab console.");
2492
+ }
2493
+
2494
+ return { args: tokens.slice(1) };
2495
+ }
2496
+
2461
2497
  function parseModuleFederationCommand(command: string): {
2462
2498
  args: string[];
2463
2499
  displayCommand: string;
@@ -3330,6 +3366,245 @@ ${code}`;
3330
3366
  res.end();
3331
3367
  });
3332
3368
 
3369
+ // ─── React Lab (Vite) sandboxes ──────────────────────────────────────────────
3370
+
3371
+ interface ReactLabSandboxEntry {
3372
+ child: ReturnType<typeof spawn>;
3373
+ port: number;
3374
+ dir: string;
3375
+ logs: string[];
3376
+ ready: boolean;
3377
+ }
3378
+
3379
+ const reactLabSandboxes = new Map<string, ReactLabSandboxEntry>();
3380
+ const REACT_LAB_SANDBOX_BASE = path.join(
3381
+ os.tmpdir(),
3382
+ "interview-cockpit-react-lab",
3383
+ );
3384
+
3385
+ app.post("/api/react-lab/start", async (req, res) => {
3386
+ const { files } = req.body as { files?: Record<string, string> };
3387
+ if (!files || typeof files !== "object") {
3388
+ return res.status(400).json({ error: "files is required" });
3389
+ }
3390
+
3391
+ const id = randomUUID();
3392
+ const dir = path.join(REACT_LAB_SANDBOX_BASE, id);
3393
+ const logs: string[] = [];
3394
+
3395
+ try {
3396
+ await fs.mkdir(dir, { recursive: true });
3397
+
3398
+ // Write all user files as-is
3399
+ for (const [filePath, content] of Object.entries(files)) {
3400
+ const fullPath = path.join(dir, filePath);
3401
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
3402
+ await fs.writeFile(fullPath, content, "utf8");
3403
+ }
3404
+
3405
+ // Auto-generate index.html if the user didn't provide one
3406
+ const hasIndex = await fs
3407
+ .access(path.join(dir, "index.html"))
3408
+ .then(() => true)
3409
+ .catch(() => false);
3410
+ if (!hasIndex) {
3411
+ await fs.writeFile(
3412
+ path.join(dir, "index.html"),
3413
+ `<!DOCTYPE html>
3414
+ <html lang="en">
3415
+ <head>
3416
+ <meta charset="UTF-8" />
3417
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3418
+ <title>React Lab</title>
3419
+ </head>
3420
+ <body>
3421
+ <div id="root"></div>
3422
+ <script type="module" src="/src/main.tsx"></script>
3423
+ </body>
3424
+ </html>\n`,
3425
+ "utf8",
3426
+ );
3427
+ }
3428
+
3429
+ // Install dependencies
3430
+ appendSandboxLog(logs, "Installing dependencies…\n");
3431
+ await runLoggedCommand(
3432
+ npmCommand(),
3433
+ ["install", "--no-audit", "--no-fund", "--prefer-offline"],
3434
+ {
3435
+ cwd: dir,
3436
+ env: { ...process.env, npm_config_update_notifier: "false" },
3437
+ },
3438
+ logs,
3439
+ );
3440
+
3441
+ const port = await getFreePort();
3442
+
3443
+ const child = spawn(
3444
+ npmCommand(),
3445
+ ["run", "dev", "--", "--port", String(port), "--host", "localhost"],
3446
+ {
3447
+ cwd: dir,
3448
+ env: {
3449
+ ...process.env,
3450
+ npm_config_update_notifier: "false",
3451
+ NO_COLOR: "1",
3452
+ },
3453
+ },
3454
+ );
3455
+
3456
+ const entry: ReactLabSandboxEntry = {
3457
+ child,
3458
+ port,
3459
+ dir,
3460
+ logs,
3461
+ ready: false,
3462
+ };
3463
+
3464
+ const markReady = (text: string) => {
3465
+ if (!entry.ready && /Local:|ready in/i.test(text)) {
3466
+ entry.ready = true;
3467
+ }
3468
+ };
3469
+
3470
+ child.stdout.on("data", (chunk: Buffer) => {
3471
+ markReady(appendSandboxLog(logs, chunk.toString()));
3472
+ });
3473
+ child.stderr.on("data", (chunk: Buffer) => {
3474
+ markReady(appendSandboxLog(logs, chunk.toString()));
3475
+ });
3476
+ child.on("exit", () => {
3477
+ reactLabSandboxes.delete(id);
3478
+ fs.rm(dir, { recursive: true, force: true }).catch(() => {});
3479
+ });
3480
+
3481
+ reactLabSandboxes.set(id, entry);
3482
+
3483
+ const deadline = Date.now() + 60_000;
3484
+ while (!entry.ready && Date.now() < deadline) {
3485
+ await new Promise((r) => setTimeout(r, 400));
3486
+ if (!reactLabSandboxes.has(id)) {
3487
+ return res
3488
+ .status(500)
3489
+ .json({ error: logs.join("").trim() || "Vite server exited" });
3490
+ }
3491
+ }
3492
+
3493
+ if (!entry.ready) {
3494
+ return res
3495
+ .status(504)
3496
+ .json({ error: "Vite did not start in time", logs });
3497
+ }
3498
+
3499
+ res.json({ id, port, url: `http://localhost:${port}` });
3500
+ } catch (error: any) {
3501
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
3502
+ res.status(500).json({
3503
+ error:
3504
+ logs.join("").trim() || error?.message || "Failed to start React lab",
3505
+ });
3506
+ }
3507
+ });
3508
+
3509
+ app.post("/api/react-lab/:id/update-files", async (req, res) => {
3510
+ const sb = reactLabSandboxes.get(req.params.id);
3511
+ if (!sb) return res.status(404).json({ error: "Sandbox not found" });
3512
+ const { files } = req.body as { files?: Record<string, string> };
3513
+ if (!files || typeof files !== "object")
3514
+ return res.status(400).json({ error: "files is required" });
3515
+ for (const [filePath, content] of Object.entries(files)) {
3516
+ const fullPath = path.join(sb.dir, filePath);
3517
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
3518
+ await fs.writeFile(fullPath, content, "utf8");
3519
+ }
3520
+ res.json({ ok: true });
3521
+ });
3522
+
3523
+ app.post("/api/react-lab/:id/command-stream", async (req, res) => {
3524
+ const sb = reactLabSandboxes.get(req.params.id);
3525
+
3526
+ res.setHeader("Content-Type", "text/event-stream");
3527
+ res.setHeader("Cache-Control", "no-cache");
3528
+ res.setHeader("Connection", "keep-alive");
3529
+ res.flushHeaders();
3530
+
3531
+ const send = (payload: unknown) => {
3532
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
3533
+ };
3534
+
3535
+ if (!sb) {
3536
+ send({ type: "error", error: "Sandbox not found" });
3537
+ res.end();
3538
+ return;
3539
+ }
3540
+
3541
+ const { command } = req.body as { command?: string };
3542
+ if (typeof command !== "string" || !command.trim()) {
3543
+ send({ type: "error", error: "command is required" });
3544
+ res.end();
3545
+ return;
3546
+ }
3547
+
3548
+ try {
3549
+ const parsed = parseReactLabCommand(command);
3550
+
3551
+ send({ type: "output", kind: "info", text: `$ ${command.trim()}\n` });
3552
+
3553
+ await runStreamedCommand(
3554
+ npmCommand(),
3555
+ parsed.args,
3556
+ {
3557
+ cwd: sb.dir,
3558
+ env: { ...process.env, npm_config_update_notifier: "false" },
3559
+ },
3560
+ ({ kind, text }) => send({ type: "output", kind, text }),
3561
+ );
3562
+
3563
+ send({ type: "complete" });
3564
+ } catch (error: any) {
3565
+ send({ type: "error", error: error?.message || "Command failed" });
3566
+ }
3567
+
3568
+ res.end();
3569
+ });
3570
+
3571
+ app.get("/api/react-lab/:id/read-file", async (req, res) => {
3572
+ const sb = reactLabSandboxes.get(req.params.id);
3573
+ if (!sb) return res.status(404).json({ error: "Sandbox not found" });
3574
+
3575
+ const filePath =
3576
+ typeof req.query.path === "string" ? req.query.path : undefined;
3577
+ if (!filePath) return res.status(400).json({ error: "path is required" });
3578
+
3579
+ // Restrict to safe relative paths only
3580
+ const normalized = path.normalize(filePath).replace(/^\//, "");
3581
+ if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
3582
+ return res.status(400).json({ error: "Invalid path" });
3583
+ }
3584
+
3585
+ const fullPath = path.join(sb.dir, normalized);
3586
+ if (!isPathInside(sb.dir, fullPath)) {
3587
+ return res.status(400).json({ error: "Path must stay inside the sandbox" });
3588
+ }
3589
+
3590
+ try {
3591
+ const content = await fs.readFile(fullPath, "utf8");
3592
+ res.json({ path: normalized, content });
3593
+ } catch {
3594
+ res.status(404).json({ error: "File not found" });
3595
+ }
3596
+ });
3597
+
3598
+ app.delete("/api/react-lab/:id", async (req, res) => {
3599
+ const sb = reactLabSandboxes.get(req.params.id);
3600
+ if (sb) {
3601
+ sb.child.kill("SIGTERM");
3602
+ reactLabSandboxes.delete(req.params.id);
3603
+ await fs.rm(sb.dir, { recursive: true, force: true }).catch(() => {});
3604
+ }
3605
+ res.json({ ok: true });
3606
+ });
3607
+
3333
3608
  // ─── Start ───────────────────────────────────────────────
3334
3609
 
3335
3610
  (async () => {