@intentius/chant-lexicon-gcp 0.0.15

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 (122) hide show
  1. package/dist/integrity.json +36 -0
  2. package/dist/manifest.json +12 -0
  3. package/dist/meta.json +10919 -0
  4. package/dist/rules/gcp-helpers.ts +117 -0
  5. package/dist/rules/hardcoded-project.ts +58 -0
  6. package/dist/rules/hardcoded-region.ts +56 -0
  7. package/dist/rules/public-iam.ts +43 -0
  8. package/dist/rules/wgc101.ts +56 -0
  9. package/dist/rules/wgc102.ts +35 -0
  10. package/dist/rules/wgc103.ts +45 -0
  11. package/dist/rules/wgc104.ts +42 -0
  12. package/dist/rules/wgc105.ts +46 -0
  13. package/dist/rules/wgc106.ts +36 -0
  14. package/dist/rules/wgc107.ts +39 -0
  15. package/dist/rules/wgc108.ts +41 -0
  16. package/dist/rules/wgc109.ts +39 -0
  17. package/dist/rules/wgc110.ts +38 -0
  18. package/dist/rules/wgc111.ts +54 -0
  19. package/dist/rules/wgc112.ts +56 -0
  20. package/dist/rules/wgc113.ts +42 -0
  21. package/dist/rules/wgc201.ts +36 -0
  22. package/dist/rules/wgc202.ts +39 -0
  23. package/dist/rules/wgc203.ts +44 -0
  24. package/dist/rules/wgc204.ts +39 -0
  25. package/dist/rules/wgc301.ts +34 -0
  26. package/dist/rules/wgc302.ts +34 -0
  27. package/dist/rules/wgc303.ts +37 -0
  28. package/dist/skills/chant-gcp-patterns.md +367 -0
  29. package/dist/skills/chant-gcp-security.md +276 -0
  30. package/dist/skills/chant-gcp.md +108 -0
  31. package/dist/types/index.d.ts +26529 -0
  32. package/package.json +35 -0
  33. package/src/actions/index.ts +52 -0
  34. package/src/codegen/docs-cli.ts +7 -0
  35. package/src/codegen/docs.ts +820 -0
  36. package/src/codegen/generate-cli.ts +24 -0
  37. package/src/codegen/generate.ts +252 -0
  38. package/src/codegen/naming.test.ts +49 -0
  39. package/src/codegen/naming.ts +132 -0
  40. package/src/codegen/package.ts +66 -0
  41. package/src/composites/cloud-function.ts +117 -0
  42. package/src/composites/cloud-run-service.ts +124 -0
  43. package/src/composites/cloud-sql-instance.ts +126 -0
  44. package/src/composites/composites.test.ts +432 -0
  45. package/src/composites/gcs-bucket.ts +111 -0
  46. package/src/composites/gke-cluster.ts +125 -0
  47. package/src/composites/index.ts +20 -0
  48. package/src/composites/managed-certificate.ts +79 -0
  49. package/src/composites/private-service.ts +95 -0
  50. package/src/composites/pubsub-pipeline.ts +102 -0
  51. package/src/composites/secure-project.ts +128 -0
  52. package/src/composites/vpc-network.ts +165 -0
  53. package/src/coverage.test.ts +27 -0
  54. package/src/coverage.ts +51 -0
  55. package/src/default-labels.test.ts +111 -0
  56. package/src/default-labels.ts +93 -0
  57. package/src/generated/index.d.ts +26529 -0
  58. package/src/generated/index.ts +1723 -0
  59. package/src/generated/lexicon-gcp.json +10919 -0
  60. package/src/generated/runtime.ts +4 -0
  61. package/src/import/generator.test.ts +125 -0
  62. package/src/import/generator.ts +82 -0
  63. package/src/import/parser.test.ts +167 -0
  64. package/src/import/parser.ts +80 -0
  65. package/src/import/roundtrip.test.ts +66 -0
  66. package/src/index.ts +54 -0
  67. package/src/lint/post-synth/gcp-helpers.ts +117 -0
  68. package/src/lint/post-synth/index.ts +20 -0
  69. package/src/lint/post-synth/post-synth.test.ts +693 -0
  70. package/src/lint/post-synth/wgc101.ts +56 -0
  71. package/src/lint/post-synth/wgc102.ts +35 -0
  72. package/src/lint/post-synth/wgc103.ts +45 -0
  73. package/src/lint/post-synth/wgc104.ts +42 -0
  74. package/src/lint/post-synth/wgc105.ts +46 -0
  75. package/src/lint/post-synth/wgc106.ts +36 -0
  76. package/src/lint/post-synth/wgc107.ts +39 -0
  77. package/src/lint/post-synth/wgc108.ts +41 -0
  78. package/src/lint/post-synth/wgc109.ts +39 -0
  79. package/src/lint/post-synth/wgc110.ts +38 -0
  80. package/src/lint/post-synth/wgc111.ts +54 -0
  81. package/src/lint/post-synth/wgc112.ts +56 -0
  82. package/src/lint/post-synth/wgc113.ts +42 -0
  83. package/src/lint/post-synth/wgc201.ts +36 -0
  84. package/src/lint/post-synth/wgc202.ts +39 -0
  85. package/src/lint/post-synth/wgc203.ts +44 -0
  86. package/src/lint/post-synth/wgc204.ts +39 -0
  87. package/src/lint/post-synth/wgc301.ts +34 -0
  88. package/src/lint/post-synth/wgc302.ts +34 -0
  89. package/src/lint/post-synth/wgc303.ts +37 -0
  90. package/src/lint/rules/hardcoded-project.ts +58 -0
  91. package/src/lint/rules/hardcoded-region.ts +56 -0
  92. package/src/lint/rules/index.ts +3 -0
  93. package/src/lint/rules/public-iam.ts +43 -0
  94. package/src/lint/rules/rules.test.ts +63 -0
  95. package/src/lsp/completions.test.ts +67 -0
  96. package/src/lsp/completions.ts +17 -0
  97. package/src/lsp/hover.test.ts +66 -0
  98. package/src/lsp/hover.ts +54 -0
  99. package/src/package-cli.ts +24 -0
  100. package/src/plugin.test.ts +250 -0
  101. package/src/plugin.ts +405 -0
  102. package/src/pseudo.test.ts +40 -0
  103. package/src/pseudo.ts +19 -0
  104. package/src/serializer.test.ts +250 -0
  105. package/src/serializer.ts +232 -0
  106. package/src/skills/chant-gcp-patterns.md +367 -0
  107. package/src/skills/chant-gcp-security.md +276 -0
  108. package/src/skills/chant-gcp.md +108 -0
  109. package/src/spec/fetch.test.ts +16 -0
  110. package/src/spec/fetch.ts +121 -0
  111. package/src/spec/parse.test.ts +163 -0
  112. package/src/spec/parse.ts +432 -0
  113. package/src/testdata/compute-instance.yaml +93 -0
  114. package/src/testdata/iam-policy-member.yaml +66 -0
  115. package/src/testdata/manifests/compute-instance.yaml +18 -0
  116. package/src/testdata/manifests/full-app.yaml +34 -0
  117. package/src/testdata/manifests/storage-bucket.yaml +12 -0
  118. package/src/testdata/storage-bucket.yaml +100 -0
  119. package/src/validate-cli.ts +13 -0
  120. package/src/validate.test.ts +38 -0
  121. package/src/validate.ts +30 -0
  122. package/src/variables.ts +15 -0
package/src/pseudo.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { PseudoParameter, createPseudoParameters } from "@intentius/chant/pseudo-parameter";
2
+
3
+ export { PseudoParameter };
4
+
5
+ export const { ProjectId, Region, Zone } =
6
+ createPseudoParameters({
7
+ ProjectId: "GCP::ProjectId",
8
+ Region: "GCP::Region",
9
+ Zone: "GCP::Zone",
10
+ });
11
+
12
+ /**
13
+ * GCP namespace containing all pseudo-parameters.
14
+ */
15
+ export const GCP = {
16
+ ProjectId,
17
+ Region,
18
+ Zone,
19
+ } as const;
@@ -0,0 +1,250 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { gcpSerializer } from "./serializer";
3
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
4
+ import {
5
+ defaultLabels,
6
+ defaultAnnotations,
7
+ DEFAULT_LABELS_MARKER,
8
+ DEFAULT_ANNOTATIONS_MARKER,
9
+ } from "./default-labels";
10
+
11
+ // ── Mock helpers ────────────────────────────────────────────────────
12
+
13
+ function mockResource(
14
+ entityType: string,
15
+ props: Record<string, unknown>,
16
+ ): any {
17
+ return {
18
+ [DECLARABLE_MARKER]: true,
19
+ lexicon: "gcp",
20
+ entityType,
21
+ kind: "resource",
22
+ props,
23
+ };
24
+ }
25
+
26
+ function mockProperty(
27
+ entityType: string,
28
+ props: Record<string, unknown>,
29
+ ): any {
30
+ return {
31
+ [DECLARABLE_MARKER]: true,
32
+ lexicon: "gcp",
33
+ entityType,
34
+ kind: "property",
35
+ props,
36
+ };
37
+ }
38
+
39
+ // ── Tests ───────────────────────────────────────────────────────────
40
+
41
+ describe("gcpSerializer", () => {
42
+ test("name is gcp", () => {
43
+ expect(gcpSerializer.name).toBe("gcp");
44
+ });
45
+
46
+ test("rulePrefix is WGC", () => {
47
+ expect(gcpSerializer.rulePrefix).toBe("WGC");
48
+ });
49
+
50
+ test("empty entities produce empty string", () => {
51
+ const result = gcpSerializer.serialize(new Map());
52
+ expect(result).toBe("");
53
+ });
54
+
55
+ test("single resource produces valid YAML with apiVersion/kind/metadata/spec", () => {
56
+ const entities = new Map<string, any>();
57
+ entities.set(
58
+ "myBucket",
59
+ mockResource("GCP::Storage::Bucket", {
60
+ location: "US",
61
+ storageClass: "STANDARD",
62
+ }),
63
+ );
64
+
65
+ const result = gcpSerializer.serialize(entities);
66
+ expect(result).toContain("apiVersion:");
67
+ expect(result).toContain("kind:");
68
+ expect(result).toContain("metadata:");
69
+ expect(result).toContain("spec:");
70
+ expect(result).toContain("location: US");
71
+ expect(result).toContain("storageClass: STANDARD");
72
+ });
73
+
74
+ test("metadata.name auto-generated from logical name (camelCase→kebab-case)", () => {
75
+ const entities = new Map<string, any>();
76
+ entities.set(
77
+ "myDataBucket",
78
+ mockResource("GCP::Storage::Bucket", {
79
+ location: "US",
80
+ }),
81
+ );
82
+
83
+ const result = gcpSerializer.serialize(entities);
84
+ expect(result).toContain("name: my-data-bucket");
85
+ });
86
+
87
+ test("explicit metadata.name preserved", () => {
88
+ const entities = new Map<string, any>();
89
+ entities.set(
90
+ "myBucket",
91
+ mockResource("GCP::Storage::Bucket", {
92
+ metadata: { name: "custom-bucket-name" },
93
+ location: "US",
94
+ }),
95
+ );
96
+
97
+ const result = gcpSerializer.serialize(entities);
98
+ expect(result).toContain("name: custom-bucket-name");
99
+ });
100
+
101
+ test("all GCP resources use spec (no specless types)", () => {
102
+ const entities = new Map<string, any>();
103
+ entities.set(
104
+ "instance",
105
+ mockResource("GCP::Compute::Instance", {
106
+ machineType: "e2-medium",
107
+ zone: "us-central1-a",
108
+ }),
109
+ );
110
+
111
+ const result = gcpSerializer.serialize(entities);
112
+ expect(result).toContain("spec:");
113
+ expect(result).toContain("machineType: e2-medium");
114
+ });
115
+
116
+ test("multi-resource entities joined by ---", () => {
117
+ const entities = new Map<string, any>();
118
+ entities.set(
119
+ "bucket",
120
+ mockResource("GCP::Storage::Bucket", {
121
+ location: "US",
122
+ }),
123
+ );
124
+ entities.set(
125
+ "instance",
126
+ mockResource("GCP::Compute::Instance", {
127
+ machineType: "e2-medium",
128
+ }),
129
+ );
130
+
131
+ const result = gcpSerializer.serialize(entities);
132
+ expect(result).toContain("---");
133
+ });
134
+
135
+ test("default labels merged into metadata.labels", () => {
136
+ const entities = new Map<string, any>();
137
+ entities.set("labels", defaultLabels({ env: "prod" }));
138
+ entities.set(
139
+ "bucket",
140
+ mockResource("GCP::Storage::Bucket", {
141
+ location: "US",
142
+ }),
143
+ );
144
+
145
+ const result = gcpSerializer.serialize(entities);
146
+ expect(result).toContain("env: prod");
147
+ });
148
+
149
+ test("default annotations merged into metadata.annotations", () => {
150
+ const entities = new Map<string, any>();
151
+ entities.set("annot", defaultAnnotations({ "cnrm.cloud.google.com/project-id": "my-project" }));
152
+ entities.set(
153
+ "bucket",
154
+ mockResource("GCP::Storage::Bucket", {
155
+ location: "US",
156
+ }),
157
+ );
158
+
159
+ const result = gcpSerializer.serialize(entities);
160
+ expect(result).toContain("cnrm.cloud.google.com/project-id: my-project");
161
+ });
162
+
163
+ test("explicit labels override default labels", () => {
164
+ const entities = new Map<string, any>();
165
+ entities.set("labels", defaultLabels({ env: "dev" }));
166
+ entities.set(
167
+ "bucket",
168
+ mockResource("GCP::Storage::Bucket", {
169
+ metadata: { labels: { env: "prod" } },
170
+ location: "US",
171
+ }),
172
+ );
173
+
174
+ const result = gcpSerializer.serialize(entities);
175
+ expect(result).toContain("env: prod");
176
+ // Should not contain "dev" since explicit overrides
177
+ const envLines = result.split("\n").filter((l: string) => l.includes("env:"));
178
+ expect(envLines.every((l: string) => !l.includes("dev"))).toBe(true);
179
+ });
180
+
181
+ test("property entities skipped in output", () => {
182
+ const entities = new Map<string, any>();
183
+ entities.set("netConfig", mockProperty("GCP::Compute::NetworkConfig", { name: "config" }));
184
+ entities.set(
185
+ "instance",
186
+ mockResource("GCP::Compute::Instance", {
187
+ machineType: "e2-medium",
188
+ }),
189
+ );
190
+
191
+ const result = gcpSerializer.serialize(entities);
192
+ // Only one document — property entities should not appear as separate docs
193
+ expect(result.split("---").length).toBeLessThanOrEqual(2);
194
+ });
195
+
196
+ test("DefaultLabels entities skipped in output", () => {
197
+ const entities = new Map<string, any>();
198
+ entities.set("labels", defaultLabels({ env: "prod" }));
199
+
200
+ const result = gcpSerializer.serialize(entities);
201
+ // defaultLabels alone should not produce any output
202
+ expect(result).toBe("");
203
+ });
204
+
205
+ test("DefaultAnnotations entities skipped in output", () => {
206
+ const entities = new Map<string, any>();
207
+ entities.set("annotations", defaultAnnotations({ note: "hi" }));
208
+
209
+ const result = gcpSerializer.serialize(entities);
210
+ expect(result).toBe("");
211
+ });
212
+
213
+ test("key ordering: apiVersion, kind, metadata, spec", () => {
214
+ const entities = new Map<string, any>();
215
+ entities.set(
216
+ "bucket",
217
+ mockResource("GCP::Storage::Bucket", {
218
+ location: "US",
219
+ }),
220
+ );
221
+
222
+ const result = gcpSerializer.serialize(entities);
223
+ const lines = result.split("\n");
224
+ const keyLines = lines.filter((l: string) => /^\w+:/.test(l));
225
+ const keys = keyLines.map((l: string) => l.split(":")[0]);
226
+
227
+ const apiIdx = keys.indexOf("apiVersion");
228
+ const kindIdx = keys.indexOf("kind");
229
+ const metaIdx = keys.indexOf("metadata");
230
+ const specIdx = keys.indexOf("spec");
231
+
232
+ expect(apiIdx).toBeLessThan(kindIdx);
233
+ expect(kindIdx).toBeLessThan(metaIdx);
234
+ expect(metaIdx).toBeLessThan(specIdx);
235
+ });
236
+
237
+ test("fallback GVK derivation (GCP::Compute::Instance → compute.cnrm.cloud.google.com/v1beta1)", () => {
238
+ const entities = new Map<string, any>();
239
+ entities.set(
240
+ "myInstance",
241
+ mockResource("GCP::Compute::Instance", {
242
+ machineType: "e2-medium",
243
+ }),
244
+ );
245
+
246
+ const result = gcpSerializer.serialize(entities);
247
+ expect(result).toContain("compute.cnrm.cloud.google.com/v1beta1");
248
+ expect(result).toContain("ComputeInstance");
249
+ });
250
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * GCP Config Connector YAML serializer.
3
+ *
4
+ * Converts Chant declarables to multi-document K8s YAML output
5
+ * for Config Connector resources with apiVersion, kind, metadata, and spec.
6
+ */
7
+
8
+ import { createRequire } from "module";
9
+ import type { Declarable } from "@intentius/chant/declarable";
10
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
+ import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
12
+ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
+ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
14
+ import { emitYAML } from "@intentius/chant/yaml";
15
+ import { isDefaultLabels, isDefaultAnnotations, type DefaultLabels, type DefaultAnnotations } from "./default-labels";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ /**
20
+ * GVK mapping entry — loaded from generated lexicon-gcp.json.
21
+ */
22
+ interface GVKEntry {
23
+ resourceType: string;
24
+ kind: "resource" | "property";
25
+ apiVersion?: string;
26
+ gvkKind?: string;
27
+ group?: string;
28
+ }
29
+
30
+ let cachedGVKMap: Record<string, GVKEntry> | null = null;
31
+
32
+ function getGVKMap(): Record<string, GVKEntry> {
33
+ if (cachedGVKMap) return cachedGVKMap;
34
+ try {
35
+ cachedGVKMap = require("./generated/lexicon-gcp.json") as Record<string, GVKEntry>;
36
+ } catch {
37
+ cachedGVKMap = {};
38
+ }
39
+ return cachedGVKMap!;
40
+ }
41
+
42
+ /**
43
+ * Resolve entityType to apiVersion and kind.
44
+ */
45
+ function resolveGVK(entityType: string): { apiVersion: string; kind: string } | null {
46
+ const gvkMap = getGVKMap();
47
+
48
+ for (const entry of Object.values(gvkMap)) {
49
+ if (entry.resourceType === entityType && entry.apiVersion && entry.gvkKind) {
50
+ return { apiVersion: entry.apiVersion, kind: entry.gvkKind };
51
+ }
52
+ }
53
+
54
+ // Fallback: derive from entity type string (GCP::Service::Kind → service.cnrm.cloud.google.com/v1beta1, Kind)
55
+ return deriveGVKFromType(entityType);
56
+ }
57
+
58
+ /**
59
+ * Derive GVK from a GCP type name.
60
+ * "GCP::Compute::Instance" → { apiVersion: "compute.cnrm.cloud.google.com/v1beta1", kind: "ComputeInstance" }
61
+ */
62
+ function deriveGVKFromType(entityType: string): { apiVersion: string; kind: string } | null {
63
+ const parts = entityType.split("::");
64
+ if (parts.length !== 3 || parts[0] !== "GCP") return null;
65
+
66
+ const service = parts[1].toLowerCase();
67
+ const shortKind = parts[2];
68
+ const group = `${service}.cnrm.cloud.google.com`;
69
+ const apiVersion = `${group}/v1beta1`;
70
+ const kind = `${parts[1]}${shortKind}`;
71
+
72
+ return { apiVersion, kind };
73
+ }
74
+
75
+ /**
76
+ * GCP visitor for the generic serializer walker.
77
+ */
78
+ function gcpVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
79
+ return {
80
+ attrRef: (name, _attr) => {
81
+ // For Config Connector, references resolve to metadata.name
82
+ return name;
83
+ },
84
+ resourceRef: (name) => {
85
+ // Config Connector uses resourceRef pattern: { name: "resource-name" }
86
+ return { name };
87
+ },
88
+ propertyDeclarable: (entity, walk) => {
89
+ if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
90
+ return undefined;
91
+ }
92
+ const props = entity.props as Record<string, unknown>;
93
+ const result: Record<string, unknown> = {};
94
+ for (const [key, value] of Object.entries(props)) {
95
+ if (value !== undefined) {
96
+ result[key] = walk(value);
97
+ }
98
+ }
99
+ return Object.keys(result).length > 0 ? result : undefined;
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Convert a value to YAML-compatible form using the walker.
106
+ */
107
+ function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
108
+ return walkValue(value, entityNames, gcpVisitor(entityNames));
109
+ }
110
+
111
+ /**
112
+ * GCP Config Connector YAML serializer implementation.
113
+ */
114
+ export const gcpSerializer: Serializer = {
115
+ name: "gcp",
116
+ rulePrefix: "WGC",
117
+
118
+ serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): string {
119
+ // Build reverse map: entity → name
120
+ const entityNames = new Map<Declarable, string>();
121
+ for (const [name, entity] of entities) {
122
+ entityNames.set(entity, name);
123
+ }
124
+
125
+ // Collect default labels and annotations
126
+ let defaultLabelEntries: Record<string, unknown> = {};
127
+ let defaultAnnotationEntries: Record<string, unknown> = {};
128
+
129
+ for (const [, entity] of entities) {
130
+ if (isDefaultLabels(entity)) {
131
+ defaultLabelEntries = { ...defaultLabelEntries, ...(entity as DefaultLabels).labels };
132
+ }
133
+ if (isDefaultAnnotations(entity)) {
134
+ defaultAnnotationEntries = { ...defaultAnnotationEntries, ...(entity as DefaultAnnotations).annotations };
135
+ }
136
+ }
137
+
138
+ const documents: string[] = [];
139
+
140
+ for (const [name, entity] of entities) {
141
+ if (isPropertyDeclarable(entity)) continue;
142
+ if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
143
+
144
+ const entityType = (entity as unknown as Record<string, unknown>).entityType as string;
145
+ const gvk = resolveGVK(entityType);
146
+ if (!gvk) continue;
147
+
148
+ const props = toYAMLValue(
149
+ (entity as unknown as Record<string, unknown>).props,
150
+ entityNames,
151
+ ) as Record<string, unknown> | undefined;
152
+
153
+ if (!props) continue;
154
+
155
+ // Build the Config Connector manifest structure
156
+ const manifest: Record<string, unknown> = {
157
+ apiVersion: gvk.apiVersion,
158
+ kind: gvk.kind,
159
+ };
160
+
161
+ // Build metadata
162
+ const metadata: Record<string, unknown> = props.metadata as Record<string, unknown> ?? {};
163
+ if (!metadata.name) {
164
+ metadata.name = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
165
+ }
166
+
167
+ // Merge default labels
168
+ if (Object.keys(defaultLabelEntries).length > 0) {
169
+ const existingLabels = (metadata.labels ?? {}) as Record<string, unknown>;
170
+ metadata.labels = { ...defaultLabelEntries, ...existingLabels };
171
+ }
172
+
173
+ // Merge default annotations
174
+ if (Object.keys(defaultAnnotationEntries).length > 0) {
175
+ const existingAnnotations = (metadata.annotations ?? {}) as Record<string, unknown>;
176
+ metadata.annotations = { ...defaultAnnotationEntries, ...existingAnnotations };
177
+ }
178
+
179
+ manifest.metadata = metadata;
180
+
181
+ // All Config Connector resources use spec
182
+ const spec: Record<string, unknown> = {};
183
+ for (const [key, value] of Object.entries(props)) {
184
+ if (key !== "metadata") {
185
+ spec[key] = value;
186
+ }
187
+ }
188
+ if (Object.keys(spec).length > 0) {
189
+ manifest.spec = spec;
190
+ }
191
+
192
+ const yamlDoc = emitK8sManifest(manifest);
193
+ documents.push(yamlDoc);
194
+ }
195
+
196
+ return documents.join("\n---\n");
197
+ },
198
+ };
199
+
200
+ /**
201
+ * Emit a key-value pair as YAML.
202
+ */
203
+ function emitKeyValue(key: string, value: unknown): string {
204
+ const yamlStr = emitYAML(value, 1);
205
+ if (yamlStr.startsWith("\n")) {
206
+ return `${key}:${yamlStr}`;
207
+ }
208
+ return `${key}: ${yamlStr}`;
209
+ }
210
+
211
+ /**
212
+ * Emit a Config Connector manifest object as YAML.
213
+ * Preserves key ordering: apiVersion, kind, metadata, spec.
214
+ */
215
+ function emitK8sManifest(manifest: Record<string, unknown>): string {
216
+ const orderedKeys = ["apiVersion", "kind", "metadata", "spec"];
217
+ const lines: string[] = [];
218
+
219
+ for (const key of orderedKeys) {
220
+ if (manifest[key] !== undefined) {
221
+ lines.push(emitKeyValue(key, manifest[key]));
222
+ }
223
+ }
224
+
225
+ for (const [key, value] of Object.entries(manifest)) {
226
+ if (!orderedKeys.includes(key) && value !== undefined) {
227
+ lines.push(emitKeyValue(key, value));
228
+ }
229
+ }
230
+
231
+ return lines.join("\n") + "\n";
232
+ }