@intentius/chant-lexicon-terraform 0.57.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 (131) hide show
  1. package/README.md +53 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +11 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate.d.ts +28 -0
  9. package/dist/codegen/generate.d.ts.map +1 -0
  10. package/dist/codegen/package.d.ts +17 -0
  11. package/dist/codegen/package.d.ts.map +1 -0
  12. package/dist/composites/terraform-apply-op.d.ts +88 -0
  13. package/dist/composites/terraform-apply-op.d.ts.map +1 -0
  14. package/dist/composites/terraform-watch-op.d.ts +124 -0
  15. package/dist/composites/terraform-watch-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +83 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +127 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +2 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/hcl/parse.d.ts +87 -0
  23. package/dist/hcl/parse.d.ts.map +1 -0
  24. package/dist/hcl/roots.d.ts +37 -0
  25. package/dist/hcl/roots.d.ts.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/integrity.json +12 -0
  29. package/dist/lint/audit-catalog.d.ts +12 -0
  30. package/dist/lint/audit-catalog.d.ts.map +1 -0
  31. package/dist/lint/audit-lineage.d.ts +21 -0
  32. package/dist/lint/audit-lineage.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts +3 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -0
  35. package/dist/lint/post-synth/tf001.d.ts +17 -0
  36. package/dist/lint/post-synth/tf001.d.ts.map +1 -0
  37. package/dist/lint/rules/index.d.ts +5 -0
  38. package/dist/lint/rules/index.d.ts.map +1 -0
  39. package/dist/lint/rules/plan-before-apply.d.ts +30 -0
  40. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +26 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/context.d.ts +70 -0
  44. package/dist/lsp/context.d.ts.map +1 -0
  45. package/dist/lsp/hover.d.ts +12 -0
  46. package/dist/lsp/hover.d.ts.map +1 -0
  47. package/dist/lsp/option-keys.d.ts +34 -0
  48. package/dist/lsp/option-keys.d.ts.map +1 -0
  49. package/dist/manifest.json +6 -0
  50. package/dist/meta.json +1 -0
  51. package/dist/okf/index.md +8 -0
  52. package/dist/okf/rules/TF001.md +11 -0
  53. package/dist/okf/rules/TF101.md +11 -0
  54. package/dist/op/activities/index.d.ts +22 -0
  55. package/dist/op/activities/index.d.ts.map +1 -0
  56. package/dist/op/activities/terraform.d.ts +213 -0
  57. package/dist/op/activities/terraform.d.ts.map +1 -0
  58. package/dist/op/builders.d.ts +56 -0
  59. package/dist/op/builders.d.ts.map +1 -0
  60. package/dist/package-cli.d.ts +3 -0
  61. package/dist/package-cli.d.ts.map +1 -0
  62. package/dist/plugin.d.ts +11 -0
  63. package/dist/plugin.d.ts.map +1 -0
  64. package/dist/rules/plan-before-apply.ts +143 -0
  65. package/dist/rules/tf001.ts +66 -0
  66. package/dist/serializer.d.ts +19 -0
  67. package/dist/serializer.d.ts.map +1 -0
  68. package/dist/skills/chant-terraform.md +92 -0
  69. package/dist/state-ownership.d.ts +27 -0
  70. package/dist/state-ownership.d.ts.map +1 -0
  71. package/dist/types/index.d.ts +2 -0
  72. package/dist/validate-cli.d.ts +3 -0
  73. package/dist/validate-cli.d.ts.map +1 -0
  74. package/dist/validate.d.ts +15 -0
  75. package/dist/validate.d.ts.map +1 -0
  76. package/package.json +75 -0
  77. package/src/__fixtures__/no-backend/main.tf +24 -0
  78. package/src/__fixtures__/show-state.json +68 -0
  79. package/src/__fixtures__/with-backend/main.tf +28 -0
  80. package/src/__fixtures__/with-module/main.tf +49 -0
  81. package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
  82. package/src/codegen/docs-cli.ts +4 -0
  83. package/src/codegen/docs.ts +50 -0
  84. package/src/codegen/generate-cli.ts +10 -0
  85. package/src/codegen/generate.ts +68 -0
  86. package/src/codegen/package.ts +50 -0
  87. package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
  88. package/src/composites/terraform-apply-op.test.ts +193 -0
  89. package/src/composites/terraform-apply-op.ts +166 -0
  90. package/src/composites/terraform-watch-op.test.ts +184 -0
  91. package/src/composites/terraform-watch-op.ts +204 -0
  92. package/src/config.ts +76 -0
  93. package/src/describe-resources.test.ts +342 -0
  94. package/src/describe-resources.ts +357 -0
  95. package/src/generated/index.d.ts +2 -0
  96. package/src/generated/index.ts +4 -0
  97. package/src/generated/lexicon-terraform.json +1 -0
  98. package/src/hcl/parse.ts +235 -0
  99. package/src/hcl/roots.ts +67 -0
  100. package/src/index.ts +65 -0
  101. package/src/lint/audit-catalog.ts +30 -0
  102. package/src/lint/audit-lineage.ts +21 -0
  103. package/src/lint/audit.test.ts +45 -0
  104. package/src/lint/post-synth/index.ts +7 -0
  105. package/src/lint/post-synth/post-synth.test.ts +95 -0
  106. package/src/lint/post-synth/tf001.ts +66 -0
  107. package/src/lint/rules/index.ts +7 -0
  108. package/src/lint/rules/plan-before-apply.test.ts +111 -0
  109. package/src/lint/rules/plan-before-apply.ts +143 -0
  110. package/src/lsp/completions.test.ts +120 -0
  111. package/src/lsp/completions.ts +101 -0
  112. package/src/lsp/context.test.ts +152 -0
  113. package/src/lsp/context.ts +349 -0
  114. package/src/lsp/hover.test.ts +82 -0
  115. package/src/lsp/hover.ts +44 -0
  116. package/src/lsp/option-keys.ts +106 -0
  117. package/src/op/activities/index.ts +48 -0
  118. package/src/op/activities/registry.test.ts +29 -0
  119. package/src/op/activities/terraform.test.ts +445 -0
  120. package/src/op/activities/terraform.ts +469 -0
  121. package/src/op/builders.test.ts +90 -0
  122. package/src/op/builders.ts +96 -0
  123. package/src/package-cli.ts +21 -0
  124. package/src/plugin.test.ts +271 -0
  125. package/src/plugin.ts +157 -0
  126. package/src/serializer.test.ts +26 -0
  127. package/src/serializer.ts +26 -0
  128. package/src/skills/chant-terraform.md +92 -0
  129. package/src/state-ownership.ts +32 -0
  130. package/src/validate-cli.ts +5 -0
  131. package/src/validate.ts +28 -0
@@ -0,0 +1,445 @@
1
+ /**
2
+ * terraform Op activity tests (#2086).
3
+ *
4
+ * Nothing here runs terraform. `node:child_process` is mocked the way
5
+ * `lexicons/k3s/src/op/activities/k3s.test.ts` mocks it — the exec export
6
+ * carries a `nodejs.util.promisify.custom` implementation, which is what
7
+ * `promisify(exec)` picks up, so every call the activities make lands in
8
+ * `execCalls` with its exact command string and options.
9
+ *
10
+ * The project config is real, not mocked: each test writes a
11
+ * `chant.config.json` into a temp directory and passes it as `cwd`, so the
12
+ * `terraform.roots` lookup, the project-root-relative `dir` resolution and the
13
+ * `binary` choice are all exercised on the path production takes.
14
+ */
15
+
16
+ import { describe, test, expect, beforeEach, afterAll, vi } from "vitest";
17
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, resolve } from "node:path";
20
+ import {
21
+ terraformInit,
22
+ terraformPlan,
23
+ terraformApply,
24
+ terraformShow,
25
+ terraformInitCommand,
26
+ terraformPlanCommand,
27
+ terraformApplyCommand,
28
+ terraformShowCommand,
29
+ terraformEnvironment,
30
+ terraformBinary,
31
+ countPlanChanges,
32
+ quoteArg,
33
+ DEFAULT_PLAN_FILE,
34
+ } from "./terraform";
35
+
36
+ // ── The child-process stub ──────────────────────────────────────────────────
37
+
38
+ interface ExecCall {
39
+ cmd: string;
40
+ opts: { cwd?: string; env?: Record<string, string>; signal?: AbortSignal };
41
+ }
42
+
43
+ const execCalls: ExecCall[] = [];
44
+
45
+ /** cmd substring -> what the stub does when it sees it. */
46
+ type Reply = { stdout: string; stderr: string } | Error;
47
+ let replies: Array<{ match: string; reply: Reply }> = [];
48
+ /** When set, every call hangs until its signal aborts (the cancellation test). */
49
+ let hangUntilAborted = false;
50
+
51
+ /** Build the Error `promisify(exec)` rejects with for a non-zero exit. */
52
+ function execError(code: number, stderr: string, stdout = ""): Error & { code: number; stdout: string; stderr: string } {
53
+ return Object.assign(new Error(`Command failed (exit ${code})`), { code, stdout, stderr });
54
+ }
55
+
56
+ vi.mock("node:child_process", () => {
57
+ const custom = Symbol.for("nodejs.util.promisify.custom");
58
+ const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
59
+ cb?.(new Error("unmocked exec path"));
60
+ }) as unknown as Record<symbol, unknown>;
61
+ exec[custom] = async (cmd: string, opts?: ExecCall["opts"]) => {
62
+ execCalls.push({ cmd, opts: opts ?? {} });
63
+ if (hangUntilAborted) {
64
+ const signal = opts?.signal;
65
+ return new Promise((_resolve, reject) => {
66
+ if (!signal) return; // hangs forever; no test does this
67
+ const fail = (): void =>
68
+ reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
69
+ if (signal.aborted) fail();
70
+ else signal.addEventListener("abort", fail, { once: true });
71
+ });
72
+ }
73
+ for (const { match, reply } of replies) {
74
+ if (cmd.includes(match)) {
75
+ if (reply instanceof Error) throw reply;
76
+ return reply;
77
+ }
78
+ }
79
+ return { stdout: "", stderr: "" };
80
+ };
81
+ return { exec };
82
+ });
83
+
84
+ // ── A real project config to resolve roots against ──────────────────────────
85
+
86
+ const workspaces: string[] = [];
87
+
88
+ /** Write a throwaway project with a `terraform` namespace; returns its root. */
89
+ function project(namespace: Record<string, unknown>): string {
90
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-op-"));
91
+ workspaces.push(dir);
92
+ mkdirSync(join(dir, "infra"), { recursive: true });
93
+ writeFileSync(
94
+ join(dir, "chant.config.json"),
95
+ JSON.stringify({ lexicons: ["terraform"], terraform: namespace }, null, 2),
96
+ );
97
+ return dir;
98
+ }
99
+
100
+ afterAll(() => {
101
+ for (const dir of workspaces) rmSync(dir, { recursive: true, force: true });
102
+ });
103
+
104
+ beforeEach(() => {
105
+ execCalls.length = 0;
106
+ replies = [];
107
+ hangUntilAborted = false;
108
+ });
109
+
110
+ /** A plan JSON with one create, one update and one replace. */
111
+ const PLAN_JSON = JSON.stringify({
112
+ format_version: "1.2",
113
+ resource_changes: [
114
+ { address: "null_resource.a", change: { actions: ["create"] } },
115
+ { address: "null_resource.b", change: { actions: ["update"] } },
116
+ { address: "null_resource.c", change: { actions: ["delete", "create"] } },
117
+ { address: "null_resource.d", change: { actions: ["no-op"] } },
118
+ ],
119
+ });
120
+
121
+ /** Wire the two `show` calls every plan makes. */
122
+ function showReplies(json = PLAN_JSON, text = "Plan: 2 to add, 1 to change, 1 to destroy.\n"): void {
123
+ replies.push({ match: "show -json", reply: { stdout: json, stderr: "" } });
124
+ replies.push({ match: "show -no-color", reply: { stdout: text, stderr: "" } });
125
+ }
126
+
127
+ // ── Pure builders ───────────────────────────────────────────────────────────
128
+
129
+ describe("terraformBinary (#2086)", () => {
130
+ test("defaults to terraform, honours an explicit tofu", () => {
131
+ expect(terraformBinary(undefined)).toBe("terraform");
132
+ expect(terraformBinary({ roots: {} })).toBe("terraform");
133
+ expect(terraformBinary({ binary: "tofu", roots: {} })).toBe("tofu");
134
+ });
135
+ });
136
+
137
+ describe("terraformEnvironment — TF_IN_AUTOMATION on every invocation (#2086)", () => {
138
+ test("always sets TF_IN_AUTOMATION=1", () => {
139
+ expect(terraformEnvironment()).toEqual({ TF_IN_AUTOMATION: "1" });
140
+ });
141
+
142
+ test("a root's workspace is selected through TF_WORKSPACE", () => {
143
+ expect(terraformEnvironment({ workspace: "prod" })).toEqual({
144
+ TF_IN_AUTOMATION: "1",
145
+ TF_WORKSPACE: "prod",
146
+ });
147
+ });
148
+
149
+ test("no workspace means no TF_WORKSPACE key at all", () => {
150
+ expect(terraformEnvironment({}).TF_WORKSPACE).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ describe("terraformInitCommand (#2086)", () => {
155
+ test("carries -input=false", () => {
156
+ expect(terraformInitCommand({ binary: "terraform" })).toBe("terraform init -input=false");
157
+ });
158
+
159
+ test("backendConfig becomes -backend-config=k=v flags, in declaration order", () => {
160
+ expect(
161
+ terraformInitCommand({
162
+ binary: "tofu",
163
+ backendConfig: { bucket: "tf-state", key: "app/terraform.tfstate" },
164
+ }),
165
+ ).toBe("tofu init -input=false -backend-config=bucket=tf-state -backend-config=key=app/terraform.tfstate");
166
+ });
167
+
168
+ test("-upgrade and -reconfigure are opt-in", () => {
169
+ expect(terraformInitCommand({ binary: "terraform", upgrade: true, reconfigure: true })).toBe(
170
+ "terraform init -input=false -upgrade -reconfigure",
171
+ );
172
+ });
173
+
174
+ test("a backend-config value with a space is quoted", () => {
175
+ expect(terraformInitCommand({ binary: "terraform", backendConfig: { path: "my state.tfstate" } })).toBe(
176
+ "terraform init -input=false -backend-config='path=my state.tfstate'",
177
+ );
178
+ });
179
+ });
180
+
181
+ describe("terraformPlanCommand (#2086)", () => {
182
+ test("carries -input=false, -detailed-exitcode and -out", () => {
183
+ expect(terraformPlanCommand({ binary: "terraform", planFile: "chant.tfplan" })).toBe(
184
+ "terraform plan -input=false -detailed-exitcode -out=chant.tfplan",
185
+ );
186
+ });
187
+
188
+ test("varFiles become -var-file flags, in order", () => {
189
+ expect(
190
+ terraformPlanCommand({ binary: "terraform", planFile: "p.tfplan", varFiles: ["base.tfvars", "prod.tfvars"] }),
191
+ ).toBe("terraform plan -input=false -detailed-exitcode -var-file=base.tfvars -var-file=prod.tfvars -out=p.tfplan");
192
+ });
193
+
194
+ test("-destroy is opt-in", () => {
195
+ expect(terraformPlanCommand({ binary: "tofu", planFile: "p", destroy: true })).toContain(" -destroy ");
196
+ });
197
+ });
198
+
199
+ describe("terraformApplyCommand (#2086)", () => {
200
+ test("carries -input=false and applies the saved plan positionally", () => {
201
+ expect(terraformApplyCommand({ binary: "terraform", planFile: "chant.tfplan" })).toBe(
202
+ "terraform apply -input=false chant.tfplan",
203
+ );
204
+ });
205
+ });
206
+
207
+ describe("terraformShowCommand (#2086)", () => {
208
+ test("json and text forms, over a plan file or over state", () => {
209
+ expect(terraformShowCommand({ binary: "terraform", json: true, planFile: "chant.tfplan" })).toBe(
210
+ "terraform show -json chant.tfplan",
211
+ );
212
+ expect(terraformShowCommand({ binary: "terraform", json: false, planFile: "chant.tfplan" })).toBe(
213
+ "terraform show -no-color chant.tfplan",
214
+ );
215
+ expect(terraformShowCommand({ binary: "tofu", json: true })).toBe("tofu show -json");
216
+ });
217
+
218
+ test("no -input flag: `terraform show` rejects it with a flag-parse error", () => {
219
+ // Every other command here carries -input=false. `show` is the exception
220
+ // upstream forces: `terraform show -input=false` fails with "flag provided
221
+ // but not defined: -input". Automation for show rests on TF_IN_AUTOMATION,
222
+ // which terraformEnvironment sets for every call.
223
+ expect(terraformShowCommand({ binary: "terraform", json: true })).not.toContain("-input");
224
+ });
225
+ });
226
+
227
+ describe("quoteArg (#2086)", () => {
228
+ test("leaves ordinary paths and k=v pairs alone", () => {
229
+ expect(quoteArg("prod.tfvars")).toBe("prod.tfvars");
230
+ expect(quoteArg("bucket=tf-state")).toBe("bucket=tf-state");
231
+ expect(quoteArg("./vars/prod.tfvars")).toBe("./vars/prod.tfvars");
232
+ });
233
+
234
+ test("quotes anything with a space or a shell metacharacter", () => {
235
+ expect(quoteArg("my vars.tfvars")).toBe("'my vars.tfvars'");
236
+ expect(quoteArg("a;rm -rf /")).toBe("'a;rm -rf /'");
237
+ expect(quoteArg("it's.tfvars")).toBe(`'it'\\''s.tfvars'`);
238
+ });
239
+ });
240
+
241
+ describe("countPlanChanges (#2086)", () => {
242
+ test("a replace counts as one add and one destroy, as terraform reports it", () => {
243
+ expect(countPlanChanges(JSON.parse(PLAN_JSON))).toEqual({ adds: 2, changes: 1, destroys: 1 });
244
+ });
245
+
246
+ test("a plan with no resource_changes counts zero", () => {
247
+ expect(countPlanChanges({ format_version: "1.2" })).toEqual({ adds: 0, changes: 0, destroys: 0 });
248
+ expect(countPlanChanges(null)).toEqual({ adds: 0, changes: 0, destroys: 0 });
249
+ });
250
+ });
251
+
252
+ // ── Root resolution ─────────────────────────────────────────────────────────
253
+
254
+ describe("root resolution against terraform.roots (#2086)", () => {
255
+ test("dir resolves against the project root, not the cwd", async () => {
256
+ const dir = project({ roots: { app: { dir: "./infra" } } });
257
+ const result = await terraformInit({ root: "app", cwd: dir });
258
+ expect(result.dir).toBe(resolve(dir, "infra"));
259
+ expect(execCalls[0].opts.cwd).toBe(resolve(dir, "infra"));
260
+ });
261
+
262
+ test("an unknown root name fails naming the roots that do exist", async () => {
263
+ const dir = project({ roots: { app: { dir: "./infra" }, data: { dir: "./infra" } } });
264
+ await expect(terraformInit({ root: "nope", cwd: dir })).rejects.toThrow(
265
+ /no root named "nope" in terraform\.roots.*known roots: app, data/s,
266
+ );
267
+ });
268
+
269
+ test("the configured binary drives every command", async () => {
270
+ const dir = project({ binary: "tofu", roots: { app: { dir: "./infra" } } });
271
+ await terraformInit({ root: "app", cwd: dir });
272
+ expect(execCalls[0].cmd.startsWith("tofu init")).toBe(true);
273
+ });
274
+ });
275
+
276
+ // ── terraformInit ───────────────────────────────────────────────────────────
277
+
278
+ describe("terraformInit (#2086)", () => {
279
+ test("runs init with -input=false and TF_IN_AUTOMATION=1", async () => {
280
+ const dir = project({ roots: { app: { dir: "./infra" } } });
281
+ await terraformInit({ root: "app", cwd: dir });
282
+ expect(execCalls).toHaveLength(1);
283
+ expect(execCalls[0].cmd).toBe("terraform init -input=false");
284
+ expect(execCalls[0].opts.env?.TF_IN_AUTOMATION).toBe("1");
285
+ });
286
+
287
+ test("backendConfig from the root becomes -backend-config flags", async () => {
288
+ const dir = project({
289
+ roots: { app: { dir: "./infra", backendConfig: { path: "terraform.tfstate" } } },
290
+ });
291
+ await terraformInit({ root: "app", cwd: dir });
292
+ expect(execCalls[0].cmd).toBe("terraform init -input=false -backend-config=path=terraform.tfstate");
293
+ });
294
+
295
+ test("the root's workspace reaches the child as TF_WORKSPACE", async () => {
296
+ const dir = project({ roots: { app: { dir: "./infra", workspace: "prod" } } });
297
+ const result = await terraformInit({ root: "app", cwd: dir });
298
+ expect(execCalls[0].opts.env?.TF_WORKSPACE).toBe("prod");
299
+ expect(result.workspace).toBe("prod");
300
+ });
301
+ });
302
+
303
+ // ── terraformPlan ───────────────────────────────────────────────────────────
304
+
305
+ describe("terraformPlan — the -detailed-exitcode mapping (#2086)", () => {
306
+ test("exit 0: no changes, and both show renders come back", async () => {
307
+ const dir = project({ roots: { app: { dir: "./infra" } } });
308
+ showReplies();
309
+ const result = await terraformPlan({ root: "app", cwd: dir });
310
+ expect(result.changed).toBe(false);
311
+ expect(result.planFile).toBe(DEFAULT_PLAN_FILE);
312
+ expect(result.text).toContain("Plan: 2 to add");
313
+ expect(result.json).toEqual(JSON.parse(PLAN_JSON));
314
+ expect(result).toMatchObject({ adds: 2, changes: 1, destroys: 1 });
315
+ expect(execCalls.map((c) => c.cmd)).toEqual([
316
+ "terraform plan -input=false -detailed-exitcode -out=chant.tfplan",
317
+ "terraform show -json chant.tfplan",
318
+ "terraform show -no-color chant.tfplan",
319
+ ]);
320
+ });
321
+
322
+ test("exit 2: changed, and the plan is still shown", async () => {
323
+ const dir = project({ roots: { app: { dir: "./infra" } } });
324
+ replies.push({ match: "plan ", reply: execError(2, "", "Plan: 2 to add...\n") });
325
+ showReplies();
326
+ const result = await terraformPlan({ root: "app", cwd: dir });
327
+ expect(result.changed).toBe(true);
328
+ expect(result.destroys).toBe(1);
329
+ });
330
+
331
+ test("exit 1: throws with terraform's stderr attached, and never shows the plan", async () => {
332
+ const dir = project({ roots: { app: { dir: "./infra" } } });
333
+ replies.push({
334
+ match: "plan ",
335
+ reply: execError(1, 'Error: Reference to undeclared input variable\n\n on main.tf line 3'),
336
+ });
337
+ showReplies();
338
+ await expect(terraformPlan({ root: "app", cwd: dir })).rejects.toThrow(
339
+ /terraform plan failed in .*infra \(exit 1\)[\s\S]*Reference to undeclared input variable/,
340
+ );
341
+ expect(execCalls).toHaveLength(1);
342
+ });
343
+
344
+ test("varFiles and TF_IN_AUTOMATION reach the plan invocation", async () => {
345
+ const dir = project({
346
+ roots: { app: { dir: "./infra", varFiles: ["prod.tfvars"], workspace: "prod" } },
347
+ });
348
+ showReplies();
349
+ await terraformPlan({ root: "app", cwd: dir, planFile: "custom.tfplan" });
350
+ expect(execCalls[0].cmd).toBe(
351
+ "terraform plan -input=false -detailed-exitcode -var-file=prod.tfvars -out=custom.tfplan",
352
+ );
353
+ for (const call of execCalls) {
354
+ expect(call.opts.env?.TF_IN_AUTOMATION).toBe("1");
355
+ expect(call.opts.env?.TF_WORKSPACE).toBe("prod");
356
+ }
357
+ });
358
+
359
+ test("-destroy plans the removal of the whole root", async () => {
360
+ const dir = project({ roots: { app: { dir: "./infra" } } });
361
+ showReplies();
362
+ await terraformPlan({ root: "app", cwd: dir, destroy: true });
363
+ expect(execCalls[0].cmd).toContain(" -destroy ");
364
+ });
365
+ });
366
+
367
+ // ── terraformApply ──────────────────────────────────────────────────────────
368
+
369
+ describe("terraformApply — saved plans only (#2086)", () => {
370
+ test("applies the named plan file with -input=false and TF_IN_AUTOMATION=1", async () => {
371
+ const dir = project({ roots: { app: { dir: "./infra" } } });
372
+ const result = await terraformApply({ root: "app", cwd: dir, planFile: "chant.tfplan" });
373
+ expect(execCalls).toHaveLength(1);
374
+ expect(execCalls[0].cmd).toBe("terraform apply -input=false chant.tfplan");
375
+ expect(execCalls[0].opts.env?.TF_IN_AUTOMATION).toBe("1");
376
+ expect(result).toMatchObject({ planFile: "chant.tfplan", applied: true });
377
+ });
378
+
379
+ test("no planFile: refused before the config is even read, with no bare apply run", async () => {
380
+ const dir = project({ roots: { app: { dir: "./infra" } } });
381
+ await expect(
382
+ terraformApply({ root: "app", cwd: dir } as unknown as { root: string; cwd: string; planFile: string }),
383
+ ).rejects.toThrow(/planFile is required.*no bare-apply mode/s);
384
+ expect(execCalls).toHaveLength(0);
385
+ });
386
+
387
+ test("an empty planFile is refused too", async () => {
388
+ const dir = project({ roots: { app: { dir: "./infra" } } });
389
+ await expect(terraformApply({ root: "app", cwd: dir, planFile: " " })).rejects.toThrow(
390
+ /planFile is required/,
391
+ );
392
+ expect(execCalls).toHaveLength(0);
393
+ });
394
+ });
395
+
396
+ // ── terraformShow ───────────────────────────────────────────────────────────
397
+
398
+ describe("terraformShow (#2086)", () => {
399
+ test("over state: json plus text, zero change counts, TF_IN_AUTOMATION=1", async () => {
400
+ const dir = project({ roots: { app: { dir: "./infra" } } });
401
+ replies.push({ match: "show -json", reply: { stdout: '{"values":{}}', stderr: "" } });
402
+ replies.push({ match: "show -no-color", reply: { stdout: "No state.\n", stderr: "" } });
403
+ const result = await terraformShow({ root: "app", cwd: dir });
404
+ expect(execCalls.map((c) => c.cmd)).toEqual(["terraform show -json", "terraform show -no-color"]);
405
+ expect(result.source).toBe("state");
406
+ expect(result).toMatchObject({ adds: 0, changes: 0, destroys: 0 });
407
+ expect(result.text).toBe("No state.\n");
408
+ expect(execCalls[0].opts.env?.TF_IN_AUTOMATION).toBe("1");
409
+ });
410
+
411
+ test("over a plan file: counts the change set, so a gate can report destroys", async () => {
412
+ const dir = project({ roots: { app: { dir: "./infra" } } });
413
+ showReplies();
414
+ const result = await terraformShow({ root: "app", cwd: dir, planFile: "chant.tfplan" });
415
+ expect(result.source).toBe("plan");
416
+ expect(result.planFile).toBe("chant.tfplan");
417
+ expect(result.destroys).toBe(1);
418
+ expect(execCalls[0].cmd).toBe("terraform show -json chant.tfplan");
419
+ });
420
+ });
421
+
422
+ // ── Cancellation ────────────────────────────────────────────────────────────
423
+
424
+ describe("every activity forwards the AbortSignal (#2086)", () => {
425
+ const cases: Array<[string, (dir: string, signal: AbortSignal) => Promise<unknown>]> = [
426
+ ["terraformInit", (dir, signal) => terraformInit({ root: "app", cwd: dir }, signal)],
427
+ ["terraformPlan", (dir, signal) => terraformPlan({ root: "app", cwd: dir }, signal)],
428
+ ["terraformApply", (dir, signal) => terraformApply({ root: "app", cwd: dir, planFile: "p" }, signal)],
429
+ ["terraformShow", (dir, signal) => terraformShow({ root: "app", cwd: dir }, signal)],
430
+ ];
431
+
432
+ for (const [name, invoke] of cases) {
433
+ test(`${name}: aborting mid-run rejects, and the signal reached the child`, async () => {
434
+ const dir = project({ roots: { app: { dir: "./infra" } } });
435
+ hangUntilAborted = true;
436
+ const controller = new AbortController();
437
+ const pending = invoke(dir, controller.signal);
438
+ // Let the activity read the config and start the child before aborting.
439
+ await vi.waitFor(() => expect(execCalls.length).toBeGreaterThan(0));
440
+ expect(execCalls[0].opts.signal).toBe(controller.signal);
441
+ controller.abort();
442
+ await expect(pending).rejects.toThrow(/aborted/i);
443
+ });
444
+ }
445
+ });