@aetherwing/fcp-terraform 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Scott Meyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # fcp-terraform
2
+
3
+ MCP server for Terraform HCL generation through intent-level commands.
4
+
5
+ ## What It Does
6
+
7
+ fcp-terraform lets LLMs build Terraform configurations by describing infrastructure intent -- resources, data sources, variables, outputs -- and renders them into valid HCL. Instead of writing raw HCL syntax, the LLM sends operations like `add resource aws_instance web ami:"ami-0c55b159" instance_type:t2.micro` and fcp-terraform manages the semantic model, dependency graph, and serialization. Built on the [FCP](https://github.com/aetherwing-io/fcp) framework.
8
+
9
+ ## Quick Example
10
+
11
+ ```
12
+ terraform_session('new "Main Infrastructure"')
13
+
14
+ terraform([
15
+ 'add resource aws_instance web ami:"ami-0c55b159" instance_type:t2.micro',
16
+ 'add resource aws_s3_bucket assets bucket:"my-assets"',
17
+ 'add variable region default:"us-east-1" type:string',
18
+ 'add output instance_ip value:"aws_instance.web.public_ip"',
19
+ ])
20
+
21
+ terraform_query('plan')
22
+ ```
23
+
24
+ The `plan` query produces:
25
+
26
+ ```hcl
27
+ variable "region" {
28
+ type = string
29
+ default = "us-east-1"
30
+ }
31
+
32
+ resource "aws_instance" "web" {
33
+ ami = "ami-0c55b159"
34
+ instance_type = "t2.micro"
35
+ }
36
+
37
+ resource "aws_s3_bucket" "assets" {
38
+ bucket = "my-assets"
39
+ }
40
+
41
+ output "instance_ip" {
42
+ value = aws_instance.web.public_ip
43
+ }
44
+ ```
45
+
46
+ ### Available MCP Tools
47
+
48
+ | Tool | Purpose |
49
+ |------|---------|
50
+ | `terraform(ops)` | Batch mutations -- add, set, remove, connect, nest, label, style |
51
+ | `terraform_query(q)` | Inspect the config -- map, list, describe, plan, graph, validate, find |
52
+ | `terraform_session(action)` | Lifecycle -- new, open, save, checkpoint, undo, redo |
53
+ | `terraform_help()` | Full reference card |
54
+
55
+ ### Supported Block Types
56
+
57
+ | Verb | Syntax |
58
+ |------|--------|
59
+ | `add resource` | `add resource TYPE LABEL [key:value...]` |
60
+ | `add provider` | `add provider PROVIDER [region:R] [key:value...]` |
61
+ | `add variable` | `add variable NAME [type:T] [default:V] [description:D]` |
62
+ | `add output` | `add output NAME value:EXPR [description:D]` |
63
+ | `add data` | `add data TYPE LABEL [key:value...]` |
64
+ | `add module` | `add module LABEL source:PATH [key:value...]` |
65
+ | `connect` | `connect SRC -> TGT [label:TEXT]` |
66
+ | `set` | `set LABEL key:value [key:value...]` |
67
+ | `nest` | `nest LABEL BLOCK_TYPE [key:value...]` |
68
+ | `remove` | `remove LABEL` or `remove @SELECTOR` |
69
+
70
+ ### Selectors
71
+
72
+ ```
73
+ @type:aws_instance All resources of a given type
74
+ @provider:aws All blocks from a given provider
75
+ @kind:resource All blocks of kind (resource, variable, output, data)
76
+ @tag:KEY or @tag:KEY=VAL Blocks matching a tag
77
+ @all All blocks
78
+ ```
79
+
80
+ ## Installation
81
+
82
+ Requires Node >= 18.
83
+
84
+ ```bash
85
+ npm install @aetherwing/fcp-terraform
86
+ ```
87
+
88
+ ### MCP Client Configuration
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "terraform": {
94
+ "command": "node",
95
+ "args": ["node_modules/@aetherwing/fcp-terraform/dist/index.js"]
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ## Architecture
102
+
103
+ 3-layer architecture:
104
+
105
+ ```
106
+ MCP Server (Intent Layer)
107
+ src/server/ -- Parses op strings, resolves refs, dispatches
108
+ |
109
+ Semantic Model (Domain)
110
+ src/model/ -- In-memory Terraform graph (resources, data, variables, outputs)
111
+ src/types/ -- Core TypeScript interfaces
112
+ |
113
+ Serialization (HCL)
114
+ src/hcl.ts -- Semantic model -> HCL text output
115
+ ```
116
+
117
+ Supporting modules:
118
+
119
+ - `src/ops.ts` -- Operation string parser
120
+ - `src/verbs.ts` -- Verb specs and reference card
121
+ - `src/queries.ts` -- Query dispatcher (map, plan, graph, validate, etc.)
122
+ - `src/adapter.ts` -- FCP core adapter
123
+
124
+ Provider is auto-detected from resource type prefixes (`aws_`, `google_`, `azurerm_`).
125
+
126
+ ## Worked Example: AWS Production Web Stack
127
+
128
+ A realistic deployment showing how operations compose. This example creates a VPC with subnets, EC2, RDS, S3, IAM, and security groups -- 13 resources total.
129
+
130
+ ```
131
+ terraform_session('new "Acme Corp Web Stack"')
132
+
133
+ # Provider + Variables
134
+ terraform([
135
+ 'add provider aws region:us-east-1',
136
+ 'add variable environment type:string default:"production" description:"Deployment environment"',
137
+ 'add variable project_name type:string default:"acme-web" description:"Project name"',
138
+ 'add variable vpc_cidr type:string default:"10.0.0.0/16" description:"VPC CIDR block"',
139
+ 'add variable instance_type type:string default:"t3.medium" description:"EC2 instance type"',
140
+ 'add variable db_instance_class type:string default:"db.t3.medium" description:"RDS instance class"',
141
+ ])
142
+
143
+ # Networking
144
+ terraform([
145
+ 'add resource aws_vpc main cidr_block:var.vpc_cidr enable_dns_support:true enable_dns_hostnames:true',
146
+ 'add resource aws_subnet public_a vpc_id:aws_vpc.main.id cidr_block:"10.0.1.0/24" map_public_ip_on_launch:true',
147
+ 'add resource aws_subnet public_b vpc_id:aws_vpc.main.id cidr_block:"10.0.2.0/24" map_public_ip_on_launch:true',
148
+ 'add resource aws_internet_gateway igw vpc_id:aws_vpc.main.id',
149
+ 'add resource aws_route_table public vpc_id:aws_vpc.main.id',
150
+ ])
151
+
152
+ # Nested blocks for route table and security groups
153
+ terraform([
154
+ 'nest public route cidr_block:"0.0.0.0/0" gateway_id:aws_internet_gateway.igw.id',
155
+ 'add resource aws_security_group web name:"${var.project_name}-web-sg" vpc_id:aws_vpc.main.id',
156
+ 'nest web ingress from_port:80 to_port:80 protocol:"tcp"',
157
+ 'nest web ingress from_port:443 to_port:443 protocol:"tcp"',
158
+ 'nest web egress from_port:0 to_port:0 protocol:"-1"',
159
+ ])
160
+
161
+ # Compute + Database
162
+ terraform([
163
+ 'add resource aws_instance webserver ami:"ami-0c55b159" instance_type:var.instance_type subnet_id:aws_subnet.public_a.id',
164
+ 'nest webserver root_block_device volume_size:20 volume_type:"gp3"',
165
+ 'add resource aws_db_subnet_group dbsubnet name:"${var.project_name}-db-subnet"',
166
+ 'set dbsubnet subnet_ids:"[aws_subnet.public_a.id,aws_subnet.public_b.id]"',
167
+ 'add resource aws_db_instance rds engine:"postgresql" instance_class:var.db_instance_class allocated_storage:20',
168
+ 'add resource aws_s3_bucket assets bucket:"${var.project_name}-assets-${var.environment}"',
169
+ ])
170
+
171
+ # Outputs
172
+ terraform([
173
+ 'add output vpc_id value:aws_vpc.main.id',
174
+ 'add output web_ip value:aws_instance.webserver.public_ip',
175
+ 'add output db_endpoint value:aws_db_instance.rds.endpoint',
176
+ ])
177
+
178
+ # Tags (all resources at once)
179
+ terraform([
180
+ 'style main tags:"Name=${var.project_name}-vpc,Environment=${var.environment}"',
181
+ 'style public_a tags:"Name=${var.project_name}-public-a,Environment=${var.environment}"',
182
+ 'style public_b tags:"Name=${var.project_name}-public-b,Environment=${var.environment}"',
183
+ 'style igw tags:"Name=${var.project_name}-igw,Environment=${var.environment}"',
184
+ 'style webserver tags:"Name=${var.project_name}-web,Environment=${var.environment}"',
185
+ 'style rds tags:"Name=${var.project_name}-db,Environment=${var.environment}"',
186
+ 'style assets tags:"Name=${var.project_name}-assets,Environment=${var.environment}"',
187
+ ])
188
+
189
+ # Day-2 modifications
190
+ terraform([
191
+ 'label webserver app_server', # Rename
192
+ 'set instance_type default:"t3.large"', # Change default
193
+ 'remove assets', # Replace S3 bucket
194
+ 'add resource aws_s3_bucket static_assets bucket:"${var.project_name}-static-${var.environment}"',
195
+ ])
196
+
197
+ terraform_query('plan') # Export final HCL
198
+ ```
199
+
200
+ ## Capability Matrix
201
+
202
+ | Capability | Status | Notes |
203
+ |------------|--------|-------|
204
+ | Resources, data sources, modules | Supported | All Terraform block types |
205
+ | Variables with types and defaults | Supported | string, number, bool, list, map, object |
206
+ | Outputs | Supported | Simple value expressions |
207
+ | Nested blocks (ingress, egress, route, filter) | Supported | Via `nest` verb |
208
+ | Tags | Supported | Via `style` verb with `tags:"K=V,K2=V2"` |
209
+ | Rename blocks | Partial | `label` renames but does not cascade references |
210
+ | Selectors for bulk operations | Partial | `remove @selector` works; `style @selector` not yet supported |
211
+ | `jsonencode()` / HCL functions | Not supported | Complex expressions containing colons conflict with the DSL tokenizer |
212
+ | Nested block removal/editing | Not supported | Nested blocks are append-only |
213
+ | `count` / `for_each` meta-arguments | Not supported | `set` cannot modify meta-arguments |
214
+
215
+ ## Known Limitations
216
+
217
+ The following issues were identified through competitive validation against raw HCL writing. They are tracked for resolution.
218
+
219
+ **HCL Serialization**
220
+
221
+ - **List values in nested blocks render without quotes.** Attributes like `cidr_blocks`, `security_groups`, and `values` in filter/ingress/egress blocks output `[0.0.0.0/0]` instead of `["0.0.0.0/0"]`. This produces invalid HCL. Workaround: post-process the output or use `terraform_query('plan')` output as a starting point for manual fixes.
222
+
223
+ - **String values may lose their type.** Setting `engine_version:"15"` renders as `engine_version = 15` (number). The serializer does not preserve explicit string quoting for numeric-looking values.
224
+
225
+ - **Output values with interpolation render unquoted.** An output `value:"VPC: ${aws_vpc.main.id}"` renders without surrounding quotes.
226
+
227
+ **Labels**
228
+
229
+ - **Labels are globally unique.** You cannot use `main` for both `aws_vpc.main` and `aws_internet_gateway.main` even though Terraform allows this (labels are scoped per type). Use distinct labels like `igw`, `rds`, `dbsubnet`.
230
+
231
+ - **`label` rename breaks the lookup index.** After renaming a block with `label old new`, subsequent `style new` or `set new` calls may return "block not found". The internal index is not rebuilt after rename.
232
+
233
+ **Nested Blocks**
234
+
235
+ - **Cannot remove or edit specific nested blocks.** Once added via `nest`, a nested block (ingress rule, route, filter) cannot be individually removed or modified. If a `nest` call produces an error but partially succeeds, the ghost block persists.
236
+
237
+ **Expressions**
238
+
239
+ - **JSON with colons conflicts with the DSL tokenizer.** IAM `assume_role_policy` or similar JSON-containing attributes are parsed as key:value pairs by the FCP tokenizer, producing bare JSON objects instead of quoted strings or `jsonencode()` calls.
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ npm install
245
+ npm run build # tsc
246
+ npm test # vitest, 138 tests
247
+ ```
248
+
249
+ ## License
250
+
251
+ MIT
252
+
@@ -0,0 +1,14 @@
1
+ import type { FcpDomainAdapter, OpResult, ParsedOp } from "@aetherwing/fcp-core";
2
+ import type { EventLog } from "@aetherwing/fcp-core";
3
+ import type { TerraformConfig, TerraformEvent } from "./types.js";
4
+ export declare class TerraformAdapter implements FcpDomainAdapter<TerraformConfig, TerraformEvent> {
5
+ createEmpty(title: string, params: Record<string, string>): TerraformConfig;
6
+ serialize(model: TerraformConfig): string;
7
+ deserialize(data: Buffer | string): TerraformConfig;
8
+ rebuildIndices(model: TerraformConfig): void;
9
+ getDigest(model: TerraformConfig): string;
10
+ dispatchOp(op: ParsedOp, model: TerraformConfig, log: EventLog<TerraformEvent>): OpResult;
11
+ dispatchQuery(query: string, model: TerraformConfig): string | Promise<string>;
12
+ reverseEvent(event: TerraformEvent, model: TerraformConfig): void;
13
+ replayEvent(event: TerraformEvent, model: TerraformConfig): void;
14
+ }
@@ -0,0 +1,266 @@
1
+ import { createEmptyConfig, rebuildLabelIndex, findByKind, addBlock, removeBlock, addConnection, removeConnection, createBlock, } from "./model.js";
2
+ import { dispatchOp } from "./ops.js";
3
+ import { dispatchQuery } from "./queries.js";
4
+ // Keep a reference to the event log for queries that need it
5
+ let currentEventLog;
6
+ export class TerraformAdapter {
7
+ createEmpty(title, params) {
8
+ const config = createEmptyConfig(title);
9
+ // Auto-add provider if specified
10
+ if (params["provider"]) {
11
+ const providerName = params["provider"];
12
+ const providerParams = {};
13
+ if (params["region"])
14
+ providerParams["region"] = params["region"];
15
+ const block = createBlock("provider", providerName, providerName, providerParams);
16
+ block.provider = providerName;
17
+ addBlock(config, block);
18
+ }
19
+ rebuildLabelIndex(config);
20
+ return config;
21
+ }
22
+ serialize(model) {
23
+ return JSON.stringify(configToJson(model), null, 2);
24
+ }
25
+ deserialize(data) {
26
+ const json = JSON.parse(typeof data === "string" ? data : data.toString());
27
+ return jsonToConfig(json);
28
+ }
29
+ rebuildIndices(model) {
30
+ rebuildLabelIndex(model);
31
+ }
32
+ getDigest(model) {
33
+ const resources = findByKind(model, "resource").length;
34
+ const variables = findByKind(model, "variable").length;
35
+ const outputs = findByKind(model, "output").length;
36
+ const providers = findByKind(model, "provider").map((p) => p.label);
37
+ const provStr = providers.length > 0 ? ` | ${providers.join(",")}` : "";
38
+ return `[${resources}r ${variables}v ${outputs}o ${model.connections.size}c${provStr}]`;
39
+ }
40
+ dispatchOp(op, model, log) {
41
+ currentEventLog = log;
42
+ return dispatchOp(op, model, log);
43
+ }
44
+ dispatchQuery(query, model) {
45
+ return dispatchQuery(query, model, currentEventLog);
46
+ }
47
+ reverseEvent(event, model) {
48
+ switch (event.type) {
49
+ case "block_added":
50
+ removeBlock(model, event.block.id);
51
+ break;
52
+ case "block_removed": {
53
+ const restored = restoreBlock(event.block);
54
+ addBlock(model, restored);
55
+ break;
56
+ }
57
+ case "attribute_set": {
58
+ const block = model.blocks.get(event.blockId);
59
+ if (!block)
60
+ return;
61
+ if (event.before === null) {
62
+ block.attributes.delete(event.key);
63
+ }
64
+ else {
65
+ block.attributes.set(event.key, structuredClone(event.before));
66
+ }
67
+ break;
68
+ }
69
+ case "attribute_removed": {
70
+ const block = model.blocks.get(event.blockId);
71
+ if (!block)
72
+ return;
73
+ block.attributes.set(event.key, structuredClone(event.before));
74
+ break;
75
+ }
76
+ case "connection_added":
77
+ removeConnection(model, event.connection.id);
78
+ break;
79
+ case "connection_removed":
80
+ addConnection(model, structuredClone(event.connection));
81
+ break;
82
+ case "tag_set": {
83
+ const block = model.blocks.get(event.blockId);
84
+ if (!block)
85
+ return;
86
+ if (event.before === null) {
87
+ block.tags.delete(event.key);
88
+ }
89
+ else {
90
+ block.tags.set(event.key, event.before);
91
+ }
92
+ break;
93
+ }
94
+ case "tag_removed": {
95
+ const block = model.blocks.get(event.blockId);
96
+ if (!block)
97
+ return;
98
+ block.tags.set(event.key, event.before);
99
+ break;
100
+ }
101
+ case "nested_block_added": {
102
+ const block = model.blocks.get(event.blockId);
103
+ if (!block)
104
+ return;
105
+ block.nestedBlocks = block.nestedBlocks.filter((nb) => nb.id !== event.nestedBlock.id);
106
+ break;
107
+ }
108
+ case "nested_block_removed": {
109
+ const block = model.blocks.get(event.blockId);
110
+ if (!block)
111
+ return;
112
+ block.nestedBlocks.push(restoreNestedBlock(event.nestedBlock));
113
+ break;
114
+ }
115
+ case "block_renamed": {
116
+ const block = model.blocks.get(event.blockId);
117
+ if (block)
118
+ block.label = event.before;
119
+ break;
120
+ }
121
+ case "title_changed":
122
+ model.title = event.before;
123
+ break;
124
+ }
125
+ rebuildLabelIndex(model);
126
+ }
127
+ replayEvent(event, model) {
128
+ switch (event.type) {
129
+ case "block_added": {
130
+ const restored = restoreBlock(event.block);
131
+ addBlock(model, restored);
132
+ break;
133
+ }
134
+ case "block_removed":
135
+ removeBlock(model, event.block.id);
136
+ break;
137
+ case "attribute_set": {
138
+ const block = model.blocks.get(event.blockId);
139
+ if (!block)
140
+ return;
141
+ block.attributes.set(event.key, structuredClone(event.after));
142
+ break;
143
+ }
144
+ case "attribute_removed": {
145
+ const block = model.blocks.get(event.blockId);
146
+ if (!block)
147
+ return;
148
+ block.attributes.delete(event.key);
149
+ break;
150
+ }
151
+ case "connection_added":
152
+ addConnection(model, structuredClone(event.connection));
153
+ break;
154
+ case "connection_removed":
155
+ removeConnection(model, event.connection.id);
156
+ break;
157
+ case "tag_set": {
158
+ const block = model.blocks.get(event.blockId);
159
+ if (!block)
160
+ return;
161
+ block.tags.set(event.key, event.after);
162
+ break;
163
+ }
164
+ case "tag_removed": {
165
+ const block = model.blocks.get(event.blockId);
166
+ if (!block)
167
+ return;
168
+ block.tags.delete(event.key);
169
+ break;
170
+ }
171
+ case "nested_block_added": {
172
+ const block = model.blocks.get(event.blockId);
173
+ if (!block)
174
+ return;
175
+ block.nestedBlocks.push(restoreNestedBlock(event.nestedBlock));
176
+ break;
177
+ }
178
+ case "nested_block_removed": {
179
+ const block = model.blocks.get(event.blockId);
180
+ if (!block)
181
+ return;
182
+ block.nestedBlocks = block.nestedBlocks.filter((nb) => nb.id !== event.nestedBlock.id);
183
+ break;
184
+ }
185
+ case "block_renamed": {
186
+ const block = model.blocks.get(event.blockId);
187
+ if (block)
188
+ block.label = event.after;
189
+ break;
190
+ }
191
+ case "title_changed":
192
+ model.title = event.after;
193
+ break;
194
+ }
195
+ rebuildLabelIndex(model);
196
+ }
197
+ }
198
+ // ── Serialization helpers ─────────────────────────────
199
+ function configToJson(config) {
200
+ return {
201
+ id: config.id,
202
+ title: config.title,
203
+ filePath: config.filePath,
204
+ blockOrder: config.blockOrder,
205
+ blocks: Object.fromEntries([...config.blocks.entries()].map(([id, block]) => [id, blockToJson(block)])),
206
+ connections: Object.fromEntries([...config.connections.entries()].map(([id, conn]) => [id, conn])),
207
+ };
208
+ }
209
+ function blockToJson(block) {
210
+ return {
211
+ ...block,
212
+ attributes: Object.fromEntries(block.attributes),
213
+ tags: Object.fromEntries(block.tags),
214
+ nestedBlocks: block.nestedBlocks.map((nb) => ({
215
+ id: nb.id,
216
+ type: nb.type,
217
+ attributes: Object.fromEntries(nb.attributes),
218
+ })),
219
+ };
220
+ }
221
+ function jsonToConfig(json) {
222
+ const blocks = new Map();
223
+ const jsonBlocks = json["blocks"];
224
+ for (const [id, jb] of Object.entries(jsonBlocks)) {
225
+ blocks.set(id, restoreBlock(jb));
226
+ }
227
+ const connections = new Map();
228
+ const jsonConns = json["connections"];
229
+ for (const [id, conn] of Object.entries(jsonConns)) {
230
+ connections.set(id, conn);
231
+ }
232
+ return {
233
+ id: json["id"],
234
+ title: json["title"],
235
+ filePath: json["filePath"],
236
+ blocks,
237
+ connections,
238
+ blockOrder: json["blockOrder"],
239
+ };
240
+ }
241
+ function restoreBlock(data) {
242
+ const attrs = data.attributes instanceof Map
243
+ ? data.attributes
244
+ : new Map(Object.entries(data.attributes));
245
+ const tags = data.tags instanceof Map
246
+ ? data.tags
247
+ : new Map(Object.entries(data.tags));
248
+ const nestedBlocks = (data.nestedBlocks ?? []).map(restoreNestedBlock);
249
+ return {
250
+ id: data.id,
251
+ kind: data.kind,
252
+ label: data.label,
253
+ fullType: data.fullType,
254
+ provider: data.provider,
255
+ attributes: attrs,
256
+ nestedBlocks,
257
+ tags,
258
+ meta: data.meta ?? { dependsOn: [] },
259
+ };
260
+ }
261
+ function restoreNestedBlock(data) {
262
+ const attrs = data.attributes instanceof Map
263
+ ? data.attributes
264
+ : new Map(Object.entries(data.attributes));
265
+ return { id: data.id, type: data.type, attributes: attrs };
266
+ }
package/dist/hcl.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { TerraformConfig } from "./types.js";
2
+ /**
3
+ * Serialize a TerraformConfig to valid HCL.
4
+ */
5
+ export declare function serializeToHcl(config: TerraformConfig): string;