create-interview-cockpit 0.10.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.
|
@@ -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 () => {
|