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
|
-
"
|
|
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["
|
|
1179
|
-
? "
|
|
1180
|
-
:
|
|
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
|
|
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
|
|
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
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
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
|
})();
|
package/template/cockpit.json
CHANGED
|
@@ -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 () => {
|