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.
@@ -4,7 +4,7 @@ import { randomUUID } from "crypto";
4
4
  import { spawn } from "child_process";
5
5
  import * as storage from "./storage.js";
6
6
 
7
- type InfraExecutionMode = "plan-only" | "localstack";
7
+ type InfraExecutionMode = "plan-only" | "localstack" | "docker";
8
8
  export type InfraRunAction = "validate" | "plan" | "command";
9
9
  type InfraRunStatus = "completed" | "failed";
10
10
  type OutputKind = "stdout" | "stderr" | "info";
@@ -12,7 +12,7 @@ type OutputKind = "stdout" | "stderr" | "info";
12
12
  interface InfraLabWorkspace {
13
13
  version: 1;
14
14
  label: string;
15
- provider: "aws";
15
+ provider: "aws" | "docker";
16
16
  executionMode: InfraExecutionMode;
17
17
  activeFile: string;
18
18
  files: Record<string, string>;
@@ -54,7 +54,7 @@ export interface InfraRunListItem {
54
54
  startedAt: string;
55
55
  completedAt: string;
56
56
  durationMs: number;
57
- provider: "aws";
57
+ provider: "aws" | "docker";
58
58
  executionMode: InfraExecutionMode;
59
59
  diagnostics: InfraDiagnostic[];
60
60
  planSummary?: InfraPlanSummary;
@@ -82,17 +82,21 @@ interface RunCommandResult {
82
82
  }
83
83
 
84
84
  interface ParsedCommand {
85
+ executable: string;
85
86
  args: string[];
86
87
  subcommand: string;
87
88
  action: InfraRunAction;
89
+ command: string;
88
90
  displayCommand: string;
89
91
  planOutputFile?: string;
92
+ tool: "terraform" | "docker" | "local";
90
93
  }
91
94
 
92
95
  const MAX_FILE_COUNT = 24;
93
96
  const MAX_TOTAL_SOURCE_BYTES = 500_000;
94
97
  const MAX_LOG_CHARS = 200_000;
95
98
  const SOURCE_MANIFEST = ".infra-source-files.json";
99
+ const SHELL_OPERATORS = new Set(["|", "||", "&&", ";", ">", ">>", "<", "<<"]);
96
100
  const ALLOWED_SUBCOMMANDS = new Set([
97
101
  "fmt",
98
102
  "init",
@@ -106,6 +110,56 @@ const ALLOWED_SUBCOMMANDS = new Set([
106
110
  "version",
107
111
  "providers",
108
112
  ]);
113
+ const ALLOWED_DOCKER_SUBCOMMANDS = new Set([
114
+ "version",
115
+ "info",
116
+ "context",
117
+ "ps",
118
+ "run",
119
+ "exec",
120
+ "create",
121
+ "container",
122
+ "image",
123
+ "images",
124
+ "history",
125
+ "network",
126
+ "volume",
127
+ "logs",
128
+ "inspect",
129
+ "port",
130
+ "top",
131
+ "stats",
132
+ "rm",
133
+ "rmi",
134
+ "stop",
135
+ "start",
136
+ "restart",
137
+ "build",
138
+ "pull",
139
+ "tag",
140
+ "compose",
141
+ "system",
142
+ ]);
143
+ const ALLOWED_LOCAL_COMMANDS = new Set(["pwd", "ls", "cat", "curl"]);
144
+ const DISALLOWED_DOCKER_FLAGS = new Set([
145
+ "--privileged",
146
+ "--pid=host",
147
+ "--ipc=host",
148
+ "--uts=host",
149
+ "--userns=host",
150
+ "--network=host",
151
+ "--net=host",
152
+ ]);
153
+ const DISALLOWED_CURL_FLAGS = new Set([
154
+ "-o",
155
+ "--output",
156
+ "-O",
157
+ "--remote-name",
158
+ "--config",
159
+ "-K",
160
+ "-T",
161
+ "--upload-file",
162
+ ]);
109
163
 
110
164
  function getInfraRunsDir(): string {
111
165
  return path.resolve(storage.getContextFilesDir(), "..", "infra-runs");
@@ -182,9 +236,13 @@ function parseWorkspace(input: unknown): InfraLabWorkspace {
182
236
  typeof candidate.label === "string" && candidate.label.trim()
183
237
  ? candidate.label.trim()
184
238
  : "Infrastructure Lab",
185
- provider: "aws",
239
+ provider: candidate.provider === "docker" ? "docker" : "aws",
186
240
  executionMode:
187
- candidate.executionMode === "plan-only" ? "plan-only" : "localstack",
241
+ candidate.executionMode === "docker"
242
+ ? "docker"
243
+ : candidate.executionMode === "plan-only"
244
+ ? "plan-only"
245
+ : "localstack",
188
246
  activeFile:
189
247
  typeof candidate.activeFile === "string" && files[candidate.activeFile]
190
248
  ? candidate.activeFile
@@ -275,12 +333,91 @@ function extractPlanOutputFile(args: string[]): string | undefined {
275
333
  return undefined;
276
334
  }
277
335
 
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");
336
+ function assertNoShellOperators(tokens: string[]): void {
337
+ const operator = tokens.find((token) => SHELL_OPERATORS.has(token));
338
+ if (operator) {
339
+ throw new Error(
340
+ `Shell operator '${operator}' is not supported. Run one command at a time.`,
341
+ );
342
+ }
343
+ }
344
+
345
+ function hasForceFlag(args: string[]): boolean {
346
+ return args.some(
347
+ (arg) =>
348
+ arg === "-f" ||
349
+ arg === "--force" ||
350
+ arg.startsWith("--force=") ||
351
+ (/^-[A-Za-z]+$/.test(arg) && arg.includes("f")),
352
+ );
353
+ }
354
+
355
+ function assertSafeLocalPathArgs(args: string[], label: string): void {
356
+ for (const arg of args) {
357
+ if (!arg || arg.startsWith("-") || arg === ".") continue;
358
+ assertSafeRelativePath(arg, label);
359
+ }
360
+ }
361
+
362
+ function assertSafeDockerArgs(args: string[]): void {
363
+ const disallowed = args.find((arg) => DISALLOWED_DOCKER_FLAGS.has(arg));
364
+ if (disallowed) {
365
+ throw new Error(`${disallowed} is disabled in the lab console`);
366
+ }
367
+
368
+ const subcommand = args[0];
369
+ const composeSubcommand = subcommand === "compose" ? args[1] : undefined;
370
+
371
+ if (subcommand === "compose" && composeSubcommand === "up") {
372
+ const detached = args.includes("-d") || args.includes("--detach");
373
+ if (!detached) {
374
+ throw new Error(
375
+ "Use docker compose up -d in this console so the command does not stay attached forever",
376
+ );
377
+ }
378
+ }
379
+
380
+ const followsLogs = args.some(
381
+ (arg) =>
382
+ arg === "-f" || arg === "--follow" || /^-[A-Za-z]*f[A-Za-z]*$/.test(arg),
383
+ );
384
+ if ((subcommand === "logs" || composeSubcommand === "logs") && followsLogs) {
385
+ throw new Error(
386
+ "Follow-mode logs are disabled; run docker logs without -f",
387
+ );
282
388
  }
283
389
 
390
+ if (subcommand === "stats" && !args.includes("--no-stream")) {
391
+ throw new Error("Use docker stats --no-stream in this console");
392
+ }
393
+ }
394
+
395
+ function assertSafeCurlArgs(args: string[]): void {
396
+ const disallowed = args.find((arg) => DISALLOWED_CURL_FLAGS.has(arg));
397
+ if (disallowed) {
398
+ throw new Error(`${disallowed} is disabled in the lab console`);
399
+ }
400
+
401
+ const urls = args.filter((arg) => /^https?:\/\//i.test(arg));
402
+ if (urls.length === 0) {
403
+ throw new Error("curl requires a localhost http:// or https:// URL");
404
+ }
405
+
406
+ for (const url of urls) {
407
+ if (
408
+ !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(
409
+ url,
410
+ )
411
+ ) {
412
+ throw new Error("curl is limited to localhost URLs in the lab console");
413
+ }
414
+ }
415
+ }
416
+
417
+ function parseTerraformCommand(
418
+ command: string,
419
+ tokens: string[],
420
+ ): ParsedCommand {
284
421
  const args = tokens[0] === "terraform" ? tokens.slice(1) : [...tokens];
285
422
  if (args.length === 0) {
286
423
  throw new Error("Type a Terraform subcommand after 'terraform'");
@@ -289,7 +426,7 @@ function parseCommand(command: string): ParsedCommand {
289
426
  const subcommand = args[0];
290
427
  if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
291
428
  throw new Error(
292
- "Only Terraform commands are allowed here: fmt, init, validate, plan, show, apply, destroy, output, state, version, providers",
429
+ "Supported Terraform commands: fmt, init, validate, plan, show, apply, destroy, output, state, version, providers",
293
430
  );
294
431
  }
295
432
 
@@ -312,7 +449,9 @@ function parseCommand(command: string): ParsedCommand {
312
449
  assertSafeRelativePath(planOutputFile, "Plan output file");
313
450
  }
314
451
 
452
+ const normalizedCommand = `terraform ${args.join(" ")}`;
315
453
  return {
454
+ executable: "terraform",
316
455
  args,
317
456
  subcommand,
318
457
  action:
@@ -321,30 +460,149 @@ function parseCommand(command: string): ParsedCommand {
321
460
  : subcommand === "plan"
322
461
  ? "plan"
323
462
  : "command",
324
- displayCommand: `$ terraform ${args.join(" ")}\n`,
463
+ command: normalizedCommand,
464
+ displayCommand: `$ ${normalizedCommand}\n`,
325
465
  planOutputFile,
466
+ tool: "terraform",
326
467
  };
327
468
  }
328
469
 
329
- async function runTerraformCommand(
470
+ function parseDockerCommand(command: string, tokens: string[]): ParsedCommand {
471
+ const args = tokens.slice(1);
472
+ if (args.length === 0) {
473
+ throw new Error("Type a Docker subcommand after 'docker'");
474
+ }
475
+
476
+ if (
477
+ args.some(
478
+ (arg) =>
479
+ arg === "-H" ||
480
+ arg.startsWith("-H=") ||
481
+ arg === "--host" ||
482
+ arg.startsWith("--host="),
483
+ )
484
+ ) {
485
+ throw new Error("Changing the Docker host is disabled in this console");
486
+ }
487
+
488
+ const subcommand = args[0];
489
+ if (!ALLOWED_DOCKER_SUBCOMMANDS.has(subcommand)) {
490
+ throw new Error(
491
+ "Supported Docker commands include: version, info, context, ps, run, exec, images, network, volume, container, logs, inspect, port, stats, rm, stop, start, build, pull, tag, compose, system",
492
+ );
493
+ }
494
+
495
+ assertSafeDockerArgs(args);
496
+
497
+ if (args.includes("prune") && !hasForceFlag(args)) {
498
+ throw new Error("Docker prune commands require --force or -f");
499
+ }
500
+
501
+ return {
502
+ executable: "docker",
503
+ args,
504
+ subcommand:
505
+ subcommand === "compose" && args[1] ? `compose ${args[1]}` : subcommand,
506
+ action: "command",
507
+ command: command.trim(),
508
+ displayCommand: `$ ${command.trim()}\n`,
509
+ tool: "docker",
510
+ };
511
+ }
512
+
513
+ function parseLocalCommand(command: string, tokens: string[]): ParsedCommand {
514
+ const executable = tokens[0];
515
+ const args = tokens.slice(1);
516
+
517
+ if (!ALLOWED_LOCAL_COMMANDS.has(executable)) {
518
+ throw new Error("Supported local commands: pwd, ls, cat, curl");
519
+ }
520
+
521
+ if (executable === "curl") {
522
+ assertSafeCurlArgs(args);
523
+ return {
524
+ executable,
525
+ args,
526
+ subcommand: executable,
527
+ action: "command",
528
+ command: command.trim(),
529
+ displayCommand: `$ ${command.trim()}\n`,
530
+ tool: "local",
531
+ };
532
+ }
533
+
534
+ if (executable === "pwd" && args.length > 0) {
535
+ throw new Error("pwd does not accept arguments in this console");
536
+ }
537
+
538
+ if (executable === "ls") {
539
+ assertSafeLocalPathArgs(args, "ls path");
540
+ }
541
+
542
+ if (executable === "cat") {
543
+ const fileArgs = args.filter((arg) => !arg.startsWith("-"));
544
+ if (fileArgs.length === 0) {
545
+ throw new Error("cat requires a workspace-relative file path");
546
+ }
547
+ assertSafeLocalPathArgs(fileArgs, "cat path");
548
+ }
549
+
550
+ return {
551
+ executable,
552
+ args,
553
+ subcommand: executable,
554
+ action: "command",
555
+ command: command.trim(),
556
+ displayCommand: `$ ${command.trim()}\n`,
557
+ tool: "local",
558
+ };
559
+ }
560
+
561
+ function parseCommand(command: string): ParsedCommand {
562
+ const tokens = splitCommand(command);
563
+ if (tokens.length === 0) {
564
+ throw new Error("Type a command to run");
565
+ }
566
+ assertNoShellOperators(tokens);
567
+
568
+ if (tokens[0] === "terraform" || ALLOWED_SUBCOMMANDS.has(tokens[0])) {
569
+ return parseTerraformCommand(command, tokens);
570
+ }
571
+
572
+ if (tokens[0] === "docker") {
573
+ return parseDockerCommand(command, tokens);
574
+ }
575
+
576
+ if (ALLOWED_LOCAL_COMMANDS.has(tokens[0])) {
577
+ return parseLocalCommand(command, tokens);
578
+ }
579
+
580
+ throw new Error(
581
+ "Supported commands: terraform, docker, pwd, ls, cat, curl. Shell operators are disabled.",
582
+ );
583
+ }
584
+
585
+ async function runWorkspaceCommand(
330
586
  cwd: string,
587
+ executable: string,
331
588
  args: string[],
332
589
  executionMode: InfraExecutionMode,
590
+ displayCommand: string,
333
591
  onChunk?: (chunk: { kind: OutputKind; text: string }) => void,
592
+ extraEnv: NodeJS.ProcessEnv = {},
334
593
  ): Promise<RunCommandResult> {
335
594
  return await new Promise<RunCommandResult>((resolve) => {
336
595
  const stdout: string[] = [];
337
596
  const stderr: string[] = [];
338
- const commandLine = `$ terraform ${args.join(" ")}\n`;
339
597
  let settled = false;
340
598
 
341
- onChunk?.({ kind: "info", text: commandLine });
599
+ onChunk?.({ kind: "info", text: displayCommand });
342
600
 
343
- const child = spawn("terraform", args, {
601
+ const child = spawn(executable, args, {
344
602
  cwd,
345
603
  env: {
346
604
  ...getDefaultEnv(executionMode),
347
- TF_DATA_DIR: path.join(cwd, ".tfdata"),
605
+ ...extraEnv,
348
606
  },
349
607
  });
350
608
 
@@ -365,13 +623,13 @@ async function runTerraformCommand(
365
623
  onChunk?.({ kind: "stderr", text });
366
624
  });
367
625
  child.on("error", (error) => {
368
- const message = `terraform launch failed: ${error.message}\n`;
626
+ const message = `${executable} launch failed: ${error.message}\n`;
369
627
  onChunk?.({ kind: "stderr", text: message });
370
628
  finish({
371
629
  exitCode: 1,
372
630
  stdout: "",
373
631
  stderr: message,
374
- combined: `${commandLine}${message}`,
632
+ combined: `${displayCommand}${message}`,
375
633
  });
376
634
  });
377
635
  child.on("close", (code) => {
@@ -381,12 +639,48 @@ async function runTerraformCommand(
381
639
  exitCode: typeof code === "number" ? code : 1,
382
640
  stdout: out,
383
641
  stderr: err,
384
- combined: `${commandLine}${out}${err}`,
642
+ combined: `${displayCommand}${out}${err}`,
385
643
  });
386
644
  });
387
645
  });
388
646
  }
389
647
 
648
+ async function runTerraformCommand(
649
+ cwd: string,
650
+ args: string[],
651
+ executionMode: InfraExecutionMode,
652
+ onChunk?: (chunk: { kind: OutputKind; text: string }) => void,
653
+ ): Promise<RunCommandResult> {
654
+ return runWorkspaceCommand(
655
+ cwd,
656
+ "terraform",
657
+ args,
658
+ executionMode,
659
+ `$ terraform ${args.join(" ")}\n`,
660
+ onChunk,
661
+ { TF_DATA_DIR: path.join(cwd, ".tfdata") },
662
+ );
663
+ }
664
+
665
+ async function runParsedCommand(
666
+ cwd: string,
667
+ parsed: ParsedCommand,
668
+ executionMode: InfraExecutionMode,
669
+ onChunk?: (chunk: { kind: OutputKind; text: string }) => void,
670
+ ): Promise<RunCommandResult> {
671
+ return runWorkspaceCommand(
672
+ cwd,
673
+ parsed.executable,
674
+ parsed.args,
675
+ executionMode,
676
+ parsed.displayCommand,
677
+ onChunk,
678
+ parsed.tool === "terraform"
679
+ ? { TF_DATA_DIR: path.join(cwd, ".tfdata") }
680
+ : {},
681
+ );
682
+ }
683
+
390
684
  function toDiagnostics(value: unknown): InfraDiagnostic[] {
391
685
  if (!value || typeof value !== "object") return [];
392
686
  const diagnostics = (value as { diagnostics?: unknown }).diagnostics;
@@ -879,7 +1173,7 @@ export async function runInfraAction(input: {
879
1173
  startedAt,
880
1174
  completedAt,
881
1175
  durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
882
- provider: "aws",
1176
+ provider: workspace.provider,
883
1177
  executionMode: workspace.executionMode,
884
1178
  diagnostics,
885
1179
  ...(planSummary ? { planSummary } : {}),
@@ -941,9 +1235,9 @@ export async function streamInfraCommand(input: {
941
1235
  input.onMessage?.(message);
942
1236
  };
943
1237
 
944
- const commandResult = await runTerraformCommand(
1238
+ const commandResult = await runParsedCommand(
945
1239
  workspaceDir,
946
- parsed.args,
1240
+ parsed,
947
1241
  workspace.executionMode,
948
1242
  (chunk) => {
949
1243
  logs = appendLog(logs, chunk.text);
@@ -953,13 +1247,10 @@ export async function streamInfraCommand(input: {
953
1247
 
954
1248
  if (commandResult.exitCode !== 0) {
955
1249
  status = "failed";
956
- error = describeRunError(
957
- commandResult,
958
- `terraform ${parsed.subcommand} failed`,
959
- );
1250
+ error = describeRunError(commandResult, `${parsed.command} failed`);
960
1251
  }
961
1252
 
962
- if (parsed.subcommand === "validate") {
1253
+ if (parsed.tool === "terraform" && parsed.subcommand === "validate") {
963
1254
  const validation = await collectValidationArtifacts(
964
1255
  workspaceDir,
965
1256
  workspace.executionMode,
@@ -970,7 +1261,7 @@ export async function streamInfraCommand(input: {
970
1261
  artifacts.push(...validation.artifacts);
971
1262
  }
972
1263
 
973
- if (parsed.subcommand === "plan") {
1264
+ if (parsed.tool === "terraform" && parsed.subcommand === "plan") {
974
1265
  artifacts.push(
975
1266
  await writeArtifact(
976
1267
  artifactsDir,
@@ -1012,12 +1303,12 @@ export async function streamInfraCommand(input: {
1012
1303
  ...(input.questionId ? { questionId: input.questionId } : {}),
1013
1304
  label: input.label?.trim() || workspace.label,
1014
1305
  action: parsed.action,
1015
- command: `terraform ${parsed.args.join(" ")}`,
1306
+ command: parsed.command,
1016
1307
  status,
1017
1308
  startedAt,
1018
1309
  completedAt,
1019
1310
  durationMs: new Date(completedAt).getTime() - new Date(startedAt).getTime(),
1020
- provider: "aws",
1311
+ provider: workspace.provider,
1021
1312
  executionMode: workspace.executionMode,
1022
1313
  diagnostics,
1023
1314
  ...(planSummary ? { planSummary } : {}),
@@ -73,7 +73,9 @@ export interface ContextFile {
73
73
  | "infra"
74
74
  | "react"
75
75
  | "nextjs"
76
- | "module-federation";
76
+ | "module-federation"
77
+ | "canvas"
78
+ | "github-actions";
77
79
  /** Language hint for code snippets (e.g. 'typescript', 'javascript'). */
78
80
  language?: string;
79
81
  /** Short display label for code snippets. */