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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +321 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -3
@@ -0,0 +1,1104 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { spawn } from "child_process";
5
+ import * as storage from "./storage.js";
6
+
7
+ type InfraExecutionMode = "plan-only" | "localstack";
8
+ export type InfraRunAction = "validate" | "plan" | "command";
9
+ type InfraRunStatus = "completed" | "failed";
10
+ type OutputKind = "stdout" | "stderr" | "info";
11
+
12
+ interface InfraLabWorkspace {
13
+ version: 1;
14
+ label: string;
15
+ provider: "aws";
16
+ executionMode: InfraExecutionMode;
17
+ activeFile: string;
18
+ files: Record<string, string>;
19
+ }
20
+
21
+ export interface InfraRunArtifact {
22
+ key: string;
23
+ label: string;
24
+ kind: "text" | "json";
25
+ fileName: string;
26
+ }
27
+
28
+ export interface InfraDiagnostic {
29
+ severity: string;
30
+ summary: string;
31
+ detail?: string;
32
+ address?: string;
33
+ filename?: string;
34
+ line?: number;
35
+ }
36
+
37
+ export interface InfraPlanSummary {
38
+ add: number;
39
+ change: number;
40
+ destroy: number;
41
+ replace: number;
42
+ resourceCount: number;
43
+ outputCount: number;
44
+ }
45
+
46
+ export interface InfraRunListItem {
47
+ id: string;
48
+ fileId?: string;
49
+ questionId?: string;
50
+ label: string;
51
+ action: InfraRunAction;
52
+ command?: string;
53
+ status: InfraRunStatus;
54
+ startedAt: string;
55
+ completedAt: string;
56
+ durationMs: number;
57
+ provider: "aws";
58
+ executionMode: InfraExecutionMode;
59
+ diagnostics: InfraDiagnostic[];
60
+ planSummary?: InfraPlanSummary;
61
+ error?: string;
62
+ artifacts: InfraRunArtifact[];
63
+ }
64
+
65
+ export interface InfraRunDetails extends InfraRunListItem {
66
+ logs: string;
67
+ validationJson?: unknown;
68
+ planJson?: unknown;
69
+ workspaceSnapshot?: InfraLabWorkspace;
70
+ }
71
+
72
+ export type InfraStreamMessage =
73
+ | { type: "output"; kind: OutputKind; text: string }
74
+ | { type: "complete"; run: InfraRunDetails }
75
+ | { type: "error"; error: string };
76
+
77
+ interface RunCommandResult {
78
+ exitCode: number;
79
+ stdout: string;
80
+ stderr: string;
81
+ combined: string;
82
+ }
83
+
84
+ interface ParsedCommand {
85
+ args: string[];
86
+ subcommand: string;
87
+ action: InfraRunAction;
88
+ displayCommand: string;
89
+ planOutputFile?: string;
90
+ }
91
+
92
+ const MAX_FILE_COUNT = 24;
93
+ const MAX_TOTAL_SOURCE_BYTES = 500_000;
94
+ const MAX_LOG_CHARS = 200_000;
95
+ const SOURCE_MANIFEST = ".infra-source-files.json";
96
+ const ALLOWED_SUBCOMMANDS = new Set([
97
+ "fmt",
98
+ "init",
99
+ "validate",
100
+ "plan",
101
+ "show",
102
+ "apply",
103
+ "destroy",
104
+ "output",
105
+ "state",
106
+ "version",
107
+ "providers",
108
+ ]);
109
+
110
+ function getInfraRunsDir(): string {
111
+ return path.resolve(storage.getContextFilesDir(), "..", "infra-runs");
112
+ }
113
+
114
+ function getInfraSessionsDir(): string {
115
+ return path.resolve(storage.getContextFilesDir(), "..", "infra-sessions");
116
+ }
117
+
118
+ function stripAnsi(text: string): string {
119
+ return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
120
+ }
121
+
122
+ function appendLog(buffer: string, chunk: string): string {
123
+ if (!chunk || buffer.length >= MAX_LOG_CHARS) return buffer;
124
+ const remaining = MAX_LOG_CHARS - buffer.length;
125
+ if (chunk.length <= remaining) return buffer + chunk;
126
+ return `${buffer}${chunk.slice(0, remaining)}\n[log truncated]\n`;
127
+ }
128
+
129
+ function sanitizeKey(value: string): string {
130
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 120) || "session";
131
+ }
132
+
133
+ function assertSafeRelativePath(filePath: string, label: string): void {
134
+ if (!filePath || path.isAbsolute(filePath) || filePath.includes("..")) {
135
+ throw new Error(`${label} must stay within the infra workspace`);
136
+ }
137
+ }
138
+
139
+ function parseWorkspace(input: unknown): InfraLabWorkspace {
140
+ if (!input || typeof input !== "object") {
141
+ throw new Error("workspace payload is required");
142
+ }
143
+
144
+ const candidate = input as Partial<InfraLabWorkspace> & {
145
+ files?: Record<string, unknown>;
146
+ };
147
+
148
+ if (!candidate.files || typeof candidate.files !== "object") {
149
+ throw new Error("workspace.files must be provided");
150
+ }
151
+
152
+ const files = Object.fromEntries(
153
+ Object.entries(candidate.files)
154
+ .filter(
155
+ (entry): entry is [string, string] =>
156
+ typeof entry[0] === "string" && typeof entry[1] === "string",
157
+ )
158
+ .map(([name, content]) => [name.trim(), content]),
159
+ );
160
+
161
+ const fileNames = Object.keys(files).filter(Boolean);
162
+ if (fileNames.length === 0) {
163
+ throw new Error("workspace must contain at least one file");
164
+ }
165
+ if (fileNames.length > MAX_FILE_COUNT) {
166
+ throw new Error(`workspace exceeds the ${MAX_FILE_COUNT} file limit`);
167
+ }
168
+
169
+ let totalBytes = 0;
170
+ for (const name of fileNames) {
171
+ assertSafeRelativePath(name, "Workspace file path");
172
+ totalBytes += Buffer.byteLength(files[name], "utf8");
173
+ }
174
+
175
+ if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
176
+ throw new Error("workspace source exceeds the allowed size limit");
177
+ }
178
+
179
+ return {
180
+ version: 1,
181
+ label:
182
+ typeof candidate.label === "string" && candidate.label.trim()
183
+ ? candidate.label.trim()
184
+ : "Infrastructure Lab",
185
+ provider: "aws",
186
+ executionMode:
187
+ candidate.executionMode === "plan-only" ? "plan-only" : "localstack",
188
+ activeFile:
189
+ typeof candidate.activeFile === "string" && files[candidate.activeFile]
190
+ ? candidate.activeFile
191
+ : fileNames[0],
192
+ files,
193
+ };
194
+ }
195
+
196
+ function getDefaultEnv(mode: InfraExecutionMode): NodeJS.ProcessEnv {
197
+ const base: NodeJS.ProcessEnv = {
198
+ ...process.env,
199
+ TF_IN_AUTOMATION: "1",
200
+ };
201
+
202
+ if (mode === "localstack") {
203
+ return {
204
+ ...base,
205
+ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID || "test",
206
+ AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY || "test",
207
+ AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "us-east-1",
208
+ AWS_REGION: process.env.AWS_REGION || "us-east-1",
209
+ };
210
+ }
211
+
212
+ return base;
213
+ }
214
+
215
+ function splitCommand(command: string): string[] {
216
+ const tokens: string[] = [];
217
+ let current = "";
218
+ let quote: '"' | "'" | null = null;
219
+ let escape = false;
220
+
221
+ for (const char of command.trim()) {
222
+ if (escape) {
223
+ current += char;
224
+ escape = false;
225
+ continue;
226
+ }
227
+
228
+ if (char === "\\") {
229
+ escape = true;
230
+ continue;
231
+ }
232
+
233
+ if (quote) {
234
+ if (char === quote) {
235
+ quote = null;
236
+ } else {
237
+ current += char;
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (char === '"' || char === "'") {
243
+ quote = char;
244
+ continue;
245
+ }
246
+
247
+ if (/\s/.test(char)) {
248
+ if (current) {
249
+ tokens.push(current);
250
+ current = "";
251
+ }
252
+ continue;
253
+ }
254
+
255
+ current += char;
256
+ }
257
+
258
+ if (escape) current += "\\";
259
+ if (quote) throw new Error("Command has an unclosed quote");
260
+ if (current) tokens.push(current);
261
+ return tokens;
262
+ }
263
+
264
+ function extractPlanOutputFile(args: string[]): string | undefined {
265
+ for (let index = 0; index < args.length; index += 1) {
266
+ const token = args[index];
267
+ if (token.startsWith("-out=")) {
268
+ const value = token.slice(5).trim();
269
+ if (value) return value;
270
+ }
271
+ if (token === "-out" && args[index + 1]) {
272
+ return args[index + 1];
273
+ }
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ function parseCommand(command: string): ParsedCommand {
279
+ const tokens = splitCommand(command);
280
+ if (tokens.length === 0) {
281
+ throw new Error("Type a Terraform command to run");
282
+ }
283
+
284
+ const args = tokens[0] === "terraform" ? tokens.slice(1) : [...tokens];
285
+ if (args.length === 0) {
286
+ throw new Error("Type a Terraform subcommand after 'terraform'");
287
+ }
288
+
289
+ const subcommand = args[0];
290
+ if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
291
+ throw new Error(
292
+ "Only Terraform commands are allowed here: fmt, init, validate, plan, show, apply, destroy, output, state, version, providers",
293
+ );
294
+ }
295
+
296
+ if (args.some((arg) => arg === "-chdir" || arg.startsWith("-chdir="))) {
297
+ throw new Error("-chdir is disabled in the infra lab console");
298
+ }
299
+
300
+ if (
301
+ (subcommand === "apply" || subcommand === "destroy") &&
302
+ !args.includes("-auto-approve")
303
+ ) {
304
+ throw new Error(
305
+ `${subcommand} requires -auto-approve in the infra lab console`,
306
+ );
307
+ }
308
+
309
+ const planOutputFile =
310
+ subcommand === "plan" ? extractPlanOutputFile(args.slice(1)) : undefined;
311
+ if (planOutputFile) {
312
+ assertSafeRelativePath(planOutputFile, "Plan output file");
313
+ }
314
+
315
+ return {
316
+ args,
317
+ subcommand,
318
+ action:
319
+ subcommand === "validate"
320
+ ? "validate"
321
+ : subcommand === "plan"
322
+ ? "plan"
323
+ : "command",
324
+ displayCommand: `$ terraform ${args.join(" ")}\n`,
325
+ planOutputFile,
326
+ };
327
+ }
328
+
329
+ async function runTerraformCommand(
330
+ cwd: string,
331
+ args: string[],
332
+ executionMode: InfraExecutionMode,
333
+ onChunk?: (chunk: { kind: OutputKind; text: string }) => void,
334
+ ): Promise<RunCommandResult> {
335
+ return await new Promise<RunCommandResult>((resolve) => {
336
+ const stdout: string[] = [];
337
+ const stderr: string[] = [];
338
+ const commandLine = `$ terraform ${args.join(" ")}\n`;
339
+ let settled = false;
340
+
341
+ onChunk?.({ kind: "info", text: commandLine });
342
+
343
+ const child = spawn("terraform", args, {
344
+ cwd,
345
+ env: {
346
+ ...getDefaultEnv(executionMode),
347
+ TF_DATA_DIR: path.join(cwd, ".tfdata"),
348
+ },
349
+ });
350
+
351
+ const finish = (result: RunCommandResult) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ resolve(result);
355
+ };
356
+
357
+ child.stdout.on("data", (chunk: Buffer) => {
358
+ const text = stripAnsi(chunk.toString());
359
+ stdout.push(text);
360
+ onChunk?.({ kind: "stdout", text });
361
+ });
362
+ child.stderr.on("data", (chunk: Buffer) => {
363
+ const text = stripAnsi(chunk.toString());
364
+ stderr.push(text);
365
+ onChunk?.({ kind: "stderr", text });
366
+ });
367
+ child.on("error", (error) => {
368
+ const message = `terraform launch failed: ${error.message}\n`;
369
+ onChunk?.({ kind: "stderr", text: message });
370
+ finish({
371
+ exitCode: 1,
372
+ stdout: "",
373
+ stderr: message,
374
+ combined: `${commandLine}${message}`,
375
+ });
376
+ });
377
+ child.on("close", (code) => {
378
+ const out = stdout.join("");
379
+ const err = stderr.join("");
380
+ finish({
381
+ exitCode: typeof code === "number" ? code : 1,
382
+ stdout: out,
383
+ stderr: err,
384
+ combined: `${commandLine}${out}${err}`,
385
+ });
386
+ });
387
+ });
388
+ }
389
+
390
+ function toDiagnostics(value: unknown): InfraDiagnostic[] {
391
+ if (!value || typeof value !== "object") return [];
392
+ const diagnostics = (value as { diagnostics?: unknown }).diagnostics;
393
+ if (!Array.isArray(diagnostics)) return [];
394
+
395
+ return diagnostics
396
+ .filter(
397
+ (entry): entry is Record<string, unknown> =>
398
+ !!entry && typeof entry === "object",
399
+ )
400
+ .map((entry) => {
401
+ const range =
402
+ entry.range && typeof entry.range === "object"
403
+ ? (entry.range as { filename?: unknown; start?: { line?: unknown } })
404
+ : undefined;
405
+ return {
406
+ severity: typeof entry.severity === "string" ? entry.severity : "error",
407
+ summary:
408
+ typeof entry.summary === "string"
409
+ ? entry.summary
410
+ : "Terraform reported a validation issue.",
411
+ detail: typeof entry.detail === "string" ? entry.detail : undefined,
412
+ address: typeof entry.address === "string" ? entry.address : undefined,
413
+ filename:
414
+ range && typeof range.filename === "string"
415
+ ? range.filename
416
+ : undefined,
417
+ line:
418
+ range && range.start && typeof range.start.line === "number"
419
+ ? range.start.line
420
+ : undefined,
421
+ } satisfies InfraDiagnostic;
422
+ });
423
+ }
424
+
425
+ function toPlanSummary(value: unknown): InfraPlanSummary | undefined {
426
+ if (!value || typeof value !== "object") return undefined;
427
+ const resourceChanges = (value as { resource_changes?: unknown })
428
+ .resource_changes;
429
+ if (!Array.isArray(resourceChanges)) return undefined;
430
+
431
+ const outputs =
432
+ (value as { planned_values?: { outputs?: Record<string, unknown> } })
433
+ .planned_values?.outputs || {};
434
+
435
+ const summary: InfraPlanSummary = {
436
+ add: 0,
437
+ change: 0,
438
+ destroy: 0,
439
+ replace: 0,
440
+ resourceCount: resourceChanges.length,
441
+ outputCount: Object.keys(outputs).length,
442
+ };
443
+
444
+ for (const change of resourceChanges) {
445
+ const actions =
446
+ change &&
447
+ typeof change === "object" &&
448
+ (change as { change?: { actions?: unknown } }).change?.actions;
449
+
450
+ if (!Array.isArray(actions)) continue;
451
+ const steps = actions.filter(
452
+ (step): step is string => typeof step === "string",
453
+ );
454
+
455
+ if (
456
+ steps.length === 2 &&
457
+ steps.includes("create") &&
458
+ steps.includes("delete")
459
+ ) {
460
+ summary.replace += 1;
461
+ continue;
462
+ }
463
+ if (steps.includes("create")) summary.add += 1;
464
+ if (steps.includes("update")) summary.change += 1;
465
+ if (steps.includes("delete")) summary.destroy += 1;
466
+ }
467
+
468
+ return summary;
469
+ }
470
+
471
+ async function writeWorkspaceFiles(
472
+ workspaceDir: string,
473
+ workspace: InfraLabWorkspace,
474
+ ): Promise<void> {
475
+ await fs.mkdir(workspaceDir, { recursive: true });
476
+ await Promise.all(
477
+ Object.entries(workspace.files).map(async ([name, content]) => {
478
+ const target = path.join(workspaceDir, name);
479
+ await fs.mkdir(path.dirname(target), { recursive: true });
480
+ await fs.writeFile(target, content, "utf8");
481
+ }),
482
+ );
483
+ }
484
+
485
+ // Injected when executionMode === "localstack" — skips real AWS credential
486
+ // validation so Terraform doesn't call STS with the test keys.
487
+ // const LOCALSTACK_OVERRIDE = `# Auto-injected by infra-runner — do not edit
488
+ // provider "aws" {
489
+ // skip_credentials_validation = true
490
+ // skip_requesting_account_id = true
491
+ // skip_metadata_api_check = true
492
+ // }
493
+ // `;
494
+
495
+ async function injectLocalStackOverride(workspaceDir: string): Promise<void> {
496
+ // await fs.writeFile(
497
+ // path.join(workspaceDir, "_localstack_override.tf"),
498
+ // LOCALSTACK_OVERRIDE,
499
+ // "utf8",
500
+ // );
501
+ }
502
+
503
+ async function writeArtifact(
504
+ artifactsDir: string,
505
+ key: string,
506
+ fileName: string,
507
+ kind: "text" | "json",
508
+ value: string | unknown,
509
+ ): Promise<InfraRunArtifact> {
510
+ await fs.mkdir(artifactsDir, { recursive: true });
511
+ const target = path.join(artifactsDir, fileName);
512
+ const content =
513
+ kind === "json" ? JSON.stringify(value, null, 2) : String(value);
514
+ await fs.writeFile(target, content, "utf8");
515
+ return {
516
+ key,
517
+ label: key,
518
+ kind,
519
+ fileName,
520
+ };
521
+ }
522
+
523
+ async function readJsonFile(filePath: string): Promise<unknown | undefined> {
524
+ try {
525
+ const raw = await fs.readFile(filePath, "utf8");
526
+ return JSON.parse(raw);
527
+ } catch {
528
+ return undefined;
529
+ }
530
+ }
531
+
532
+ async function readTextFile(filePath: string): Promise<string> {
533
+ try {
534
+ return await fs.readFile(filePath, "utf8");
535
+ } catch {
536
+ return "";
537
+ }
538
+ }
539
+
540
+ async function saveMetadata(
541
+ runDir: string,
542
+ metadata: InfraRunListItem,
543
+ ): Promise<void> {
544
+ await fs.mkdir(runDir, { recursive: true });
545
+ await fs.writeFile(
546
+ path.join(runDir, "metadata.json"),
547
+ JSON.stringify(metadata, null, 2),
548
+ "utf8",
549
+ );
550
+ }
551
+
552
+ async function syncWorkspaceToSession(
553
+ sessionKey: string,
554
+ workspace: InfraLabWorkspace,
555
+ ): Promise<string> {
556
+ const sessionDir = path.join(getInfraSessionsDir(), sanitizeKey(sessionKey));
557
+ const workspaceDir = path.join(sessionDir, "workspace");
558
+ const manifestPath = path.join(sessionDir, SOURCE_MANIFEST);
559
+ await fs.mkdir(sessionDir, { recursive: true });
560
+
561
+ const previous = await readJsonFile(manifestPath);
562
+ const previousFiles = Array.isArray(previous)
563
+ ? previous.filter((item): item is string => typeof item === "string")
564
+ : [];
565
+ const nextFiles = Object.keys(workspace.files);
566
+
567
+ await Promise.all(
568
+ previousFiles
569
+ .filter((fileName) => !nextFiles.includes(fileName))
570
+ .map(async (fileName) => {
571
+ try {
572
+ await fs.unlink(path.join(workspaceDir, fileName));
573
+ } catch {
574
+ // ignore if already gone
575
+ }
576
+ }),
577
+ );
578
+
579
+ await writeWorkspaceFiles(workspaceDir, workspace);
580
+ if (workspace.executionMode === "localstack") {
581
+ await injectLocalStackOverride(workspaceDir);
582
+ }
583
+ await fs.writeFile(manifestPath, JSON.stringify(nextFiles, null, 2), "utf8");
584
+ return workspaceDir;
585
+ }
586
+
587
+ async function readWorkspaceSnapshot(
588
+ workspaceDir: string,
589
+ workspace: InfraLabWorkspace,
590
+ ): Promise<InfraLabWorkspace> {
591
+ const manifest = await readJsonFile(
592
+ path.join(path.dirname(workspaceDir), SOURCE_MANIFEST),
593
+ );
594
+ const fileNames = Array.isArray(manifest)
595
+ ? manifest.filter((item): item is string => typeof item === "string")
596
+ : Object.keys(workspace.files);
597
+
598
+ // Also pick up any Terraform-generated files that appeared after the command
599
+ const GENERATED_FILES = [".terraform.lock.hcl"];
600
+ const extraFiles: string[] = [];
601
+ for (const gen of GENERATED_FILES) {
602
+ if (!fileNames.includes(gen)) {
603
+ try {
604
+ await fs.access(path.join(workspaceDir, gen));
605
+ extraFiles.push(gen);
606
+ } catch {
607
+ // not generated yet — skip
608
+ }
609
+ }
610
+ }
611
+
612
+ const files = Object.fromEntries(
613
+ await Promise.all(
614
+ [...fileNames, ...extraFiles].map(async (fileName) => {
615
+ try {
616
+ const content = await fs.readFile(
617
+ path.join(workspaceDir, fileName),
618
+ "utf8",
619
+ );
620
+ return [fileName, content] as const;
621
+ } catch {
622
+ return [fileName, ""] as const;
623
+ }
624
+ }),
625
+ ),
626
+ );
627
+
628
+ const activeFile = files[workspace.activeFile]
629
+ ? workspace.activeFile
630
+ : Object.keys(files)[0] || workspace.activeFile;
631
+
632
+ return {
633
+ ...workspace,
634
+ activeFile,
635
+ files,
636
+ };
637
+ }
638
+
639
+ async function collectValidationArtifacts(
640
+ workspaceDir: string,
641
+ executionMode: InfraExecutionMode,
642
+ artifactsDir: string,
643
+ ): Promise<{
644
+ diagnostics: InfraDiagnostic[];
645
+ validationJson?: unknown;
646
+ artifacts: InfraRunArtifact[];
647
+ }> {
648
+ const artifacts: InfraRunArtifact[] = [];
649
+ try {
650
+ const validate = await runTerraformCommand(
651
+ workspaceDir,
652
+ ["validate", "-json"],
653
+ executionMode,
654
+ );
655
+ const validationJson = validate.stdout
656
+ ? JSON.parse(validate.stdout)
657
+ : undefined;
658
+ const diagnostics = toDiagnostics(validationJson);
659
+ artifacts.push(
660
+ await writeArtifact(
661
+ artifactsDir,
662
+ "validation-json",
663
+ "validation.json",
664
+ "json",
665
+ validationJson ?? { diagnostics: [] },
666
+ ),
667
+ );
668
+ return { diagnostics, validationJson, artifacts };
669
+ } catch {
670
+ return { diagnostics: [], artifacts };
671
+ }
672
+ }
673
+
674
+ async function collectPlanArtifacts(
675
+ workspaceDir: string,
676
+ executionMode: InfraExecutionMode,
677
+ artifactsDir: string,
678
+ planFile?: string,
679
+ ): Promise<{
680
+ planSummary?: InfraPlanSummary;
681
+ planJson?: unknown;
682
+ artifacts: InfraRunArtifact[];
683
+ }> {
684
+ const artifacts: InfraRunArtifact[] = [];
685
+ if (!planFile) return { artifacts };
686
+
687
+ try {
688
+ assertSafeRelativePath(planFile, "Plan output file");
689
+ await fs.access(path.join(workspaceDir, planFile));
690
+ } catch {
691
+ return { artifacts };
692
+ }
693
+
694
+ try {
695
+ const show = await runTerraformCommand(
696
+ workspaceDir,
697
+ ["show", "-json", planFile],
698
+ executionMode,
699
+ );
700
+ if (show.exitCode !== 0 || !show.stdout.trim()) {
701
+ return { artifacts };
702
+ }
703
+
704
+ const planJson = JSON.parse(show.stdout);
705
+ const planSummary = toPlanSummary(planJson);
706
+ artifacts.push(
707
+ await writeArtifact(
708
+ artifactsDir,
709
+ "plan-json",
710
+ "plan.json",
711
+ "json",
712
+ planJson,
713
+ ),
714
+ );
715
+ return { planSummary, planJson, artifacts };
716
+ } catch {
717
+ return { artifacts };
718
+ }
719
+ }
720
+
721
+ function describeRunError(result: RunCommandResult, fallback: string): string {
722
+ return result.stderr.trim() || result.stdout.trim() || fallback;
723
+ }
724
+
725
+ export async function runInfraAction(input: {
726
+ questionId?: string;
727
+ fileId?: string;
728
+ label?: string;
729
+ action: Extract<InfraRunAction, "validate" | "plan">;
730
+ workspace: unknown;
731
+ }): Promise<InfraRunDetails> {
732
+ if (input.action !== "validate" && input.action !== "plan") {
733
+ throw new Error("action must be 'validate' or 'plan'");
734
+ }
735
+
736
+ const workspace = parseWorkspace(input.workspace);
737
+ const runId = randomUUID();
738
+ const startedAt = new Date().toISOString();
739
+ const runDir = path.join(getInfraRunsDir(), runId);
740
+ const workspaceDir = path.join(runDir, "workspace");
741
+ const artifactsDir = path.join(runDir, "artifacts");
742
+ let logs = "";
743
+ const artifacts: InfraRunArtifact[] = [];
744
+ let diagnostics: InfraDiagnostic[] = [];
745
+ let planSummary: InfraPlanSummary | undefined;
746
+ let validationJson: unknown;
747
+ let planJson: unknown;
748
+ let workspaceSnapshot: InfraLabWorkspace | undefined;
749
+ let status: InfraRunStatus = "completed";
750
+ let error: string | undefined;
751
+
752
+ await fs.mkdir(runDir, { recursive: true });
753
+ await writeWorkspaceFiles(workspaceDir, workspace);
754
+ if (workspace.executionMode === "localstack") {
755
+ await injectLocalStackOverride(workspaceDir);
756
+ }
757
+ artifacts.push(
758
+ await writeArtifact(
759
+ artifactsDir,
760
+ "workspace",
761
+ "workspace.json",
762
+ "json",
763
+ workspace,
764
+ ),
765
+ );
766
+
767
+ const runStep = async (args: string[]) => {
768
+ const result = await runTerraformCommand(
769
+ workspaceDir,
770
+ args,
771
+ workspace.executionMode,
772
+ );
773
+ logs = appendLog(logs, result.combined);
774
+ return result;
775
+ };
776
+
777
+ try {
778
+ const fmt = await runStep(["fmt", "-recursive"]);
779
+ if (fmt.exitCode !== 0) {
780
+ status = "failed";
781
+ error = describeRunError(fmt, "terraform fmt failed");
782
+ throw new Error(error);
783
+ }
784
+
785
+ const init = await runStep([
786
+ "init",
787
+ "-backend=false",
788
+ "-input=false",
789
+ "-no-color",
790
+ ]);
791
+ if (init.exitCode !== 0) {
792
+ status = "failed";
793
+ error = describeRunError(init, "terraform init failed");
794
+ throw new Error(error);
795
+ }
796
+
797
+ const validation = await collectValidationArtifacts(
798
+ workspaceDir,
799
+ workspace.executionMode,
800
+ artifactsDir,
801
+ );
802
+ diagnostics = validation.diagnostics;
803
+ validationJson = validation.validationJson;
804
+ artifacts.push(...validation.artifacts);
805
+ if (
806
+ input.action === "validate" &&
807
+ diagnostics.some((item) => item.severity === "error")
808
+ ) {
809
+ status = "failed";
810
+ error = diagnostics[0]?.summary || "terraform validate failed";
811
+ throw new Error(error);
812
+ }
813
+
814
+ if (input.action === "plan") {
815
+ const plan = await runStep([
816
+ "plan",
817
+ "-out=tfplan",
818
+ "-input=false",
819
+ "-lock=false",
820
+ "-no-color",
821
+ ]);
822
+ artifacts.push(
823
+ await writeArtifact(
824
+ artifactsDir,
825
+ "plan-text",
826
+ "plan.txt",
827
+ "text",
828
+ plan.stdout || plan.stderr,
829
+ ),
830
+ );
831
+ if (plan.exitCode !== 0) {
832
+ status = "failed";
833
+ error = describeRunError(plan, "terraform plan failed");
834
+ throw new Error(error);
835
+ }
836
+
837
+ const planArtifacts = await collectPlanArtifacts(
838
+ workspaceDir,
839
+ workspace.executionMode,
840
+ artifactsDir,
841
+ "tfplan",
842
+ );
843
+ planSummary = planArtifacts.planSummary;
844
+ planJson = planArtifacts.planJson;
845
+ artifacts.push(...planArtifacts.artifacts);
846
+ }
847
+ } catch (runError: any) {
848
+ if (!error) {
849
+ error = String(runError?.message ?? runError ?? "Infra run failed");
850
+ }
851
+ }
852
+
853
+ workspaceSnapshot = await readWorkspaceSnapshot(workspaceDir, workspace);
854
+ artifacts.push(
855
+ await writeArtifact(
856
+ artifactsDir,
857
+ "workspace-after",
858
+ "workspace-after.json",
859
+ "json",
860
+ workspaceSnapshot,
861
+ ),
862
+ );
863
+ artifacts.push(
864
+ await writeArtifact(artifactsDir, "log", "run.log", "text", logs),
865
+ );
866
+
867
+ const completedAt = new Date().toISOString();
868
+ const metadata: InfraRunListItem = {
869
+ id: runId,
870
+ ...(input.fileId ? { fileId: input.fileId } : {}),
871
+ ...(input.questionId ? { questionId: input.questionId } : {}),
872
+ label: input.label?.trim() || workspace.label,
873
+ action: input.action,
874
+ command:
875
+ input.action === "plan"
876
+ ? "terraform plan -out=tfplan -input=false -lock=false -no-color"
877
+ : "terraform validate -json",
878
+ status,
879
+ startedAt,
880
+ completedAt,
881
+ durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
882
+ provider: "aws",
883
+ executionMode: workspace.executionMode,
884
+ diagnostics,
885
+ ...(planSummary ? { planSummary } : {}),
886
+ ...(error ? { error } : {}),
887
+ artifacts,
888
+ };
889
+
890
+ await saveMetadata(runDir, metadata);
891
+
892
+ return {
893
+ ...metadata,
894
+ logs,
895
+ ...(validationJson !== undefined ? { validationJson } : {}),
896
+ ...(planJson !== undefined ? { planJson } : {}),
897
+ ...(workspaceSnapshot ? { workspaceSnapshot } : {}),
898
+ };
899
+ }
900
+
901
+ export async function streamInfraCommand(input: {
902
+ questionId?: string;
903
+ fileId?: string;
904
+ label?: string;
905
+ command: string;
906
+ workspace: unknown;
907
+ onMessage?: (message: InfraStreamMessage) => void;
908
+ }): Promise<InfraRunDetails> {
909
+ const workspace = parseWorkspace(input.workspace);
910
+ const parsed = parseCommand(input.command);
911
+ const sessionKey =
912
+ input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
913
+ const runId = randomUUID();
914
+ const startedAt = new Date().toISOString();
915
+ const runDir = path.join(getInfraRunsDir(), runId);
916
+ const artifactsDir = path.join(runDir, "artifacts");
917
+ const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
918
+
919
+ let logs = "";
920
+ const artifacts: InfraRunArtifact[] = [];
921
+ let diagnostics: InfraDiagnostic[] = [];
922
+ let planSummary: InfraPlanSummary | undefined;
923
+ let validationJson: unknown;
924
+ let planJson: unknown;
925
+ let workspaceSnapshot: InfraLabWorkspace | undefined;
926
+ let status: InfraRunStatus = "completed";
927
+ let error: string | undefined;
928
+
929
+ await fs.mkdir(runDir, { recursive: true });
930
+ artifacts.push(
931
+ await writeArtifact(
932
+ artifactsDir,
933
+ "workspace",
934
+ "workspace.json",
935
+ "json",
936
+ workspace,
937
+ ),
938
+ );
939
+
940
+ const emit = (message: InfraStreamMessage) => {
941
+ input.onMessage?.(message);
942
+ };
943
+
944
+ const commandResult = await runTerraformCommand(
945
+ workspaceDir,
946
+ parsed.args,
947
+ workspace.executionMode,
948
+ (chunk) => {
949
+ logs = appendLog(logs, chunk.text);
950
+ emit({ type: "output", kind: chunk.kind, text: chunk.text });
951
+ },
952
+ );
953
+
954
+ if (commandResult.exitCode !== 0) {
955
+ status = "failed";
956
+ error = describeRunError(
957
+ commandResult,
958
+ `terraform ${parsed.subcommand} failed`,
959
+ );
960
+ }
961
+
962
+ if (parsed.subcommand === "validate") {
963
+ const validation = await collectValidationArtifacts(
964
+ workspaceDir,
965
+ workspace.executionMode,
966
+ artifactsDir,
967
+ );
968
+ diagnostics = validation.diagnostics;
969
+ validationJson = validation.validationJson;
970
+ artifacts.push(...validation.artifacts);
971
+ }
972
+
973
+ if (parsed.subcommand === "plan") {
974
+ artifacts.push(
975
+ await writeArtifact(
976
+ artifactsDir,
977
+ "plan-text",
978
+ "plan.txt",
979
+ "text",
980
+ commandResult.stdout || commandResult.stderr,
981
+ ),
982
+ );
983
+ const planArtifacts = await collectPlanArtifacts(
984
+ workspaceDir,
985
+ workspace.executionMode,
986
+ artifactsDir,
987
+ parsed.planOutputFile,
988
+ );
989
+ planSummary = planArtifacts.planSummary;
990
+ planJson = planArtifacts.planJson;
991
+ artifacts.push(...planArtifacts.artifacts);
992
+ }
993
+
994
+ workspaceSnapshot = await readWorkspaceSnapshot(workspaceDir, workspace);
995
+ artifacts.push(
996
+ await writeArtifact(
997
+ artifactsDir,
998
+ "workspace-after",
999
+ "workspace-after.json",
1000
+ "json",
1001
+ workspaceSnapshot,
1002
+ ),
1003
+ );
1004
+ artifacts.push(
1005
+ await writeArtifact(artifactsDir, "log", "run.log", "text", logs),
1006
+ );
1007
+
1008
+ const completedAt = new Date().toISOString();
1009
+ const metadata: InfraRunListItem = {
1010
+ id: runId,
1011
+ ...(input.fileId ? { fileId: input.fileId } : {}),
1012
+ ...(input.questionId ? { questionId: input.questionId } : {}),
1013
+ label: input.label?.trim() || workspace.label,
1014
+ action: parsed.action,
1015
+ command: `terraform ${parsed.args.join(" ")}`,
1016
+ status,
1017
+ startedAt,
1018
+ completedAt,
1019
+ durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
1020
+ provider: "aws",
1021
+ executionMode: workspace.executionMode,
1022
+ diagnostics,
1023
+ ...(planSummary ? { planSummary } : {}),
1024
+ ...(error ? { error } : {}),
1025
+ artifacts,
1026
+ };
1027
+
1028
+ await saveMetadata(runDir, metadata);
1029
+
1030
+ const run: InfraRunDetails = {
1031
+ ...metadata,
1032
+ logs,
1033
+ ...(validationJson !== undefined ? { validationJson } : {}),
1034
+ ...(planJson !== undefined ? { planJson } : {}),
1035
+ ...(workspaceSnapshot ? { workspaceSnapshot } : {}),
1036
+ };
1037
+
1038
+ emit({ type: "complete", run });
1039
+ return run;
1040
+ }
1041
+
1042
+ export async function listInfraRuns(filter?: {
1043
+ fileId?: string;
1044
+ questionId?: string;
1045
+ }): Promise<InfraRunListItem[]> {
1046
+ const runsDir = getInfraRunsDir();
1047
+ try {
1048
+ const entries = await fs.readdir(runsDir, { withFileTypes: true });
1049
+ const items = await Promise.all(
1050
+ entries
1051
+ .filter((entry) => entry.isDirectory())
1052
+ .map(async (entry) => {
1053
+ try {
1054
+ const raw = await fs.readFile(
1055
+ path.join(runsDir, entry.name, "metadata.json"),
1056
+ "utf8",
1057
+ );
1058
+ return JSON.parse(raw) as InfraRunListItem;
1059
+ } catch {
1060
+ return null;
1061
+ }
1062
+ }),
1063
+ );
1064
+
1065
+ return items
1066
+ .filter((item): item is InfraRunListItem => !!item)
1067
+ .filter((item) => (filter?.fileId ? item.fileId === filter.fileId : true))
1068
+ .filter((item) =>
1069
+ filter?.questionId ? item.questionId === filter.questionId : true,
1070
+ )
1071
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt));
1072
+ } catch {
1073
+ return [];
1074
+ }
1075
+ }
1076
+
1077
+ export async function getInfraRun(runId: string): Promise<InfraRunDetails> {
1078
+ const runDir = path.join(getInfraRunsDir(), runId);
1079
+ const metadataRaw = await fs.readFile(
1080
+ path.join(runDir, "metadata.json"),
1081
+ "utf8",
1082
+ );
1083
+ const metadata = JSON.parse(metadataRaw) as InfraRunListItem;
1084
+ const logs = await readTextFile(path.join(runDir, "artifacts", "run.log"));
1085
+ const validationJson = await readJsonFile(
1086
+ path.join(runDir, "artifacts", "validation.json"),
1087
+ );
1088
+ const planJson = await readJsonFile(
1089
+ path.join(runDir, "artifacts", "plan.json"),
1090
+ );
1091
+ const workspaceSnapshot = await readJsonFile(
1092
+ path.join(runDir, "artifacts", "workspace-after.json"),
1093
+ );
1094
+
1095
+ return {
1096
+ ...metadata,
1097
+ logs,
1098
+ ...(validationJson !== undefined ? { validationJson } : {}),
1099
+ ...(planJson !== undefined ? { planJson } : {}),
1100
+ ...(workspaceSnapshot
1101
+ ? { workspaceSnapshot: workspaceSnapshot as InfraLabWorkspace }
1102
+ : {}),
1103
+ };
1104
+ }