create-interview-cockpit 0.6.0 → 0.7.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/api.ts +65 -2
- package/template/client/src/components/CodeContextPanel.tsx +111 -0
- package/template/client/src/components/CodeRunnerModal.tsx +457 -163
- package/template/client/src/reactLab.ts +488 -5
- package/template/client/src/store.ts +35 -4
- package/template/client/src/types.ts +3 -2
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +2 -0
- package/template/server/src/index.ts +266 -5
- package/template/server/src/storage.ts +11 -2
|
@@ -690,7 +690,14 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
690
690
|
code: string;
|
|
691
691
|
language: string;
|
|
692
692
|
label: string;
|
|
693
|
-
origin:
|
|
693
|
+
origin:
|
|
694
|
+
| "user"
|
|
695
|
+
| "ai"
|
|
696
|
+
| "sandbox"
|
|
697
|
+
| "infra"
|
|
698
|
+
| "react"
|
|
699
|
+
| "nextjs"
|
|
700
|
+
| "module-federation";
|
|
694
701
|
};
|
|
695
702
|
if (typeof code !== "string" || !code.trim()) {
|
|
696
703
|
return res.status(400).json({ error: "code is required" });
|
|
@@ -701,11 +708,12 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
701
708
|
origin !== "sandbox" &&
|
|
702
709
|
origin !== "infra" &&
|
|
703
710
|
origin !== "react" &&
|
|
704
|
-
origin !== "nextjs"
|
|
711
|
+
origin !== "nextjs" &&
|
|
712
|
+
origin !== "module-federation"
|
|
705
713
|
) {
|
|
706
714
|
return res.status(400).json({
|
|
707
715
|
error:
|
|
708
|
-
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', or '
|
|
716
|
+
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', 'nextjs', or 'module-federation'",
|
|
709
717
|
});
|
|
710
718
|
}
|
|
711
719
|
try {
|
|
@@ -994,7 +1002,7 @@ app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
|
994
1002
|
const { messages, workspace, labType, questionId } = req.body as {
|
|
995
1003
|
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
996
1004
|
workspace?: Record<string, string>;
|
|
997
|
-
labType?: "react" | "nextjs";
|
|
1005
|
+
labType?: "react" | "nextjs" | "module-federation";
|
|
998
1006
|
questionId?: string;
|
|
999
1007
|
};
|
|
1000
1008
|
|
|
@@ -1008,7 +1016,11 @@ app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
|
1008
1016
|
const aiSettings = await storage.getAiSettings();
|
|
1009
1017
|
|
|
1010
1018
|
const typeLabel =
|
|
1011
|
-
labType === "nextjs"
|
|
1019
|
+
labType === "nextjs"
|
|
1020
|
+
? "Next.js App Router"
|
|
1021
|
+
: labType === "module-federation"
|
|
1022
|
+
? "Webpack Module Federation"
|
|
1023
|
+
: "React + TypeScript";
|
|
1012
1024
|
|
|
1013
1025
|
let workspaceBlock = "";
|
|
1014
1026
|
if (workspace && typeof workspace === "object") {
|
|
@@ -2280,6 +2292,91 @@ const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
|
|
|
2280
2292
|
// walking up the directory tree — no symlinks needed, no Turbopack restrictions.
|
|
2281
2293
|
const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
|
|
2282
2294
|
|
|
2295
|
+
interface ModuleFederationSandboxEntry {
|
|
2296
|
+
child: ReturnType<typeof spawn>;
|
|
2297
|
+
dir: string;
|
|
2298
|
+
hostUrl: string;
|
|
2299
|
+
appUrls: Record<string, string>;
|
|
2300
|
+
workspaceFiles: Set<string>;
|
|
2301
|
+
logs: string[];
|
|
2302
|
+
ready: boolean;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const moduleFederationSandboxes = new Map<
|
|
2306
|
+
string,
|
|
2307
|
+
ModuleFederationSandboxEntry
|
|
2308
|
+
>();
|
|
2309
|
+
const MODULE_FEDERATION_SANDBOX_BASE = path.join(
|
|
2310
|
+
os.tmpdir(),
|
|
2311
|
+
"interview-cockpit-module-federation",
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
function appendSandboxLog(logs: string[], text: string): string {
|
|
2315
|
+
const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2316
|
+
logs.push(clean);
|
|
2317
|
+
if (logs.length > 400) {
|
|
2318
|
+
logs.splice(0, logs.length - 400);
|
|
2319
|
+
}
|
|
2320
|
+
return clean;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function npmCommand(): string {
|
|
2324
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
async function writeModuleFederationSandboxFiles(
|
|
2328
|
+
dir: string,
|
|
2329
|
+
files: Record<string, string>,
|
|
2330
|
+
) {
|
|
2331
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2332
|
+
const fullPath = path.join(dir, filePath);
|
|
2333
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2334
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function runLoggedCommand(
|
|
2339
|
+
command: string,
|
|
2340
|
+
args: string[],
|
|
2341
|
+
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
|
2342
|
+
logs: string[],
|
|
2343
|
+
): Promise<void> {
|
|
2344
|
+
await new Promise<void>((resolve, reject) => {
|
|
2345
|
+
const child = spawn(command, args, {
|
|
2346
|
+
cwd: options.cwd,
|
|
2347
|
+
env: options.env,
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2351
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2352
|
+
});
|
|
2353
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2354
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2355
|
+
});
|
|
2356
|
+
child.on("error", reject);
|
|
2357
|
+
child.on("exit", (code) => {
|
|
2358
|
+
if (code === 0) {
|
|
2359
|
+
resolve();
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
reject(
|
|
2363
|
+
new Error(
|
|
2364
|
+
logs.join("").trim() ||
|
|
2365
|
+
`${command} ${args.join(" ")} exited with code ${String(code)}`,
|
|
2366
|
+
),
|
|
2367
|
+
);
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
async function getDistinctPorts(count: number): Promise<number[]> {
|
|
2373
|
+
const ports = new Set<number>();
|
|
2374
|
+
while (ports.size < count) {
|
|
2375
|
+
ports.add(await getFreePort());
|
|
2376
|
+
}
|
|
2377
|
+
return Array.from(ports);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2283
2380
|
/** Write all user files and necessary config into a sandbox directory. */
|
|
2284
2381
|
async function writeNextSandboxFiles(
|
|
2285
2382
|
dir: string,
|
|
@@ -2480,6 +2577,170 @@ app.delete("/api/nextjs/:id", async (req, res) => {
|
|
|
2480
2577
|
res.json({ ok: true });
|
|
2481
2578
|
});
|
|
2482
2579
|
|
|
2580
|
+
app.post("/api/module-federation/start", async (req, res) => {
|
|
2581
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2582
|
+
if (!files || typeof files !== "object") {
|
|
2583
|
+
return res.status(400).json({ error: "files is required" });
|
|
2584
|
+
}
|
|
2585
|
+
if (typeof files["package.json"] !== "string") {
|
|
2586
|
+
return res.status(400).json({ error: "package.json is required" });
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
const id = randomUUID();
|
|
2590
|
+
const dir = path.join(MODULE_FEDERATION_SANDBOX_BASE, id);
|
|
2591
|
+
const logs: string[] = [];
|
|
2592
|
+
|
|
2593
|
+
try {
|
|
2594
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2595
|
+
await writeModuleFederationSandboxFiles(dir, files);
|
|
2596
|
+
|
|
2597
|
+
await runLoggedCommand(
|
|
2598
|
+
npmCommand(),
|
|
2599
|
+
["install", "--no-audit", "--no-fund", "--prefer-offline"],
|
|
2600
|
+
{
|
|
2601
|
+
cwd: dir,
|
|
2602
|
+
env: {
|
|
2603
|
+
...process.env,
|
|
2604
|
+
npm_config_update_notifier: "false",
|
|
2605
|
+
},
|
|
2606
|
+
},
|
|
2607
|
+
logs,
|
|
2608
|
+
);
|
|
2609
|
+
|
|
2610
|
+
const [hostPort, profilePort, checkoutPort] = await getDistinctPorts(3);
|
|
2611
|
+
const appUrls = {
|
|
2612
|
+
host: `http://localhost:${hostPort}`,
|
|
2613
|
+
profile: `http://localhost:${profilePort}`,
|
|
2614
|
+
checkout: `http://localhost:${checkoutPort}`,
|
|
2615
|
+
};
|
|
2616
|
+
const readyPorts = new Set<string>();
|
|
2617
|
+
|
|
2618
|
+
const child = spawn(npmCommand(), ["run", "dev"], {
|
|
2619
|
+
cwd: dir,
|
|
2620
|
+
env: {
|
|
2621
|
+
...process.env,
|
|
2622
|
+
HOST_PORT: String(hostPort),
|
|
2623
|
+
PROFILE_PORT: String(profilePort),
|
|
2624
|
+
CHECKOUT_PORT: String(checkoutPort),
|
|
2625
|
+
npm_config_update_notifier: "false",
|
|
2626
|
+
},
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
const entry: ModuleFederationSandboxEntry = {
|
|
2630
|
+
child,
|
|
2631
|
+
dir,
|
|
2632
|
+
hostUrl: appUrls.host,
|
|
2633
|
+
appUrls,
|
|
2634
|
+
workspaceFiles: new Set(Object.keys(files)),
|
|
2635
|
+
logs,
|
|
2636
|
+
ready: false,
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
const markReady = (text: string) => {
|
|
2640
|
+
if (text.includes(`localhost:${hostPort}`)) readyPorts.add("host");
|
|
2641
|
+
if (text.includes(`localhost:${profilePort}`)) readyPorts.add("profile");
|
|
2642
|
+
if (text.includes(`localhost:${checkoutPort}`))
|
|
2643
|
+
readyPorts.add("checkout");
|
|
2644
|
+
if (readyPorts.size === 3) {
|
|
2645
|
+
entry.ready = true;
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2648
|
+
|
|
2649
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2650
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2651
|
+
});
|
|
2652
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2653
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2654
|
+
});
|
|
2655
|
+
child.on("exit", () => {
|
|
2656
|
+
moduleFederationSandboxes.delete(id);
|
|
2657
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
moduleFederationSandboxes.set(id, entry);
|
|
2661
|
+
|
|
2662
|
+
const deadline = Date.now() + 90_000;
|
|
2663
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
2664
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2665
|
+
if (!moduleFederationSandboxes.has(id)) {
|
|
2666
|
+
return res.status(500).json({
|
|
2667
|
+
error: logs.join("").trim() || "Webpack module federation lab exited",
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (!entry.ready) {
|
|
2673
|
+
return res.status(504).json({
|
|
2674
|
+
error: "Webpack module federation lab did not start in time",
|
|
2675
|
+
logs,
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
res.json({
|
|
2680
|
+
id,
|
|
2681
|
+
hostUrl: entry.hostUrl,
|
|
2682
|
+
appUrls: entry.appUrls,
|
|
2683
|
+
});
|
|
2684
|
+
} catch (error: any) {
|
|
2685
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2686
|
+
res.status(500).json({
|
|
2687
|
+
error:
|
|
2688
|
+
logs.join("").trim() ||
|
|
2689
|
+
error?.message ||
|
|
2690
|
+
"Failed to start webpack module federation lab",
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
app.post("/api/module-federation/:id/update-files", async (req, res) => {
|
|
2696
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2697
|
+
if (!sandbox) return res.status(404).json({ error: "Sandbox not found" });
|
|
2698
|
+
|
|
2699
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2700
|
+
if (!files || typeof files !== "object") {
|
|
2701
|
+
return res.status(400).json({ error: "files is required" });
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
const nextFiles = new Set(Object.keys(files));
|
|
2705
|
+
await Promise.all(
|
|
2706
|
+
Array.from(sandbox.workspaceFiles)
|
|
2707
|
+
.filter((filePath) => !nextFiles.has(filePath))
|
|
2708
|
+
.map((filePath) =>
|
|
2709
|
+
fs
|
|
2710
|
+
.rm(path.join(sandbox.dir, filePath), { force: true })
|
|
2711
|
+
.catch(() => {}),
|
|
2712
|
+
),
|
|
2713
|
+
);
|
|
2714
|
+
await writeModuleFederationSandboxFiles(sandbox.dir, files);
|
|
2715
|
+
sandbox.workspaceFiles = nextFiles;
|
|
2716
|
+
res.json({ ok: true });
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
app.get("/api/module-federation/:id/status", (req, res) => {
|
|
2720
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2721
|
+
if (!sandbox) {
|
|
2722
|
+
return res.json({ running: false });
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
res.json({
|
|
2726
|
+
running: true,
|
|
2727
|
+
ready: sandbox.ready,
|
|
2728
|
+
hostUrl: sandbox.hostUrl,
|
|
2729
|
+
appUrls: sandbox.appUrls,
|
|
2730
|
+
logs: sandbox.logs.slice(-80),
|
|
2731
|
+
});
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
app.delete("/api/module-federation/:id", async (req, res) => {
|
|
2735
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2736
|
+
if (sandbox) {
|
|
2737
|
+
sandbox.child.kill("SIGTERM");
|
|
2738
|
+
moduleFederationSandboxes.delete(req.params.id);
|
|
2739
|
+
await fs.rm(sandbox.dir, { recursive: true, force: true }).catch(() => {});
|
|
2740
|
+
}
|
|
2741
|
+
res.json({ ok: true });
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2483
2744
|
async function getFreePort(): Promise<number> {
|
|
2484
2745
|
return new Promise((resolve, reject) => {
|
|
2485
2746
|
const srv = net.createServer();
|
|
@@ -61,8 +61,17 @@ export interface ContextFile {
|
|
|
61
61
|
* 'sandbox' = paired server+client sandbox saved as JSON,
|
|
62
62
|
* 'infra' = Terraform-style infra lab workspace saved as JSON,
|
|
63
63
|
* 'react' = React + TypeScript lab workspace,
|
|
64
|
-
* 'nextjs' = Next.js App Router lab workspace
|
|
65
|
-
|
|
64
|
+
* 'nextjs' = Next.js App Router lab workspace,
|
|
65
|
+
* 'module-federation' = Webpack Module Federation lab workspace. */
|
|
66
|
+
origin?:
|
|
67
|
+
| "user"
|
|
68
|
+
| "ai"
|
|
69
|
+
| "upload"
|
|
70
|
+
| "sandbox"
|
|
71
|
+
| "infra"
|
|
72
|
+
| "react"
|
|
73
|
+
| "nextjs"
|
|
74
|
+
| "module-federation";
|
|
66
75
|
/** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
|
|
67
76
|
language?: string;
|
|
68
77
|
/** Short display label for code snippets. */
|