create-interview-cockpit 0.5.0 → 0.6.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/package-lock.json +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +321 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +419 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +22 -8
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +219 -6
- package/template/client/src/types.ts +35 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/server/src/google-drive.ts +37 -3
- package/template/server/src/index.ts +693 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +13 -3
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
Send,
|
|
12
12
|
MessageSquare,
|
|
13
13
|
Undo2,
|
|
14
|
+
Copy,
|
|
15
|
+
Check,
|
|
16
|
+
Download,
|
|
14
17
|
} from "lucide-react";
|
|
15
18
|
import { parse as parseYaml } from "yaml";
|
|
16
19
|
import {
|
|
@@ -76,6 +79,21 @@ const VIZ_DARK_THEME_CSS = `
|
|
|
76
79
|
.viz-embed-host .viz-anim-flow .viz-edge {
|
|
77
80
|
stroke: #0ea5e9;
|
|
78
81
|
}
|
|
82
|
+
|
|
83
|
+
/* Tooltip dark-theme overrides */
|
|
84
|
+
.viz-tooltip {
|
|
85
|
+
background: #1e293b !important;
|
|
86
|
+
border: 1px solid #334155 !important;
|
|
87
|
+
color: #e2e8f0 !important;
|
|
88
|
+
font-size: 12px !important;
|
|
89
|
+
font-family: ui-sans-serif, system-ui, sans-serif !important;
|
|
90
|
+
border-radius: 6px !important;
|
|
91
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important;
|
|
92
|
+
padding: 6px 10px !important;
|
|
93
|
+
max-width: 280px !important;
|
|
94
|
+
white-space: pre-wrap !important;
|
|
95
|
+
word-break: break-word !important;
|
|
96
|
+
}
|
|
79
97
|
`;
|
|
80
98
|
|
|
81
99
|
// Inject VizCraft default CSS + dark overrides once into <head>
|
|
@@ -90,19 +108,75 @@ function ensureVizCss() {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
/**
|
|
93
|
-
* Replace typographic characters that the YAML parser mishandles
|
|
94
|
-
* - en-dash (U+2013) and em-dash (U+2014) → plain hyphen
|
|
95
|
-
* - left/right curly double-quotes → straight double-quote
|
|
96
|
-
* - left/right curly single-quotes / apostrophes → straight single-quote
|
|
111
|
+
* Replace typographic characters and structural issues that the YAML parser mishandles.
|
|
97
112
|
*
|
|
98
|
-
*
|
|
113
|
+
* Fixes applied (in order):
|
|
114
|
+
* 1. Typographic characters → ASCII equivalents
|
|
115
|
+
* 2. Single-quoted YAML scalar values → double-quoted
|
|
116
|
+
* yaml v2 (YAML 1.2) is strict: `fill: '#fff'` inside flow maps can fail.
|
|
117
|
+
* Double quotes are always safe.
|
|
118
|
+
* 3. Inline flow maps `{ ... }` that the LLM split across multiple lines are
|
|
119
|
+
* rejoined onto a single line. yaml v2 requires a flow-map opening `{` and
|
|
120
|
+
* its matching `}` to reside on the same line when inside a block sequence.
|
|
99
121
|
*/
|
|
100
122
|
function sanitizeSpecText(raw: string): string {
|
|
101
|
-
|
|
123
|
+
let s = raw
|
|
102
124
|
.replace(/\u2013/g, "-") // en-dash –
|
|
103
125
|
.replace(/\u2014/g, "-") // em-dash —
|
|
104
126
|
.replace(/[\u201C\u201D]/g, '"') // curly double quotes " "
|
|
105
127
|
.replace(/[\u2018\u2019\u02BC]/g, "'"); // curly single quotes ' '
|
|
128
|
+
|
|
129
|
+
// Convert single-quoted YAML scalar values to double-quoted.
|
|
130
|
+
// e.g. fill: '#1e40af' → fill: "#1e40af"
|
|
131
|
+
s = s.replace(/(:\s*)'([^']*)'/g, (_, colon, val) => `${colon}"${val}"`);
|
|
132
|
+
|
|
133
|
+
// Rejoin flow-map items broken across lines.
|
|
134
|
+
// A YAML sequence item that opens with `{` but doesn't close with `}` on the
|
|
135
|
+
// same line will cause yaml v2 to throw "Flow map … must end with a }".
|
|
136
|
+
const lines = s.split("\n");
|
|
137
|
+
const joined: string[] = [];
|
|
138
|
+
let pending = "";
|
|
139
|
+
let depth = 0;
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
if (depth === 0) {
|
|
142
|
+
let d = 0;
|
|
143
|
+
for (const ch of line) {
|
|
144
|
+
if (ch === "{" || ch === "[") d++;
|
|
145
|
+
else if (ch === "}" || ch === "]") d--;
|
|
146
|
+
}
|
|
147
|
+
if (d > 0) {
|
|
148
|
+
pending = line;
|
|
149
|
+
depth = d;
|
|
150
|
+
} else {
|
|
151
|
+
joined.push(line);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const trimmed = line.trimStart();
|
|
155
|
+
// Insert a comma separator when joining flow-map / flow-sequence items
|
|
156
|
+
// that the LLM split across lines without a trailing comma.
|
|
157
|
+
// e.g. `{ width: 960` + `height: 560 }` → `{ width: 960, height: 560 }`
|
|
158
|
+
const tail = pending.trimEnd().slice(-1);
|
|
159
|
+
const needsComma =
|
|
160
|
+
tail !== "," &&
|
|
161
|
+
tail !== "{" &&
|
|
162
|
+
tail !== "[" &&
|
|
163
|
+
trimmed[0] !== "}" &&
|
|
164
|
+
trimmed[0] !== "]";
|
|
165
|
+
pending += (needsComma ? ", " : " ") + trimmed;
|
|
166
|
+
for (const ch of line) {
|
|
167
|
+
if (ch === "{" || ch === "[") depth++;
|
|
168
|
+
else if (ch === "}" || ch === "]") depth--;
|
|
169
|
+
}
|
|
170
|
+
if (depth <= 0) {
|
|
171
|
+
joined.push(pending);
|
|
172
|
+
pending = "";
|
|
173
|
+
depth = 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (pending) joined.push(pending);
|
|
178
|
+
|
|
179
|
+
return joined.join("\n");
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
function parseSpec(raw: string): VizSpec {
|
|
@@ -117,6 +191,8 @@ function parseSpec(raw: string): VizSpec {
|
|
|
117
191
|
/**
|
|
118
192
|
* Walk each VizNode in the (already-built) scene and inject a sensible
|
|
119
193
|
* maxWidth + overflow on any label that doesn't have one yet.
|
|
194
|
+
* Also injects a tooltip with the full label text so truncated nodes
|
|
195
|
+
* reveal their complete contents on hover.
|
|
120
196
|
*
|
|
121
197
|
* We read the shape from the built VizNode (which uses the internal NodeShape
|
|
122
198
|
* discriminated union, e.g. {kind:'rect', w:120, h:40}), not from VizSpec.
|
|
@@ -124,12 +200,17 @@ function parseSpec(raw: string): VizSpec {
|
|
|
124
200
|
function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
|
|
125
201
|
const scene = builder.build();
|
|
126
202
|
for (const node of scene.nodes) {
|
|
127
|
-
if (
|
|
128
|
-
|
|
203
|
+
if (!node.label) continue;
|
|
204
|
+
const alreadyHasMaxWidth =
|
|
129
205
|
(node.label as VizNode["label"] & { maxWidth?: number })?.maxWidth !==
|
|
130
|
-
|
|
131
|
-
)
|
|
206
|
+
undefined;
|
|
207
|
+
if (alreadyHasMaxWidth) {
|
|
208
|
+
// Still inject tooltip if there isn't one
|
|
209
|
+
if (!node.tooltip) {
|
|
210
|
+
builder.updateNode(node.id, { tooltip: node.label.text });
|
|
211
|
+
}
|
|
132
212
|
continue;
|
|
213
|
+
}
|
|
133
214
|
const shape = node.shape as Record<string, unknown>;
|
|
134
215
|
let w = 60;
|
|
135
216
|
if (typeof shape.w === "number") w = shape.w;
|
|
@@ -145,6 +226,8 @@ function injectLabelMaxWidth(builder: ReturnType<typeof fromSpec>): void {
|
|
|
145
226
|
(node.label as { overflow?: "visible" | "ellipsis" | "clip" })
|
|
146
227
|
.overflow ?? "ellipsis",
|
|
147
228
|
},
|
|
229
|
+
// Show full label on hover so truncated "..." nodes are readable
|
|
230
|
+
tooltip: node.tooltip ?? node.label.text,
|
|
148
231
|
});
|
|
149
232
|
}
|
|
150
233
|
}
|
|
@@ -176,6 +259,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
176
259
|
const [refineHistory, setRefineHistory] = useState<
|
|
177
260
|
Array<{ prompt: string; spec: string }>
|
|
178
261
|
>([]);
|
|
262
|
+
const [copiedSpec, setCopiedSpec] = useState(false);
|
|
179
263
|
|
|
180
264
|
// Keep activeSpec in sync when the prop changes (streaming / message reload)
|
|
181
265
|
useEffect(() => {
|
|
@@ -193,7 +277,19 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
193
277
|
});
|
|
194
278
|
if (!res.ok) throw new Error("Fix request failed");
|
|
195
279
|
const { spec: fixed } = (await res.json()) as { spec: string };
|
|
280
|
+
// Guard against truncated responses — a complete spec always ends with a
|
|
281
|
+
// complete value (not mid-token). Check the last non-whitespace char.
|
|
282
|
+
const lastChar = fixed.trimEnd().slice(-1);
|
|
283
|
+
const looksComplete = lastChar === "}" || /[\w"']/.test(lastChar);
|
|
284
|
+
if (!looksComplete) {
|
|
285
|
+
setError(
|
|
286
|
+
"Fix response was truncated before completing. Try again — the model may need another attempt for this large spec.",
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
196
290
|
setActiveSpec(fixed);
|
|
291
|
+
// Persist the fix so it survives reload (mirrors handleRefine behaviour)
|
|
292
|
+
onSpecRefined?.(spec, fixed);
|
|
197
293
|
} catch (err) {
|
|
198
294
|
console.error("Fix viz error:", err);
|
|
199
295
|
} finally {
|
|
@@ -236,6 +332,130 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
236
332
|
setRefineHistory((h) => h.slice(0, -1));
|
|
237
333
|
};
|
|
238
334
|
|
|
335
|
+
const handleCopySpec = async () => {
|
|
336
|
+
try {
|
|
337
|
+
await navigator.clipboard.writeText(activeSpec);
|
|
338
|
+
setCopiedSpec(true);
|
|
339
|
+
setTimeout(() => setCopiedSpec(false), 1800);
|
|
340
|
+
} catch {
|
|
341
|
+
// fallback: select a textarea
|
|
342
|
+
const ta = document.createElement("textarea");
|
|
343
|
+
ta.value = activeSpec;
|
|
344
|
+
ta.style.position = "fixed";
|
|
345
|
+
ta.style.opacity = "0";
|
|
346
|
+
document.body.appendChild(ta);
|
|
347
|
+
ta.select();
|
|
348
|
+
document.execCommand("copy");
|
|
349
|
+
document.body.removeChild(ta);
|
|
350
|
+
setCopiedSpec(true);
|
|
351
|
+
setTimeout(() => setCopiedSpec(false), 1800);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const handleDownloadPng = () => {
|
|
356
|
+
const container = containerRef.current;
|
|
357
|
+
if (!container) return;
|
|
358
|
+
const svgEl = container.querySelector("svg");
|
|
359
|
+
if (!svgEl) return;
|
|
360
|
+
|
|
361
|
+
// ── 1. Clone, then inline every element's computed styles ────────────────
|
|
362
|
+
// CSS class selectors (e.g. .viz-embed-host .viz-node-shape) don't resolve
|
|
363
|
+
// once the SVG is detached from the page, so we must bake in computed values.
|
|
364
|
+
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
|
365
|
+
const origEls = Array.from(svgEl.querySelectorAll("*"));
|
|
366
|
+
const cloneEls = Array.from(clone.querySelectorAll("*")) as SVGElement[];
|
|
367
|
+
const INLINE_PROPS = [
|
|
368
|
+
"fill",
|
|
369
|
+
"stroke",
|
|
370
|
+
"stroke-width",
|
|
371
|
+
"stroke-dasharray",
|
|
372
|
+
"stroke-dashoffset",
|
|
373
|
+
"stroke-opacity",
|
|
374
|
+
"fill-opacity",
|
|
375
|
+
"opacity",
|
|
376
|
+
"font-size",
|
|
377
|
+
"font-family",
|
|
378
|
+
"font-weight",
|
|
379
|
+
"text-anchor",
|
|
380
|
+
"dominant-baseline",
|
|
381
|
+
"alignment-baseline",
|
|
382
|
+
"stroke-linecap",
|
|
383
|
+
"stroke-linejoin",
|
|
384
|
+
];
|
|
385
|
+
origEls.forEach((orig, i) => {
|
|
386
|
+
if (i >= cloneEls.length) return;
|
|
387
|
+
const cs = window.getComputedStyle(orig);
|
|
388
|
+
INLINE_PROPS.forEach((p) => {
|
|
389
|
+
const v = cs.getPropertyValue(p);
|
|
390
|
+
if (v) cloneEls[i].style.setProperty(p, v);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ── 2. Compute tight content bounds via getBBox on the panzoom group ─────
|
|
395
|
+
// The panzoom group's getBBox returns bounds in its own local coordinate
|
|
396
|
+
// space (before the translate/scale transform). By removing that transform
|
|
397
|
+
// and setting viewBox to those bounds we get a perfectly framed export.
|
|
398
|
+
const panGroup = Array.from(svgEl.children).find(
|
|
399
|
+
(el) => el.tagName.toLowerCase() === "g",
|
|
400
|
+
) as SVGGraphicsElement | undefined;
|
|
401
|
+
const pad = 24;
|
|
402
|
+
let vx = -pad,
|
|
403
|
+
vy = -pad,
|
|
404
|
+
vw = 800,
|
|
405
|
+
vh = 500;
|
|
406
|
+
if (panGroup) {
|
|
407
|
+
try {
|
|
408
|
+
const bb = panGroup.getBBox();
|
|
409
|
+
if (bb.width > 0 && bb.height > 0) {
|
|
410
|
+
vx = bb.x - pad;
|
|
411
|
+
vy = bb.y - pad;
|
|
412
|
+
vw = bb.width + pad * 2;
|
|
413
|
+
vh = bb.height + pad * 2;
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
/* getBBox unavailable — fall back to defaults */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Remove the live pan/zoom transform from the clone — viewBox does the framing
|
|
421
|
+
const clonePanGroup = Array.from(clone.children).find(
|
|
422
|
+
(el) => el.tagName.toLowerCase() === "g",
|
|
423
|
+
);
|
|
424
|
+
if (clonePanGroup) clonePanGroup.removeAttribute("transform");
|
|
425
|
+
|
|
426
|
+
// ── 3. Set output dimensions — target ~1600 px wide, max 4× scale ────────
|
|
427
|
+
const TARGET_W = 1600;
|
|
428
|
+
const renderScale = Math.max(1, Math.min(4, TARGET_W / vw));
|
|
429
|
+
const outW = Math.round(vw * renderScale);
|
|
430
|
+
const outH = Math.round(vh * renderScale);
|
|
431
|
+
clone.setAttribute("viewBox", `${vx} ${vy} ${vw} ${vh}`);
|
|
432
|
+
clone.setAttribute("width", String(outW));
|
|
433
|
+
clone.setAttribute("height", String(outH));
|
|
434
|
+
|
|
435
|
+
// ── 4. Serialize → canvas → PNG download ─────────────────────────────────
|
|
436
|
+
const svgStr = new XMLSerializer().serializeToString(clone);
|
|
437
|
+
const blob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" });
|
|
438
|
+
const url = URL.createObjectURL(blob);
|
|
439
|
+
|
|
440
|
+
const img = new Image();
|
|
441
|
+
img.onload = () => {
|
|
442
|
+
const canvas = document.createElement("canvas");
|
|
443
|
+
canvas.width = outW;
|
|
444
|
+
canvas.height = outH;
|
|
445
|
+
const ctx = canvas.getContext("2d")!;
|
|
446
|
+
ctx.fillStyle = "#0f172a";
|
|
447
|
+
ctx.fillRect(0, 0, outW, outH);
|
|
448
|
+
ctx.drawImage(img, 0, 0);
|
|
449
|
+
URL.revokeObjectURL(url);
|
|
450
|
+
const a = document.createElement("a");
|
|
451
|
+
a.href = canvas.toDataURL("image/png");
|
|
452
|
+
a.download = "diagram.png";
|
|
453
|
+
a.click();
|
|
454
|
+
};
|
|
455
|
+
img.onerror = () => URL.revokeObjectURL(url);
|
|
456
|
+
img.src = url;
|
|
457
|
+
};
|
|
458
|
+
|
|
239
459
|
// Prevent the parent chat scroll container from scrolling when the user
|
|
240
460
|
// wheels over the viz. We need a non-passive listener so preventDefault works.
|
|
241
461
|
// (React's synthetic onWheel is passive in some environments and can't prevent scroll.)
|
|
@@ -460,7 +680,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
460
680
|
<div className="my-3 rounded-lg overflow-hidden border border-slate-700/50 bg-slate-900/60">
|
|
461
681
|
{/* Toolbar — zoom controls (always shown unless there's an error) */}
|
|
462
682
|
{!error && (
|
|
463
|
-
<div className="flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
|
|
683
|
+
<div className="relative z-10 flex items-center justify-end gap-1 px-2 pt-1.5 pb-0">
|
|
464
684
|
{/* Zoom out */}
|
|
465
685
|
<button
|
|
466
686
|
onClick={handleZoomOut}
|
|
@@ -488,6 +708,30 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
488
708
|
<Maximize2 className="w-3 h-3" />
|
|
489
709
|
Fit
|
|
490
710
|
</button>
|
|
711
|
+
{/* Divider */}
|
|
712
|
+
<span className="w-px h-3 bg-slate-700 mx-0.5" />
|
|
713
|
+
{/* Copy spec */}
|
|
714
|
+
<button
|
|
715
|
+
onClick={handleCopySpec}
|
|
716
|
+
title="Copy diagram spec"
|
|
717
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
718
|
+
>
|
|
719
|
+
{copiedSpec ? (
|
|
720
|
+
<Check className="w-3 h-3 text-green-400" />
|
|
721
|
+
) : (
|
|
722
|
+
<Copy className="w-3 h-3" />
|
|
723
|
+
)}
|
|
724
|
+
{copiedSpec ? "Copied!" : "Copy"}
|
|
725
|
+
</button>
|
|
726
|
+
{/* Download PNG */}
|
|
727
|
+
<button
|
|
728
|
+
onClick={handleDownloadPng}
|
|
729
|
+
title="Download as PNG"
|
|
730
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-800 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
|
731
|
+
>
|
|
732
|
+
<Download className="w-3 h-3" />
|
|
733
|
+
PNG
|
|
734
|
+
</button>
|
|
491
735
|
</div>
|
|
492
736
|
)}
|
|
493
737
|
|
|
@@ -532,7 +776,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
532
776
|
|
|
533
777
|
{/* Step controls — only rendered when spec has steps */}
|
|
534
778
|
{stepState && !error && (
|
|
535
|
-
<div className="flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
|
|
779
|
+
<div className="relative z-10 flex items-center justify-between px-3 py-2 border-t border-slate-700/50 bg-slate-800/60 gap-3">
|
|
536
780
|
{/* Prev */}
|
|
537
781
|
<button
|
|
538
782
|
onClick={handlePrev}
|
|
@@ -589,7 +833,7 @@ export default memo(function VizCraftEmbed({ spec, onSpecRefined }: Props) {
|
|
|
589
833
|
)}
|
|
590
834
|
|
|
591
835
|
{/* Refine panel */}
|
|
592
|
-
<div className="border-t border-slate-700/50">
|
|
836
|
+
<div className="relative z-10 border-t border-slate-700/50">
|
|
593
837
|
{/* History chips */}
|
|
594
838
|
{refineHistory.length > 0 && (
|
|
595
839
|
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-2">
|
|
@@ -369,6 +369,10 @@ export default function WorkspaceSwitcher() {
|
|
|
369
369
|
folderPicker.ws.id,
|
|
370
370
|
name,
|
|
371
371
|
);
|
|
372
|
+
if ("needsAuth" in created && created.needsAuth) {
|
|
373
|
+
window.location.href = created.authUrl;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
372
376
|
setFolderPicker((p) =>
|
|
373
377
|
p ? { ...p, folders: [...p.folders, created] } : p,
|
|
374
378
|
);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { InfraLabWorkspace } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INFRA_FILES: Record<string, string> = {
|
|
4
|
+
"provider.tf": `terraform {
|
|
5
|
+
required_providers {
|
|
6
|
+
aws = {
|
|
7
|
+
source = "hashicorp/aws"
|
|
8
|
+
version = "~> 5.0"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
provider "aws" {
|
|
14
|
+
region = "us-east-1"
|
|
15
|
+
access_key = "test"
|
|
16
|
+
secret_key = "test"
|
|
17
|
+
skip_credentials_validation = true
|
|
18
|
+
skip_metadata_api_check = true
|
|
19
|
+
skip_requesting_account_id = true
|
|
20
|
+
s3_use_path_style = true
|
|
21
|
+
|
|
22
|
+
endpoints {
|
|
23
|
+
s3 = "http://localhost:4566"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
"main.tf": `resource "aws_s3_bucket" "example" {
|
|
28
|
+
bucket = "practice-bucket"
|
|
29
|
+
}
|
|
30
|
+
`,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_INFRA_LAB: InfraLabWorkspace = {
|
|
34
|
+
version: 1,
|
|
35
|
+
label: "AWS LocalStack Lab",
|
|
36
|
+
provider: "aws",
|
|
37
|
+
executionMode: "localstack",
|
|
38
|
+
activeFile: "main.tf",
|
|
39
|
+
files: DEFAULT_INFRA_FILES,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function cloneInfraLabWorkspace(
|
|
43
|
+
workspace?: InfraLabWorkspace | null,
|
|
44
|
+
): InfraLabWorkspace {
|
|
45
|
+
const source = workspace ?? DEFAULT_INFRA_LAB;
|
|
46
|
+
// Only seed defaults when the source has no files of its own
|
|
47
|
+
const files =
|
|
48
|
+
source.files && Object.keys(source.files).length > 0
|
|
49
|
+
? { ...source.files }
|
|
50
|
+
: { ...DEFAULT_INFRA_FILES };
|
|
51
|
+
const activeFile = files[source.activeFile]
|
|
52
|
+
? source.activeFile
|
|
53
|
+
: (Object.keys(files)[0] ?? "main.tf");
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
label: source.label || DEFAULT_INFRA_LAB.label,
|
|
58
|
+
provider: "aws",
|
|
59
|
+
executionMode:
|
|
60
|
+
source.executionMode === "plan-only" ? "plan-only" : "localstack",
|
|
61
|
+
activeFile,
|
|
62
|
+
files,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getInfraLabFileOrder(workspace: InfraLabWorkspace): string[] {
|
|
67
|
+
const preferred = [
|
|
68
|
+
"main.tf",
|
|
69
|
+
"provider.tf",
|
|
70
|
+
"variables.tf",
|
|
71
|
+
"terraform.tfvars",
|
|
72
|
+
"outputs.tf",
|
|
73
|
+
"locals.tf",
|
|
74
|
+
"README.md",
|
|
75
|
+
];
|
|
76
|
+
const extras = Object.keys(workspace.files)
|
|
77
|
+
.filter((name) => !preferred.includes(name))
|
|
78
|
+
.sort();
|
|
79
|
+
|
|
80
|
+
return preferred.filter((name) => workspace.files[name]).concat(extras);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function serializeInfraLabWorkspace(
|
|
84
|
+
workspace: InfraLabWorkspace,
|
|
85
|
+
): string {
|
|
86
|
+
return JSON.stringify(cloneInfraLabWorkspace(workspace), null, 2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseInfraLabWorkspace(raw: string): InfraLabWorkspace | null {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(raw) as Partial<InfraLabWorkspace> & {
|
|
92
|
+
files?: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
96
|
+
if (!parsed.files || typeof parsed.files !== "object") return null;
|
|
97
|
+
|
|
98
|
+
const files = Object.fromEntries(
|
|
99
|
+
Object.entries(parsed.files).filter(
|
|
100
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (Object.keys(files).length === 0) return null;
|
|
105
|
+
|
|
106
|
+
return cloneInfraLabWorkspace({
|
|
107
|
+
version: 1,
|
|
108
|
+
label:
|
|
109
|
+
typeof parsed.label === "string" && parsed.label.trim()
|
|
110
|
+
? parsed.label.trim()
|
|
111
|
+
: DEFAULT_INFRA_LAB.label,
|
|
112
|
+
provider: "aws",
|
|
113
|
+
executionMode:
|
|
114
|
+
parsed.executionMode === "plan-only" ? "plan-only" : "localstack",
|
|
115
|
+
activeFile:
|
|
116
|
+
typeof parsed.activeFile === "string"
|
|
117
|
+
? parsed.activeFile
|
|
118
|
+
: DEFAULT_INFRA_LAB.activeFile,
|
|
119
|
+
files,
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|