create-interview-cockpit 0.17.3 → 0.19.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/App.tsx +3 -0
- package/template/client/src/api.ts +184 -8
- package/template/client/src/components/GhaHistoryPanel.tsx +194 -0
- package/template/client/src/components/GhaJobsPanel.tsx +432 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +1048 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +603 -60
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +294 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +130 -10
- package/template/client/src/types.ts +33 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +793 -0
- package/template/server/src/google-drive.ts +542 -149
- package/template/server/src/index.ts +327 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -1206,6 +1206,415 @@ export const DEFAULT_NEXTJS_LAB: FrontendLabWorkspace = {
|
|
|
1206
1206
|
files: NEXTJS_DEFAULT_FILES,
|
|
1207
1207
|
};
|
|
1208
1208
|
|
|
1209
|
+
const NEXTJS_BFF_AUTH_CLIENT_FILES: Record<string, string> = {
|
|
1210
|
+
"README.md": `# Next.js BFF Auth Client Lab
|
|
1211
|
+
|
|
1212
|
+
This lab is intentionally separate from the Infrastructure Lab.
|
|
1213
|
+
|
|
1214
|
+
## Flow
|
|
1215
|
+
|
|
1216
|
+
1. Deploy the infrastructure lab first:
|
|
1217
|
+
- terraform init
|
|
1218
|
+
- terraform plan
|
|
1219
|
+
- terraform apply -auto-approve
|
|
1220
|
+
2. Start this Next.js lab.
|
|
1221
|
+
3. Click **Sign in through BFF**.
|
|
1222
|
+
4. The browser goes to the deployed BFF at http://localhost:4300.
|
|
1223
|
+
5. The BFF redirects to the local Cognito-like provider.
|
|
1224
|
+
6. After sign-in, the BFF stores tokens in Redis and redirects back to this Next.js app.
|
|
1225
|
+
7. This app calls the BFF with credentials included.
|
|
1226
|
+
|
|
1227
|
+
## Why separate labs?
|
|
1228
|
+
|
|
1229
|
+
- Infra Lab owns deployment.
|
|
1230
|
+
- Next.js Lab owns the frontend shell/client experience.
|
|
1231
|
+
- The integration point is the BFF URL: http://localhost:4300.
|
|
1232
|
+
`,
|
|
1233
|
+
"app/layout.tsx": `import "./globals.css";
|
|
1234
|
+
|
|
1235
|
+
export const metadata = {
|
|
1236
|
+
title: "Enterprise Auth Client Lab",
|
|
1237
|
+
description: "Next.js client talking to a locally deployed BFF",
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1241
|
+
return (
|
|
1242
|
+
<html lang="en">
|
|
1243
|
+
<body>{children}</body>
|
|
1244
|
+
</html>
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
`,
|
|
1248
|
+
"app/page.tsx": `import { AuthDashboard } from "../components/AuthDashboard";
|
|
1249
|
+
|
|
1250
|
+
export default function HomePage() {
|
|
1251
|
+
return <AuthDashboard />;
|
|
1252
|
+
}
|
|
1253
|
+
`,
|
|
1254
|
+
"app/globals.css": `* {
|
|
1255
|
+
box-sizing: border-box;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
html,
|
|
1259
|
+
body {
|
|
1260
|
+
margin: 0;
|
|
1261
|
+
min-height: 100%;
|
|
1262
|
+
background:
|
|
1263
|
+
radial-gradient(circle at top left, rgba(6, 182, 212, 0.18), transparent 30rem),
|
|
1264
|
+
radial-gradient(circle at bottom right, rgba(124, 58, 237, 0.16), transparent 28rem),
|
|
1265
|
+
#020617;
|
|
1266
|
+
color: #e2e8f0;
|
|
1267
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
button,
|
|
1271
|
+
textarea {
|
|
1272
|
+
font: inherit;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
button {
|
|
1276
|
+
cursor: pointer;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
.shell {
|
|
1280
|
+
width: min(1120px, calc(100vw - 32px));
|
|
1281
|
+
margin: 0 auto;
|
|
1282
|
+
padding: 48px 0;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.hero,
|
|
1286
|
+
.card {
|
|
1287
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
1288
|
+
background: rgba(15, 23, 42, 0.78);
|
|
1289
|
+
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
|
|
1290
|
+
backdrop-filter: blur(18px);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.hero {
|
|
1294
|
+
border-radius: 28px;
|
|
1295
|
+
padding: 40px;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.eyebrow {
|
|
1299
|
+
margin: 0 0 12px;
|
|
1300
|
+
color: #22d3ee;
|
|
1301
|
+
font-size: 0.78rem;
|
|
1302
|
+
font-weight: 800;
|
|
1303
|
+
letter-spacing: 0.22em;
|
|
1304
|
+
text-transform: uppercase;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
h1,
|
|
1308
|
+
h2,
|
|
1309
|
+
p {
|
|
1310
|
+
margin-top: 0;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
h1 {
|
|
1314
|
+
max-width: 760px;
|
|
1315
|
+
margin-bottom: 12px;
|
|
1316
|
+
color: #f8fafc;
|
|
1317
|
+
font-size: clamp(2.25rem, 6vw, 4.5rem);
|
|
1318
|
+
line-height: 0.95;
|
|
1319
|
+
letter-spacing: -0.06em;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
.hero p {
|
|
1323
|
+
max-width: 720px;
|
|
1324
|
+
color: #cbd5e1;
|
|
1325
|
+
font-size: 1.05rem;
|
|
1326
|
+
line-height: 1.7;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
.actions {
|
|
1330
|
+
display: flex;
|
|
1331
|
+
flex-wrap: wrap;
|
|
1332
|
+
gap: 12px;
|
|
1333
|
+
margin-top: 28px;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
button {
|
|
1337
|
+
border: 1px solid rgba(148, 163, 184, 0.26);
|
|
1338
|
+
border-radius: 999px;
|
|
1339
|
+
background: rgba(15, 23, 42, 0.92);
|
|
1340
|
+
color: #e2e8f0;
|
|
1341
|
+
padding: 11px 16px;
|
|
1342
|
+
font-weight: 800;
|
|
1343
|
+
transition: transform 150ms ease, border-color 150ms ease, background 150ms ease;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
button:hover:not(:disabled) {
|
|
1347
|
+
transform: translateY(-1px);
|
|
1348
|
+
border-color: rgba(34, 211, 238, 0.7);
|
|
1349
|
+
background: rgba(8, 47, 73, 0.9);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
button:disabled {
|
|
1353
|
+
cursor: not-allowed;
|
|
1354
|
+
opacity: 0.45;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
button.primary {
|
|
1358
|
+
border-color: rgba(34, 211, 238, 0.5);
|
|
1359
|
+
background: linear-gradient(135deg, #06b6d4, #8b5cf6);
|
|
1360
|
+
color: #020617;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.grid {
|
|
1364
|
+
display: grid;
|
|
1365
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1366
|
+
gap: 18px;
|
|
1367
|
+
margin-top: 18px;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
.card {
|
|
1371
|
+
border-radius: 22px;
|
|
1372
|
+
padding: 24px;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
.card.wide {
|
|
1376
|
+
grid-column: 1 / -1;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
.cardHeader {
|
|
1380
|
+
display: flex;
|
|
1381
|
+
align-items: center;
|
|
1382
|
+
gap: 12px;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
.cardHeader span {
|
|
1386
|
+
display: inline-grid;
|
|
1387
|
+
width: 30px;
|
|
1388
|
+
height: 30px;
|
|
1389
|
+
place-items: center;
|
|
1390
|
+
border-radius: 999px;
|
|
1391
|
+
background: rgba(34, 211, 238, 0.14);
|
|
1392
|
+
color: #67e8f9;
|
|
1393
|
+
font-weight: 900;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
h2 {
|
|
1397
|
+
margin-bottom: 0;
|
|
1398
|
+
color: #f8fafc;
|
|
1399
|
+
font-size: 1rem;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
.hint {
|
|
1403
|
+
margin: 12px 0 16px;
|
|
1404
|
+
color: #94a3b8;
|
|
1405
|
+
line-height: 1.6;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
pre,
|
|
1409
|
+
textarea {
|
|
1410
|
+
width: 100%;
|
|
1411
|
+
border: 1px solid rgba(51, 65, 85, 0.95);
|
|
1412
|
+
border-radius: 16px;
|
|
1413
|
+
background: rgba(2, 6, 23, 0.8);
|
|
1414
|
+
color: #cbd5e1;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
pre {
|
|
1418
|
+
min-height: 164px;
|
|
1419
|
+
overflow: auto;
|
|
1420
|
+
padding: 16px;
|
|
1421
|
+
font-size: 0.82rem;
|
|
1422
|
+
line-height: 1.55;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
textarea {
|
|
1426
|
+
display: block;
|
|
1427
|
+
margin-bottom: 12px;
|
|
1428
|
+
padding: 14px;
|
|
1429
|
+
resize: vertical;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
@media (max-width: 820px) {
|
|
1433
|
+
.grid {
|
|
1434
|
+
grid-template-columns: 1fr;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
`,
|
|
1438
|
+
"components/AuthDashboard.tsx": `"use client";
|
|
1439
|
+
|
|
1440
|
+
import { useEffect, useMemo, useState } from "react";
|
|
1441
|
+
|
|
1442
|
+
const BFF_BASE_URL = "http://localhost:4300";
|
|
1443
|
+
|
|
1444
|
+
type ApiResult = {
|
|
1445
|
+
status: number;
|
|
1446
|
+
body: unknown;
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
function getCookie(name: string): string | null {
|
|
1450
|
+
const match = document.cookie
|
|
1451
|
+
.split("; ")
|
|
1452
|
+
.find((row) => row.startsWith(name + "="));
|
|
1453
|
+
|
|
1454
|
+
return match ? decodeURIComponent(match.split("=")[1]) : null;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function bffFetch(path: string, init: RequestInit = {}): Promise<ApiResult> {
|
|
1458
|
+
const method = (init.method || "GET").toUpperCase();
|
|
1459
|
+
const headers = new Headers(init.headers || {});
|
|
1460
|
+
|
|
1461
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
|
|
1462
|
+
const csrf = getCookie("XSRF-TOKEN");
|
|
1463
|
+
if (csrf) headers.set("x-csrf-token", csrf);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const response = await fetch(BFF_BASE_URL + path, {
|
|
1467
|
+
...init,
|
|
1468
|
+
headers,
|
|
1469
|
+
credentials: "include",
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
const text = await response.text();
|
|
1473
|
+
let body: unknown = text;
|
|
1474
|
+
try {
|
|
1475
|
+
body = text ? JSON.parse(text) : null;
|
|
1476
|
+
} catch {
|
|
1477
|
+
body = text;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return { status: response.status, body };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
export function AuthDashboard() {
|
|
1484
|
+
const [me, setMe] = useState<ApiResult | null>(null);
|
|
1485
|
+
const [claim, setClaim] = useState<ApiResult | null>(null);
|
|
1486
|
+
const [noteResult, setNoteResult] = useState<ApiResult | null>(null);
|
|
1487
|
+
const [note, setNote] = useState("Customer uploaded first notice of loss.");
|
|
1488
|
+
const [loading, setLoading] = useState<string | null>(null);
|
|
1489
|
+
|
|
1490
|
+
const isAuthenticated = useMemo(() => {
|
|
1491
|
+
if (!me || typeof me.body !== "object" || me.body === null) return false;
|
|
1492
|
+
return Boolean((me.body as { authenticated?: boolean }).authenticated);
|
|
1493
|
+
}, [me]);
|
|
1494
|
+
|
|
1495
|
+
async function run(label: string, action: () => Promise<void>) {
|
|
1496
|
+
setLoading(label);
|
|
1497
|
+
try {
|
|
1498
|
+
await action();
|
|
1499
|
+
} finally {
|
|
1500
|
+
setLoading(null);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
async function refreshMe() {
|
|
1505
|
+
setMe(await bffFetch("/auth/me"));
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
useEffect(() => {
|
|
1509
|
+
void refreshMe();
|
|
1510
|
+
}, []);
|
|
1511
|
+
|
|
1512
|
+
function login() {
|
|
1513
|
+
const returnTo = encodeURIComponent(window.location.origin);
|
|
1514
|
+
window.location.href = BFF_BASE_URL + "/auth/login?returnTo=" + returnTo;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
async function logout() {
|
|
1518
|
+
await run("logout", async () => {
|
|
1519
|
+
await bffFetch("/auth/logout", { method: "POST" });
|
|
1520
|
+
await refreshMe();
|
|
1521
|
+
setClaim(null);
|
|
1522
|
+
setNoteResult(null);
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function loadClaim() {
|
|
1527
|
+
await run("claim", async () => {
|
|
1528
|
+
setClaim(await bffFetch("/api/claims/CLM-1001"));
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function addNote() {
|
|
1533
|
+
await run("note", async () => {
|
|
1534
|
+
setNoteResult(
|
|
1535
|
+
await bffFetch("/api/claims/CLM-1001/notes", {
|
|
1536
|
+
method: "POST",
|
|
1537
|
+
headers: { "content-type": "application/json" },
|
|
1538
|
+
body: JSON.stringify({ text: note }),
|
|
1539
|
+
}),
|
|
1540
|
+
);
|
|
1541
|
+
await loadClaim();
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return (
|
|
1546
|
+
<main className="shell">
|
|
1547
|
+
<section className="hero">
|
|
1548
|
+
<p className="eyebrow">Next.js shell + deployed BFF</p>
|
|
1549
|
+
<h1>Enterprise BFF Auth Client</h1>
|
|
1550
|
+
<p>
|
|
1551
|
+
This frontend stores no bearer tokens. It redirects through the BFF,
|
|
1552
|
+
then calls BFF endpoints with cookies and CSRF protection.
|
|
1553
|
+
</p>
|
|
1554
|
+
<div className="actions">
|
|
1555
|
+
<button className="primary" onClick={login}>Sign in through BFF</button>
|
|
1556
|
+
<button onClick={() => void refreshMe()}>Refresh /auth/me</button>
|
|
1557
|
+
<button onClick={() => void logout()} disabled={!isAuthenticated || loading === "logout"}>
|
|
1558
|
+
Logout
|
|
1559
|
+
</button>
|
|
1560
|
+
</div>
|
|
1561
|
+
</section>
|
|
1562
|
+
|
|
1563
|
+
<section className="grid">
|
|
1564
|
+
<article className="card">
|
|
1565
|
+
<div className="cardHeader">
|
|
1566
|
+
<span>1</span>
|
|
1567
|
+
<h2>Session</h2>
|
|
1568
|
+
</div>
|
|
1569
|
+
<p className="hint">The browser sees only cookie-backed auth state.</p>
|
|
1570
|
+
<pre>{JSON.stringify(me?.body ?? "Not loaded", null, 2)}</pre>
|
|
1571
|
+
</article>
|
|
1572
|
+
|
|
1573
|
+
<article className="card">
|
|
1574
|
+
<div className="cardHeader">
|
|
1575
|
+
<span>2</span>
|
|
1576
|
+
<h2>Downstream API through BFF</h2>
|
|
1577
|
+
</div>
|
|
1578
|
+
<p className="hint">The client never calls the claims API directly.</p>
|
|
1579
|
+
<button onClick={() => void loadClaim()} disabled={!isAuthenticated || loading === "claim"}>
|
|
1580
|
+
Load claim CLM-1001
|
|
1581
|
+
</button>
|
|
1582
|
+
<pre>{JSON.stringify(claim?.body ?? "No claim loaded", null, 2)}</pre>
|
|
1583
|
+
</article>
|
|
1584
|
+
|
|
1585
|
+
<article className="card wide">
|
|
1586
|
+
<div className="cardHeader">
|
|
1587
|
+
<span>3</span>
|
|
1588
|
+
<h2>Write with CSRF header</h2>
|
|
1589
|
+
</div>
|
|
1590
|
+
<p className="hint">
|
|
1591
|
+
The readable XSRF-TOKEN cookie is copied into x-csrf-token for writes.
|
|
1592
|
+
</p>
|
|
1593
|
+
<textarea value={note} onChange={(event) => setNote(event.target.value)} rows={3} />
|
|
1594
|
+
<button onClick={() => void addNote()} disabled={!isAuthenticated || loading === "note"}>
|
|
1595
|
+
Add note
|
|
1596
|
+
</button>
|
|
1597
|
+
<pre>{JSON.stringify(noteResult?.body ?? "No note submitted", null, 2)}</pre>
|
|
1598
|
+
</article>
|
|
1599
|
+
</section>
|
|
1600
|
+
</main>
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
`,
|
|
1604
|
+
"components/AuthDashboard.module.css": `/* Optional scratch file: move styles here if you want CSS modules practice. */
|
|
1605
|
+
`,
|
|
1606
|
+
"app/styles.css": `/* Optional scratch file for experimenting with additional global styles. */
|
|
1607
|
+
`,
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
export const NEXTJS_BFF_AUTH_CLIENT_LAB: FrontendLabWorkspace = {
|
|
1611
|
+
version: 1,
|
|
1612
|
+
label: "Next.js BFF Auth Client",
|
|
1613
|
+
type: "nextjs",
|
|
1614
|
+
activeFile: "components/AuthDashboard.tsx",
|
|
1615
|
+
files: NEXTJS_BFF_AUTH_CLIENT_FILES,
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1209
1618
|
export const DEFAULT_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
|
|
1210
1619
|
version: 1,
|
|
1211
1620
|
label: "Webpack Module Federation Lab",
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
WorkspaceMeta,
|
|
7
7
|
InfraLabWorkspace,
|
|
8
8
|
FrontendLabWorkspace,
|
|
9
|
+
GithubActionsLabWorkspace,
|
|
9
10
|
ContextFileOrigin,
|
|
10
11
|
} from "./types";
|
|
11
12
|
import type { AiSettings } from "./api";
|
|
@@ -126,12 +127,11 @@ interface Store {
|
|
|
126
127
|
deleteWorkspace: (id: string) => Promise<void>;
|
|
127
128
|
renameWorkspace: (id: string, name: string) => Promise<void>;
|
|
128
129
|
patchWorkspace: (id: string, data: object) => Promise<void>;
|
|
129
|
-
syncWorkspace: (id: string) => Promise<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}>;
|
|
130
|
+
syncWorkspace: (id: string) => Promise<import("./api").SyncWorkspaceResult>;
|
|
131
|
+
syncTopic: (
|
|
132
|
+
workspaceId: string,
|
|
133
|
+
topicId: string,
|
|
134
|
+
) => Promise<import("./api").SyncWorkspaceResult>;
|
|
135
135
|
linkDriveFolder: (
|
|
136
136
|
workspaceId: string,
|
|
137
137
|
url: string,
|
|
@@ -148,6 +148,11 @@ interface Store {
|
|
|
148
148
|
id: string,
|
|
149
149
|
targetFolderId?: string,
|
|
150
150
|
) => Promise<import("./api").ExportWorkspaceResult>;
|
|
151
|
+
exportTopic: (
|
|
152
|
+
workspaceId: string,
|
|
153
|
+
topicId: string,
|
|
154
|
+
targetFolderId?: string,
|
|
155
|
+
) => Promise<import("./api").ExportWorkspaceResult>;
|
|
151
156
|
fetchDriveSubfolders: (id: string) => Promise<import("./api").DriveFolder[]>;
|
|
152
157
|
createDriveSubfolder: (
|
|
153
158
|
id: string,
|
|
@@ -176,6 +181,12 @@ interface Store {
|
|
|
176
181
|
topicId: string,
|
|
177
182
|
parentQuestionId: string | null,
|
|
178
183
|
) => Promise<void>;
|
|
184
|
+
copyQuestion: (
|
|
185
|
+
questionId: string,
|
|
186
|
+
topicId: string,
|
|
187
|
+
parentQuestionId: string | null,
|
|
188
|
+
targetTopicId?: string,
|
|
189
|
+
) => Promise<void>;
|
|
179
190
|
removeQuestion: (questionId: string, topicId: string) => Promise<void>;
|
|
180
191
|
renameQuestion: (
|
|
181
192
|
questionId: string,
|
|
@@ -330,6 +341,13 @@ interface Store {
|
|
|
330
341
|
openInfraLab: (workspace?: InfraLabWorkspace, fileId?: string) => void;
|
|
331
342
|
closeInfraLab: () => void;
|
|
332
343
|
|
|
344
|
+
// ── GitHub Actions Lab ───────────────────────────────────────
|
|
345
|
+
showGhaLab: boolean;
|
|
346
|
+
runnerInitialGha: GithubActionsLabWorkspace | null;
|
|
347
|
+
runnerInitialGhaFileId: string | null;
|
|
348
|
+
openGhaLab: (workspace?: GithubActionsLabWorkspace, fileId?: string) => void;
|
|
349
|
+
closeGhaLab: () => void;
|
|
350
|
+
|
|
333
351
|
// ── Frontend Labs (React / Next.js / Module Federation) — open inside the sandbox ──
|
|
334
352
|
openReactLab: (
|
|
335
353
|
workspace?: FrontendLabWorkspace,
|
|
@@ -387,6 +405,9 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
387
405
|
showInfraLab: false,
|
|
388
406
|
runnerInitialInfra: null,
|
|
389
407
|
runnerInitialInfraFileId: null,
|
|
408
|
+
showGhaLab: false,
|
|
409
|
+
runnerInitialGha: null,
|
|
410
|
+
runnerInitialGhaFileId: null,
|
|
390
411
|
showCanvasLab: false,
|
|
391
412
|
canvasLabInitialCode: null,
|
|
392
413
|
canvasLabInitialFileId: null,
|
|
@@ -467,9 +488,49 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
467
488
|
|
|
468
489
|
syncWorkspace: async (id) => {
|
|
469
490
|
const result = await api.syncWorkspaceApi(id);
|
|
491
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
470
494
|
if (id === get().activeWorkspaceId) {
|
|
471
|
-
const topics = await
|
|
472
|
-
|
|
495
|
+
const [topics, workspaceFiles] = await Promise.all([
|
|
496
|
+
api.fetchTopics(),
|
|
497
|
+
api.fetchWorkspaceFiles(),
|
|
498
|
+
]);
|
|
499
|
+
set({ topics, workspaceFiles, questionsByTopic: {} });
|
|
500
|
+
}
|
|
501
|
+
const registry = await api.fetchWorkspaces();
|
|
502
|
+
set({ workspaces: registry.workspaces });
|
|
503
|
+
return result;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
syncTopic: async (workspaceId, topicId) => {
|
|
507
|
+
const result = await api.syncTopicApi(workspaceId, topicId);
|
|
508
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
if (workspaceId === get().activeWorkspaceId) {
|
|
512
|
+
const [topics, questions] = await Promise.all([
|
|
513
|
+
api.fetchTopics(),
|
|
514
|
+
api.fetchQuestions(topicId),
|
|
515
|
+
]);
|
|
516
|
+
set((s) => {
|
|
517
|
+
const selectedStillExists = questions.some(
|
|
518
|
+
(q) => q.id === s.selectedQuestionId,
|
|
519
|
+
);
|
|
520
|
+
const selectedWasInTopic = s.currentQuestion?.topicId === topicId;
|
|
521
|
+
return {
|
|
522
|
+
topics,
|
|
523
|
+
questionsByTopic: { ...s.questionsByTopic, [topicId]: questions },
|
|
524
|
+
selectedQuestionId:
|
|
525
|
+
selectedWasInTopic && !selectedStillExists
|
|
526
|
+
? null
|
|
527
|
+
: s.selectedQuestionId,
|
|
528
|
+
currentQuestion:
|
|
529
|
+
selectedWasInTopic && !selectedStillExists
|
|
530
|
+
? null
|
|
531
|
+
: s.currentQuestion,
|
|
532
|
+
};
|
|
533
|
+
});
|
|
473
534
|
}
|
|
474
535
|
const registry = await api.fetchWorkspaces();
|
|
475
536
|
set({ workspaces: registry.workspaces });
|
|
@@ -491,8 +552,15 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
491
552
|
if (workspaceId === get().activeWorkspaceId) {
|
|
492
553
|
set({ topics: [], questionsByTopic: {} });
|
|
493
554
|
const result = await api.syncWorkspaceApi(workspaceId);
|
|
494
|
-
|
|
495
|
-
|
|
555
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
556
|
+
window.location.href = result.authUrl;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const [topics, workspaceFiles] = await Promise.all([
|
|
560
|
+
api.fetchTopics(),
|
|
561
|
+
api.fetchWorkspaceFiles(),
|
|
562
|
+
]);
|
|
563
|
+
set({ topics, workspaceFiles, questionsByTopic: {} });
|
|
496
564
|
const reg2 = await api.fetchWorkspaces();
|
|
497
565
|
set({ workspaces: reg2.workspaces });
|
|
498
566
|
}
|
|
@@ -508,6 +576,7 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
508
576
|
selectedTopicId: null,
|
|
509
577
|
selectedQuestionId: null,
|
|
510
578
|
currentQuestion: null,
|
|
579
|
+
workspaceFiles: [],
|
|
511
580
|
});
|
|
512
581
|
},
|
|
513
582
|
|
|
@@ -530,6 +599,10 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
530
599
|
return api.exportWorkspaceToDrive(id, targetFolderId);
|
|
531
600
|
},
|
|
532
601
|
|
|
602
|
+
exportTopic: async (workspaceId, topicId, targetFolderId) => {
|
|
603
|
+
return api.exportTopicToDrive(workspaceId, topicId, targetFolderId);
|
|
604
|
+
},
|
|
605
|
+
|
|
533
606
|
fetchDriveSubfolders: async (id) => {
|
|
534
607
|
return api.fetchDriveSubfolders(id);
|
|
535
608
|
},
|
|
@@ -629,6 +702,39 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
629
702
|
}));
|
|
630
703
|
},
|
|
631
704
|
|
|
705
|
+
copyQuestion: async (
|
|
706
|
+
questionId,
|
|
707
|
+
topicId,
|
|
708
|
+
parentQuestionId,
|
|
709
|
+
targetTopicId,
|
|
710
|
+
) => {
|
|
711
|
+
const destinationTopicId = targetTopicId ?? topicId;
|
|
712
|
+
const copied = await api.copyQuestion(questionId, {
|
|
713
|
+
parentQuestionId,
|
|
714
|
+
targetTopicId: destinationTopicId,
|
|
715
|
+
});
|
|
716
|
+
const copiedRoot = copied[0];
|
|
717
|
+
set((s) => ({
|
|
718
|
+
questionsByTopic: {
|
|
719
|
+
...s.questionsByTopic,
|
|
720
|
+
[destinationTopicId]: [
|
|
721
|
+
...(s.questionsByTopic[destinationTopicId] || []),
|
|
722
|
+
...copied,
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
selectedTopicId: copiedRoot?.id ? destinationTopicId : s.selectedTopicId,
|
|
726
|
+
selectedQuestionId: copiedRoot?.id ?? s.selectedQuestionId,
|
|
727
|
+
currentQuestion: copiedRoot ?? s.currentQuestion,
|
|
728
|
+
expandedTopics: s.expandedTopics.includes(destinationTopicId)
|
|
729
|
+
? s.expandedTopics
|
|
730
|
+
: [...s.expandedTopics, destinationTopicId],
|
|
731
|
+
}));
|
|
732
|
+
if (copiedRoot) {
|
|
733
|
+
sessionStorage.setItem("lastTopicId", destinationTopicId);
|
|
734
|
+
sessionStorage.setItem("lastQuestionId", copiedRoot.id);
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
|
|
632
738
|
removeQuestion: async (questionId, topicId) => {
|
|
633
739
|
await api.deleteQuestion(questionId);
|
|
634
740
|
set((s) => ({
|
|
@@ -1079,6 +1185,20 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
1079
1185
|
openBrowserSecurityLab: () => set({ showBrowserSecurityLab: true }),
|
|
1080
1186
|
closeBrowserSecurityLab: () => set({ showBrowserSecurityLab: false }),
|
|
1081
1187
|
closeInfraLab: () => set({ showInfraLab: false }),
|
|
1188
|
+
openGhaLab: (workspace, fileId?) =>
|
|
1189
|
+
set({
|
|
1190
|
+
showGhaLab: true,
|
|
1191
|
+
showInfraLab: false,
|
|
1192
|
+
showCodeRunner: false,
|
|
1193
|
+
runnerInitialGha: workspace ?? null,
|
|
1194
|
+
runnerInitialGhaFileId: fileId ?? null,
|
|
1195
|
+
}),
|
|
1196
|
+
closeGhaLab: () =>
|
|
1197
|
+
set({
|
|
1198
|
+
showGhaLab: false,
|
|
1199
|
+
runnerInitialGha: null,
|
|
1200
|
+
runnerInitialGhaFileId: null,
|
|
1201
|
+
}),
|
|
1082
1202
|
openCanvasLab: (code?, fileId?) =>
|
|
1083
1203
|
set({
|
|
1084
1204
|
showCanvasLab: true,
|
|
@@ -8,7 +8,8 @@ export type ContextFileOrigin =
|
|
|
8
8
|
| "react"
|
|
9
9
|
| "nextjs"
|
|
10
10
|
| "module-federation"
|
|
11
|
-
| "canvas"
|
|
11
|
+
| "canvas"
|
|
12
|
+
| "github-actions";
|
|
12
13
|
|
|
13
14
|
export interface ContextFile {
|
|
14
15
|
id: string;
|
|
@@ -42,12 +43,29 @@ export interface FrontendLabWorkspace {
|
|
|
42
43
|
export interface InfraLabWorkspace {
|
|
43
44
|
version: 1;
|
|
44
45
|
label: string;
|
|
45
|
-
provider: "aws";
|
|
46
|
-
executionMode: "plan-only" | "localstack";
|
|
46
|
+
provider: "aws" | "docker";
|
|
47
|
+
executionMode: "plan-only" | "localstack" | "docker";
|
|
47
48
|
activeFile: string;
|
|
48
49
|
files: Record<string, string>;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
export interface GithubActionsLabWorkspace {
|
|
53
|
+
version: 1;
|
|
54
|
+
label: string;
|
|
55
|
+
activeFile: string;
|
|
56
|
+
files: Record<string, string>;
|
|
57
|
+
/** Optional default event the run button uses (push, pull_request, workflow_dispatch). */
|
|
58
|
+
defaultEvent?: string;
|
|
59
|
+
/** Optional default workflow file path under .github/workflows. */
|
|
60
|
+
defaultWorkflow?: string;
|
|
61
|
+
/**
|
|
62
|
+
* When true, the most recent act runs for this lab are embedded into the
|
|
63
|
+
* saved snapshot so the chat LLM can reason about real execution results
|
|
64
|
+
* (job statuses, durations, exit codes) instead of just the YAML.
|
|
65
|
+
*/
|
|
66
|
+
includeRunHistoryInContext?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
export interface WorkspaceMeta {
|
|
52
70
|
id: string;
|
|
53
71
|
name: string;
|
|
@@ -116,6 +134,16 @@ export interface ReadingBookmark {
|
|
|
116
134
|
blockIndex: number;
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
export interface StoredCodeAnnotation {
|
|
138
|
+
id: string;
|
|
139
|
+
lineNumber: number;
|
|
140
|
+
lineContent: string;
|
|
141
|
+
prompt: string;
|
|
142
|
+
response: string;
|
|
143
|
+
filePath: string;
|
|
144
|
+
createdAt: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
export interface Question {
|
|
120
148
|
id: string;
|
|
121
149
|
topicId: string;
|
|
@@ -127,6 +155,8 @@ export interface Question {
|
|
|
127
155
|
messages: Message[];
|
|
128
156
|
annotations?: Annotation[];
|
|
129
157
|
readingBookmark?: ReadingBookmark;
|
|
158
|
+
/** Code-line annotations keyed by file path. */
|
|
159
|
+
codeAnnotations?: { [filePath: string]: StoredCodeAnnotation[] };
|
|
130
160
|
/** IDs of other questions in the same topic whose conversation history is injected as context. */
|
|
131
161
|
linkedConversationIds?: string[];
|
|
132
162
|
createdAt: string;
|