alchemy 0.2.5 → 0.3.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 (151) hide show
  1. package/README.md +3 -3
  2. package/lib/ai/astro-file.d.ts +137 -0
  3. package/lib/ai/astro-file.js +173 -0
  4. package/lib/ai/client.d.ts +2 -10
  5. package/lib/ai/client.js +8 -24
  6. package/lib/ai/css-file.d.ts +131 -0
  7. package/lib/ai/css-file.js +158 -0
  8. package/lib/ai/data.d.ts +45 -5
  9. package/lib/ai/data.js +45 -10
  10. package/lib/ai/document.d.ts +44 -20
  11. package/lib/ai/document.js +95 -28
  12. package/lib/ai/html-file.d.ts +128 -0
  13. package/lib/ai/html-file.js +155 -0
  14. package/lib/ai/index.d.ts +7 -0
  15. package/lib/ai/index.js +7 -0
  16. package/lib/ai/json-file.d.ts +160 -0
  17. package/lib/ai/json-file.js +208 -0
  18. package/lib/ai/typescript-file.d.ts +140 -0
  19. package/lib/ai/typescript-file.js +176 -0
  20. package/lib/ai/vue-file.d.ts +126 -0
  21. package/lib/ai/vue-file.js +153 -0
  22. package/lib/ai/yaml-file.d.ts +169 -0
  23. package/lib/ai/yaml-file.js +236 -0
  24. package/lib/cloudflare/dns.d.ts +88 -0
  25. package/lib/cloudflare/dns.js +192 -0
  26. package/lib/cloudflare/index.d.ts +2 -1
  27. package/lib/cloudflare/index.js +2 -1
  28. package/lib/cloudflare/r2-rest-state-store.d.ts +125 -0
  29. package/lib/cloudflare/r2-rest-state-store.js +254 -0
  30. package/lib/cloudflare/response.d.ts +12 -0
  31. package/lib/cloudflare/response.js +0 -0
  32. package/lib/cloudflare/zone.d.ts +18 -0
  33. package/lib/cloudflare/zone.js +17 -4
  34. package/lib/dns/godaddy.d.ts +7 -0
  35. package/lib/dns/godaddy.js +21 -0
  36. package/lib/dns/import-dns.d.ts +82 -0
  37. package/lib/dns/import-dns.js +114 -0
  38. package/lib/dns/index.d.ts +2 -0
  39. package/lib/dns/index.js +2 -0
  40. package/lib/dns/record.d.ts +88 -0
  41. package/lib/dns/record.js +13 -0
  42. package/lib/fs/file-system-state-store.d.ts +17 -0
  43. package/lib/fs/file-system-state-store.js +87 -0
  44. package/lib/fs/folder.d.ts +10 -0
  45. package/lib/fs/folder.js +2 -2
  46. package/lib/fs/index.d.ts +4 -4
  47. package/lib/fs/index.js +4 -4
  48. package/lib/fs/{json-file.d.ts → static-json-file.d.ts} +2 -2
  49. package/lib/fs/static-json-file.js +14 -0
  50. package/lib/fs/{text-file.d.ts → static-text-file.d.ts} +2 -2
  51. package/lib/fs/{text-file.js → static-text-file.js} +1 -1
  52. package/lib/fs/{typescript-file.d.ts → static-typescript-file.d.ts} +2 -2
  53. package/lib/fs/{typescript-file.js → static-typescript-file.js} +1 -1
  54. package/lib/fs/{yaml-file.d.ts → static-yaml-file.d.ts} +2 -2
  55. package/lib/fs/{yaml-file.js → static-yaml-file.js} +1 -1
  56. package/lib/internal/getting-started.d.ts +10 -0
  57. package/lib/internal/getting-started.js +94 -0
  58. package/lib/internal/project.d.ts +13 -0
  59. package/lib/internal/project.js +101 -0
  60. package/lib/internal/providers.d.ts +23 -0
  61. package/lib/internal/providers.js +148 -0
  62. package/lib/resource.js +8 -1
  63. package/lib/scope.d.ts +1 -1
  64. package/lib/scope.js +4 -2
  65. package/lib/state.d.ts +1 -16
  66. package/lib/state.js +0 -87
  67. package/lib/test/bun.d.ts +11 -0
  68. package/lib/test/bun.js +36 -8
  69. package/lib/web/astro.d.ts +147 -0
  70. package/lib/web/astro.js +414 -0
  71. package/lib/{shadcn/component.js → web/shadcn-component.js} +2 -9
  72. package/lib/web/shadcn.d.ts +97 -0
  73. package/lib/web/shadcn.js +99 -0
  74. package/lib/web/tailwind.d.ts +64 -0
  75. package/lib/web/tailwind.js +97 -0
  76. package/lib/{vite → web}/vite.d.ts +5 -0
  77. package/lib/{vite → web}/vite.js +44 -44
  78. package/lib/web/vitepress/custom-theme.d.ts +173 -0
  79. package/lib/web/vitepress/custom-theme.js +336 -0
  80. package/lib/{vitepress → web/vitepress}/dependencies.d.ts +2 -2
  81. package/lib/{vitepress → web/vitepress}/dependencies.js +1 -1
  82. package/lib/web/vitepress/home-page.d.ts +244 -0
  83. package/lib/web/vitepress/home-page.js +134 -0
  84. package/lib/{vitepress → web/vitepress}/index.d.ts +1 -0
  85. package/lib/{vitepress → web/vitepress}/index.js +1 -0
  86. package/lib/{vitepress → web/vitepress}/vitepress.d.ts +10 -8
  87. package/lib/{vitepress → web/vitepress}/vitepress.js +11 -12
  88. package/package.json +6 -2
  89. package/src/ai/astro-file.ts +288 -0
  90. package/src/ai/client.ts +8 -28
  91. package/src/ai/css-file.ts +266 -0
  92. package/src/ai/data.ts +53 -16
  93. package/src/ai/document.ts +130 -33
  94. package/src/ai/html-file.ts +263 -0
  95. package/src/ai/index.ts +7 -0
  96. package/src/ai/json-file.ts +349 -0
  97. package/src/ai/typescript-file.ts +293 -0
  98. package/src/ai/vue-file.ts +261 -0
  99. package/src/ai/yaml-file.ts +368 -0
  100. package/src/apply.ts +0 -1
  101. package/src/cloudflare/dns.ts +344 -0
  102. package/src/cloudflare/index.ts +2 -1
  103. package/src/cloudflare/r2-rest-state-store.ts +353 -0
  104. package/src/cloudflare/response.ts +9 -0
  105. package/src/cloudflare/zone.ts +39 -23
  106. package/src/dns/godaddy.ts +29 -0
  107. package/src/dns/import-dns.ts +213 -0
  108. package/src/dns/index.ts +2 -0
  109. package/src/dns/record.ts +121 -0
  110. package/src/fs/file-system-state-store.ts +109 -0
  111. package/src/fs/folder.ts +14 -2
  112. package/src/fs/index.ts +4 -4
  113. package/src/fs/{json-file.ts → static-json-file.ts} +13 -3
  114. package/src/fs/{text-file.ts → static-text-file.ts} +5 -2
  115. package/src/fs/{typescript-file.ts → static-typescript-file.ts} +3 -3
  116. package/src/fs/{yaml-file.ts → static-yaml-file.ts} +5 -2
  117. package/src/internal/getting-started.ts +107 -0
  118. package/src/internal/project.ts +119 -0
  119. package/src/internal/providers.ts +209 -0
  120. package/src/resource.ts +10 -1
  121. package/src/scope.ts +5 -6
  122. package/src/state.ts +1 -111
  123. package/src/test/bun.ts +60 -10
  124. package/src/web/astro.ts +644 -0
  125. package/src/{shadcn/component.ts → web/shadcn-component.ts} +5 -13
  126. package/src/web/shadcn.ts +219 -0
  127. package/src/web/tailwind.ts +167 -0
  128. package/src/{vite → web}/vite.ts +54 -56
  129. package/src/web/vitepress/custom-theme.ts +514 -0
  130. package/src/{vitepress → web/vitepress}/dependencies.ts +2 -2
  131. package/src/web/vitepress/home-page.ts +363 -0
  132. package/src/{vitepress → web/vitepress}/index.ts +1 -0
  133. package/src/{vitepress → web/vitepress}/vitepress.ts +34 -27
  134. package/lib/cloudflare/state.d.ts +0 -82
  135. package/lib/cloudflare/state.js +0 -108
  136. package/lib/fs/json-file.js +0 -7
  137. package/lib/internal/docs.d.ts +0 -5
  138. package/lib/internal/docs.js +0 -287
  139. package/lib/shadcn/index.d.ts +0 -1
  140. package/lib/shadcn/index.js +0 -1
  141. package/lib/vite/index.d.ts +0 -1
  142. package/lib/vite/index.js +0 -1
  143. package/lib/vitepress/home-page.d.ts +0 -133
  144. package/lib/vitepress/home-page.js +0 -11
  145. package/src/cloudflare/state.ts +0 -138
  146. package/src/internal/docs.ts +0 -338
  147. package/src/shadcn/index.ts +0 -1
  148. package/src/vite/index.ts +0 -1
  149. package/src/vitepress/home-page.ts +0 -166
  150. /package/lib/{shadcn/component.d.ts → web/shadcn-component.d.ts} +0 -0
  151. /package/src/{vitepress → web/vitepress}/index.md +0 -0
@@ -0,0 +1,368 @@
1
+ import { generateObject, generateText } from "ai";
2
+ import type { JsonSchema, Type, type } from "arktype";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { Context } from "../context";
6
+ import { Resource } from "../resource";
7
+ import type { Secret } from "../secret";
8
+ import { ignore } from "../util/ignore";
9
+ import { ark } from "./ark";
10
+ import { type ModelConfig, createModel } from "./client";
11
+
12
+ /**
13
+ * Properties for creating or updating a YAMLFile
14
+ */
15
+ export interface YAMLFileProps<
16
+ T extends Type<any, any> | undefined = undefined,
17
+ > {
18
+ /**
19
+ * Path to the YAML file
20
+ */
21
+ path: string;
22
+
23
+ /**
24
+ * Optional ArkType schema to validate and structure the generated YAML
25
+ * When provided, the resource will use generateObject with schema validation
26
+ * When not provided, it will extract YAML from between ```yaml fences
27
+ */
28
+ schema?: T;
29
+
30
+ /**
31
+ * Base URL for the OpenAI API
32
+ * @default 'https://api.openai.com/v1'
33
+ */
34
+ baseURL?: string;
35
+
36
+ /**
37
+ * Prompt for generating content
38
+ * Use alchemy template literals to include file context:
39
+ * @example
40
+ * prompt: await alchemy`
41
+ * Generate a YAML configuration for:
42
+ * ${alchemy.file("src/serverless.js")}
43
+ * `
44
+ */
45
+ prompt: string;
46
+
47
+ /**
48
+ * System prompt for the model
49
+ * This is used to provide instructions to the model about how to format the response
50
+ * @default Depends on whether schema is provided
51
+ */
52
+ system?: string;
53
+
54
+ /**
55
+ * OpenAI API key to use for generating content
56
+ * If not provided, will use OPENAI_API_KEY environment variable
57
+ */
58
+ apiKey?: Secret;
59
+
60
+ /**
61
+ * Model configuration
62
+ */
63
+ model?: ModelConfig;
64
+
65
+ /**
66
+ * Temperature for controlling randomness in generation.
67
+ * Higher values (e.g., 0.8) make output more random,
68
+ * lower values (e.g., 0.2) make it more deterministic.
69
+ * @default 0.7
70
+ */
71
+ temperature?: number;
72
+ }
73
+
74
+ /**
75
+ * A YAML file that can be created, updated, and deleted
76
+ */
77
+ export interface YAMLFile<T = any>
78
+ extends Omit<YAMLFileProps, "schema">,
79
+ Resource<"ai::YAMLFile"> {
80
+ /**
81
+ * Content of the YAML file as a string
82
+ */
83
+ content: string;
84
+
85
+ /**
86
+ * Parsed YAML object
87
+ */
88
+ yaml: T;
89
+
90
+ /**
91
+ * Schema used to validate the YAML (if provided)
92
+ */
93
+ schema?: JsonSchema;
94
+
95
+ /**
96
+ * Time at which the file was created
97
+ */
98
+ createdAt: number;
99
+
100
+ /**
101
+ * Time at which the file was last updated
102
+ */
103
+ updatedAt: number;
104
+ }
105
+
106
+ /**
107
+ * Default system prompt for YAML file generation without schema
108
+ */
109
+ const DEFAULT_YAML_SYSTEM_PROMPT =
110
+ "You are a YAML generator. Create valid YAML based on the user's requirements. Your response MUST include only YAML inside ```yaml fences. Do not include any other text, explanations, or multiple code blocks. Use standard YAML syntax with proper indentation. Use quotes around strings that contain special characters when necessary.";
111
+
112
+ /**
113
+ * Resource for generating YAML files using AI models.
114
+ * Can operate in two modes:
115
+ * 1. With schema: Uses generateObject with type validation, then converts to YAML
116
+ * 2. Without schema: Extracts YAML from between ```yaml fences
117
+ *
118
+ * @example
119
+ * // Generate a serverless configuration file
120
+ * const serverlessConfig = await YAMLFile("serverless-config", {
121
+ * path: "./serverless.yml",
122
+ * prompt: await alchemy`
123
+ * Generate a serverless.yml configuration for an AWS Lambda API with:
124
+ * - A service name "user-api"
125
+ * - Node.js 16.x runtime
126
+ * - Three functions: createUser, getUser, and listUsers
127
+ * - API Gateway endpoints for each function
128
+ * - DynamoDB table for users
129
+ * - IAM permissions for DynamoDB access
130
+ * `,
131
+ * model: {
132
+ * id: "gpt-4o",
133
+ * provider: "openai"
134
+ * }
135
+ * });
136
+ *
137
+ * @example
138
+ * // Generate YAML with schema validation
139
+ * import { type } from "arktype";
140
+ *
141
+ * const k8sConfigSchema = type({
142
+ * apiVersion: "string",
143
+ * kind: "string",
144
+ * metadata: {
145
+ * name: "string",
146
+ * namespace: "string?",
147
+ * labels: "Record<string, string>?"
148
+ * },
149
+ * spec: {
150
+ * replicas: "number",
151
+ * selector: {
152
+ * matchLabels: "Record<string, string>"
153
+ * },
154
+ * template: {
155
+ * metadata: {
156
+ * labels: "Record<string, string>"
157
+ * },
158
+ * spec: {
159
+ * containers: [{
160
+ * name: "string",
161
+ * image: "string",
162
+ * ports: [{
163
+ * containerPort: "number"
164
+ * }]
165
+ * }]
166
+ * }
167
+ * }
168
+ * }
169
+ * });
170
+ *
171
+ * const deployment = await YAMLFile("k8s-deployment", {
172
+ * path: "./kubernetes/deployment.yaml",
173
+ * schema: k8sConfigSchema,
174
+ * prompt: "Generate a Kubernetes deployment for a web application named 'frontend' with 3 replicas using the nginx:latest image and exposing port 80",
175
+ * temperature: 0.2
176
+ * });
177
+ *
178
+ * @example
179
+ * // Generate GitHub Actions workflow with custom system prompt
180
+ * const workflow = await YAMLFile("github-workflow", {
181
+ * path: "./.github/workflows/ci.yml",
182
+ * prompt: await alchemy`
183
+ * Create a GitHub Actions workflow for a Node.js project that:
184
+ * - Runs on push to main and pull requests
185
+ * - Sets up Node.js 18
186
+ * - Installs dependencies with npm
187
+ * - Runs linting and tests
188
+ * - Builds the project
189
+ * - Deploys to GitHub Pages on success (main branch only)
190
+ * `,
191
+ * system: "You are a DevOps expert specializing in GitHub Actions workflows. Create a single YAML file inside ```yaml fences with no additional text. Follow GitHub Actions best practices and use proper YAML syntax.",
192
+ * model: {
193
+ * id: "claude-3-opus-20240229",
194
+ * provider: "anthropic"
195
+ * }
196
+ * });
197
+ */
198
+ export const YAMLFile = Resource("ai::YAMLFile", async function <
199
+ const T extends Type<any, any> | undefined = undefined,
200
+ >(this: Context<YAMLFile<T extends Type<any, any> ? type.infer<T> : any>>, id: string, props: YAMLFileProps<T>): Promise<
201
+ YAMLFile<T extends Type<any, any> ? type.infer<T> : any>
202
+ > {
203
+ // Ensure directory exists
204
+ await fs.mkdir(path.dirname(props.path), { recursive: true });
205
+
206
+ if (this.phase === "delete") {
207
+ try {
208
+ await fs.unlink(props.path);
209
+ } catch (error: any) {
210
+ // Ignore if file doesn't exist
211
+ if (error.code !== "ENOENT") {
212
+ throw error;
213
+ }
214
+ }
215
+ return this.destroy();
216
+ }
217
+
218
+ // Dynamic import js-yaml to avoid dependency issues
219
+ // This allows the code to work even if js-yaml is not installed
220
+ let yaml: typeof import("yaml");
221
+ try {
222
+ yaml = await import("yaml");
223
+ } catch (err) {
224
+ throw new Error(
225
+ "The 'yaml' package is required for the YAMLFile resource. Please install it with: bun add yaml",
226
+ );
227
+ }
228
+
229
+ let yamlContent: string;
230
+ let yamlObject: any;
231
+
232
+ // Check if schema is provided
233
+ if (props.schema) {
234
+ // Use schema-based generation
235
+ const { object } = await generateObject({
236
+ model: createModel(props),
237
+ schema: ark.schema<type.infer<typeof props.schema>>(props.schema),
238
+ providerOptions: props.model?.options,
239
+ system:
240
+ props.system ||
241
+ "Generate a valid object based on the provided requirements.",
242
+ prompt: props.prompt,
243
+ ...(props.temperature === undefined
244
+ ? {}
245
+ : { temperature: props.temperature }),
246
+ });
247
+
248
+ yamlObject = object;
249
+
250
+ // Convert object to YAML
251
+ try {
252
+ yamlContent = yaml.stringify(yamlObject, {
253
+ indent: 2,
254
+ });
255
+ } catch (error) {
256
+ throw new Error(
257
+ `Failed to convert object to YAML: ${(error as Error).message}`,
258
+ );
259
+ }
260
+ } else {
261
+ // Use fence-based extraction
262
+ // Use provided system prompt or default
263
+ const system = props.system || DEFAULT_YAML_SYSTEM_PROMPT;
264
+
265
+ // Generate initial content
266
+ const { text } = await generateText({
267
+ model: createModel(props),
268
+ prompt: props.prompt,
269
+ system,
270
+ providerOptions: props.model?.options,
271
+ ...(props.temperature === undefined
272
+ ? {}
273
+ : { temperature: props.temperature }),
274
+ });
275
+
276
+ // Extract and validate YAML content
277
+ let { content, error } = await extractYAMLContent(text);
278
+
279
+ // Re-prompt if there are validation errors
280
+ if (error) {
281
+ const errorSystem = `${system}\n\nERROR: ${error}\n\nPlease try again and ensure your response contains exactly one YAML block inside \`\`\`yaml fences.`;
282
+
283
+ const { text: retryText } = await generateText({
284
+ model: createModel(props),
285
+ prompt: props.prompt,
286
+ system: errorSystem,
287
+ providerOptions: props.model?.options,
288
+ ...(props.temperature === undefined
289
+ ? {}
290
+ : { temperature: props.temperature }),
291
+ });
292
+
293
+ const retryResult = await extractYAMLContent(retryText);
294
+
295
+ if (retryResult.error) {
296
+ throw new Error(`Failed to generate valid YAML: ${retryResult.error}`);
297
+ }
298
+
299
+ content = retryResult.content;
300
+ }
301
+
302
+ yamlContent = content;
303
+
304
+ // Parse YAML to validate and get object representation
305
+ try {
306
+ yamlObject = yaml.parse(yamlContent);
307
+ } catch (error) {
308
+ throw new Error(`Failed to parse YAML: ${(error as Error).message}`);
309
+ }
310
+ }
311
+
312
+ if (this.phase === "update" && props.path !== this.props.path) {
313
+ await ignore("ENOENT", () => fs.unlink(this.props.path));
314
+ }
315
+
316
+ // Write content to file
317
+ await fs.writeFile(props.path, yamlContent);
318
+
319
+ // Get file stats for timestamps
320
+ const stats = await fs.stat(props.path);
321
+
322
+ // Return the resource
323
+ return this({
324
+ ...props,
325
+ schema: props.schema,
326
+ content: yamlContent,
327
+ yaml: yamlObject,
328
+ createdAt: stats.birthtimeMs,
329
+ updatedAt: stats.mtimeMs,
330
+ });
331
+ });
332
+
333
+ /**
334
+ * Extracts YAML content from between ```yaml fences
335
+ * Validates that exactly one YAML code block exists
336
+ *
337
+ * @param text The text to extract YAML from
338
+ * @returns The extracted YAML or error message
339
+ */
340
+ async function extractYAMLContent(
341
+ text: string,
342
+ ): Promise<{ content: string; error?: string }> {
343
+ // Check for yaml or yml fence blocks
344
+ const yamlCodeRegex = /```(yaml|yml)\s*([\s\S]*?)```/g;
345
+ const matches = Array.from(text.matchAll(yamlCodeRegex));
346
+
347
+ if (matches.length === 0) {
348
+ return {
349
+ content: "",
350
+ error:
351
+ "No YAML code block found in the response. Please include your YAML within ```yaml fences.",
352
+ };
353
+ }
354
+
355
+ if (matches.length > 1) {
356
+ return {
357
+ content: "",
358
+ error:
359
+ "Multiple YAML code blocks found in the response. Please provide exactly one YAML block within ```yaml fences.",
360
+ };
361
+ }
362
+
363
+ const content = matches[0][2].trim();
364
+
365
+ // We don't validate YAML parsing here because js-yaml might not be available
366
+ // Validation will happen at usage time if needed
367
+ return { content };
368
+ }
package/src/apply.ts CHANGED
@@ -119,7 +119,6 @@ export async function apply<Out extends Resource>(
119
119
  const output = await alchemy.run(resource.ID, async () =>
120
120
  provider.handler.bind(ctx)(resource.ID, props),
121
121
  );
122
-
123
122
  if (!quiet) {
124
123
  console.log(
125
124
  `${phase === "create" ? "Created" : "Updated"}: "${resource.FQN}"`,
@@ -0,0 +1,344 @@
1
+ import type { Context } from "../context";
2
+ import type {
3
+ DnsRecord as BaseDnsRecord,
4
+ DnsRecordType,
5
+ DnsRecordWithMetadata,
6
+ } from "../dns/record";
7
+ import { Resource } from "../resource";
8
+ import { type CloudflareApi, createCloudflareApi } from "./api";
9
+ import type { CloudflareResponse } from "./response";
10
+
11
+ /**
12
+ * Cloudflare DNS Record response format
13
+ */
14
+ interface CloudflareDnsRecord {
15
+ id: string;
16
+ type: string;
17
+ name: string;
18
+ content: string;
19
+ proxiable: boolean;
20
+ proxied: boolean;
21
+ ttl: number;
22
+ locked: boolean;
23
+ zone_id: string;
24
+ zone_name: string;
25
+ created_on: string;
26
+ modified_on: string;
27
+ data?: Record<string, unknown>;
28
+ priority?: number;
29
+ comment?: string;
30
+ tags?: string[];
31
+ }
32
+
33
+ /**
34
+ * Properties for a DNS record
35
+ */
36
+ export interface DnsRecordProps extends Omit<BaseDnsRecord, "type"> {
37
+ /**
38
+ * Record type (A, AAAA, CNAME, etc.)
39
+ */
40
+ type: DnsRecordType;
41
+ }
42
+
43
+ /**
44
+ * Output returned after DNS record creation/update
45
+ */
46
+ export interface DnsRecord extends DnsRecordWithMetadata {}
47
+
48
+ /**
49
+ * Properties for managing multiple DNS records
50
+ */
51
+ export interface DnsRecordsProps {
52
+ /**
53
+ * Zone ID or domain name where records will be created
54
+ */
55
+ zoneId: string;
56
+
57
+ /**
58
+ * Array of DNS records to manage
59
+ */
60
+ records: DnsRecordProps[];
61
+ }
62
+
63
+ /**
64
+ * Output returned after DNS records creation/update
65
+ */
66
+ export interface DnsRecords extends Resource<"cloudflare::DnsRecords"> {
67
+ /**
68
+ * Zone ID where records are created
69
+ */
70
+ zoneId: string;
71
+
72
+ /**
73
+ * Array of created/updated DNS records
74
+ */
75
+ records: DnsRecord[];
76
+ }
77
+
78
+ /**
79
+ * Manages a batch of DNS records in a Cloudflare zone.
80
+ * Supports creating, updating, and deleting multiple records at once.
81
+ *
82
+ * @example
83
+ * // Create multiple A and CNAME records
84
+ * const dnsRecords = await DnsRecords("example.com-dns", {
85
+ * zone: "example.com",
86
+ * records: [
87
+ * {
88
+ * name: "www.example.com",
89
+ * type: "A",
90
+ * content: "192.0.2.1",
91
+ * proxied: true
92
+ * },
93
+ * {
94
+ * name: "blog.example.com",
95
+ * type: "CNAME",
96
+ * content: "www.example.com",
97
+ * proxied: true
98
+ * }
99
+ * ]
100
+ * });
101
+ *
102
+ * @example
103
+ * // Create MX records for email routing
104
+ * const emailRecords = await DnsRecords("example.com-email", {
105
+ * zone: "example.com",
106
+ * records: [
107
+ * {
108
+ * name: "example.com",
109
+ * type: "MX",
110
+ * content: "aspmx.l.google.com",
111
+ * priority: 1
112
+ * },
113
+ * {
114
+ * name: "example.com",
115
+ * type: "MX",
116
+ * content: "alt1.aspmx.l.google.com",
117
+ * priority: 5
118
+ * }
119
+ * ]
120
+ * });
121
+ */
122
+ export const DnsRecords = Resource(
123
+ "cloudflare::DnsRecords",
124
+ async function (
125
+ this: Context<DnsRecords>,
126
+ id: string,
127
+ props: DnsRecordsProps,
128
+ ): Promise<DnsRecords> {
129
+ // Create Cloudflare API client
130
+ const api = await createCloudflareApi();
131
+
132
+ // Get zone ID if domain name was provided
133
+ const zoneId = props.zoneId;
134
+
135
+ if (this.phase === "delete") {
136
+ if (this.output?.records) {
137
+ // Delete all existing records
138
+ await Promise.all(
139
+ this.output.records.map(async (record) => {
140
+ try {
141
+ const response = await api.delete(
142
+ `/zones/${zoneId}/dns_records/${record.id}`,
143
+ );
144
+ if (!response.ok && response.status !== 404) {
145
+ console.error(
146
+ `Failed to delete DNS record ${record.name}: ${response.statusText}`,
147
+ );
148
+ }
149
+ } catch (error) {
150
+ console.error(`Error deleting DNS record ${record.name}:`, error);
151
+ }
152
+ }),
153
+ );
154
+ }
155
+ return this.destroy();
156
+ }
157
+
158
+ if (this.phase === "update" && this.output?.records) {
159
+ // Get current records to compare with desired state
160
+ const currentRecords = this.output.records;
161
+ const desiredRecords = props.records;
162
+
163
+ // Find records to delete (exist in current but not in desired)
164
+ const recordsToDelete = currentRecords.filter(
165
+ (current) =>
166
+ !desiredRecords.some(
167
+ (desired) =>
168
+ desired.name === current.name && desired.type === current.type,
169
+ ),
170
+ );
171
+
172
+ // Delete orphaned records
173
+ await Promise.all(
174
+ recordsToDelete.map(async (record) => {
175
+ try {
176
+ const response = await api.delete(
177
+ `/zones/${zoneId}/dns_records/${record.id}`,
178
+ );
179
+ if (!response.ok && response.status !== 404) {
180
+ console.error(
181
+ `Failed to delete DNS record ${record.name}: ${response.statusText}`,
182
+ );
183
+ }
184
+ } catch (error) {
185
+ console.error(`Error deleting DNS record ${record.name}:`, error);
186
+ }
187
+ }),
188
+ );
189
+
190
+ // Update or create records
191
+ const updatedRecords = await Promise.all(
192
+ desiredRecords.map(async (desired) => {
193
+ // Find matching existing record
194
+ const existing = currentRecords.find(
195
+ (current) =>
196
+ current.name === desired.name && current.type === desired.type,
197
+ );
198
+
199
+ if (existing) {
200
+ // Update if content or other properties changed
201
+ if (
202
+ existing.content !== desired.content ||
203
+ existing.ttl !== (desired.ttl || 1) ||
204
+ existing.proxied !== (desired.proxied || false) ||
205
+ existing.priority !== desired.priority ||
206
+ existing.comment !== desired.comment
207
+ ) {
208
+ return createOrUpdateRecord(api, zoneId, desired, existing.id);
209
+ }
210
+ return existing;
211
+ } else {
212
+ // Create new record
213
+ return createOrUpdateRecord(api, zoneId, desired);
214
+ }
215
+ }),
216
+ );
217
+
218
+ return this({
219
+ zoneId,
220
+ records: updatedRecords,
221
+ });
222
+ }
223
+
224
+ // Create new records
225
+ const uniqueRecords = props.records.reduce(
226
+ (acc, record) => {
227
+ const key = `${record.name}-${record.type}`;
228
+ acc[key] = record;
229
+ return acc;
230
+ },
231
+ {} as Record<string, DnsRecordProps>,
232
+ );
233
+
234
+ const createdRecords = await Promise.all(
235
+ Object.values(uniqueRecords).map(async (record) => {
236
+ // First check if record exists
237
+ const listResponse = await api.get(
238
+ `/zones/${zoneId}/dns_records?type=${record.type}&name=${record.name}`,
239
+ );
240
+ if (!listResponse.ok) {
241
+ throw new Error(
242
+ `Failed to check existing DNS records: ${listResponse.statusText}`,
243
+ );
244
+ }
245
+
246
+ const listResult = (await listResponse.json()) as CloudflareResponse<
247
+ CloudflareDnsRecord[]
248
+ >;
249
+ const existingRecord = listResult.result[0];
250
+
251
+ return createOrUpdateRecord(api, zoneId, record, existingRecord?.id);
252
+ }),
253
+ );
254
+
255
+ return this({
256
+ zoneId,
257
+ records: createdRecords,
258
+ });
259
+ },
260
+ );
261
+
262
+ /**
263
+ * Create or update a DNS record
264
+ */
265
+ async function createOrUpdateRecord(
266
+ api: CloudflareApi,
267
+ zoneId: string,
268
+ record: DnsRecordProps,
269
+ existingId?: string,
270
+ ): Promise<DnsRecord> {
271
+ const payload = getRecordPayload(record);
272
+
273
+ const response = await (existingId
274
+ ? api.put(`/zones/${zoneId}/dns_records/${existingId}`, payload)
275
+ : api.post(`/zones/${zoneId}/dns_records`, payload));
276
+
277
+ if (!response.ok) {
278
+ const errorBody = await response.text();
279
+
280
+ // If it's an update operation and the record doesn't exist, fall back to creation
281
+ if (existingId && response.status === 404) {
282
+ try {
283
+ const createResponse = await api.post(
284
+ `/zones/${zoneId}/dns_records`,
285
+ payload,
286
+ );
287
+ if (createResponse.ok) {
288
+ return convertCloudflareRecord(
289
+ ((await createResponse.json()) as any).result,
290
+ zoneId,
291
+ );
292
+ }
293
+ } catch (err) {
294
+ // Fall through to the original error
295
+ }
296
+ }
297
+
298
+ throw new Error(
299
+ `Failed to ${existingId ? "update" : "create"} DNS record ${record.name}: ${response.statusText}\nResponse: ${errorBody}`,
300
+ );
301
+ }
302
+
303
+ const result =
304
+ (await response.json()) as CloudflareResponse<CloudflareDnsRecord>;
305
+ return convertCloudflareRecord(result.result, zoneId);
306
+ }
307
+
308
+ /**
309
+ * Get the record payload for create/update operations
310
+ */
311
+ function getRecordPayload(record: DnsRecordProps) {
312
+ return {
313
+ type: record.type,
314
+ name: record.name,
315
+ content: record.content,
316
+ ttl: record.ttl || 1,
317
+ proxied: record.proxied || false,
318
+ priority: record.priority,
319
+ comment: record.comment,
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Convert a Cloudflare DNS record response to our DnsRecord type
325
+ */
326
+ function convertCloudflareRecord(
327
+ record: CloudflareDnsRecord,
328
+ zoneId: string,
329
+ ): DnsRecord {
330
+ return {
331
+ id: record.id,
332
+ name: record.name,
333
+ type: record.type as DnsRecordProps["type"],
334
+ content: record.content,
335
+ ttl: record.ttl,
336
+ proxied: record.proxied,
337
+ priority: record.priority,
338
+ comment: record.comment,
339
+ tags: record.tags,
340
+ createdAt: new Date(record.created_on).getTime(),
341
+ modifiedAt: new Date(record.modified_on).getTime(),
342
+ zoneId,
343
+ };
344
+ }