alchemy 0.41.1 → 0.42.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 (48) hide show
  1. package/README.md +2 -0
  2. package/bin/alchemy.mjs +99 -44
  3. package/lib/build-date.d.ts +1 -1
  4. package/lib/build-date.js +1 -1
  5. package/lib/cloudflare/certificate-pack.d.ts +216 -0
  6. package/lib/cloudflare/certificate-pack.d.ts.map +1 -0
  7. package/lib/cloudflare/certificate-pack.js +350 -0
  8. package/lib/cloudflare/certificate-pack.js.map +1 -0
  9. package/lib/cloudflare/container.d.ts +6 -0
  10. package/lib/cloudflare/container.d.ts.map +1 -1
  11. package/lib/cloudflare/container.js +32 -25
  12. package/lib/cloudflare/container.js.map +1 -1
  13. package/lib/cloudflare/index.d.ts +3 -0
  14. package/lib/cloudflare/index.d.ts.map +1 -1
  15. package/lib/cloudflare/index.js +3 -0
  16. package/lib/cloudflare/index.js.map +1 -1
  17. package/lib/cloudflare/orange.d.ts +37 -0
  18. package/lib/cloudflare/orange.d.ts.map +1 -0
  19. package/lib/cloudflare/orange.js +48 -0
  20. package/lib/cloudflare/orange.js.map +1 -0
  21. package/lib/cloudflare/redirect-rule.d.ts +168 -0
  22. package/lib/cloudflare/redirect-rule.d.ts.map +1 -0
  23. package/lib/cloudflare/redirect-rule.js +380 -0
  24. package/lib/cloudflare/redirect-rule.js.map +1 -0
  25. package/lib/cloudflare/worker/miniflare-worker-options.d.ts.map +1 -1
  26. package/lib/cloudflare/worker/miniflare-worker-options.js +11 -12
  27. package/lib/cloudflare/worker/miniflare-worker-options.js.map +1 -1
  28. package/lib/cloudflare/worker.d.ts +1 -1
  29. package/lib/fs/file-system-state-store.d.ts.map +1 -1
  30. package/lib/fs/file-system-state-store.js +8 -1
  31. package/lib/fs/file-system-state-store.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/build-date.ts +1 -1
  34. package/src/cloudflare/certificate-pack.ts +698 -0
  35. package/src/cloudflare/container.ts +44 -30
  36. package/src/cloudflare/index.ts +3 -0
  37. package/src/cloudflare/orange.ts +61 -0
  38. package/src/cloudflare/redirect-rule.ts +642 -0
  39. package/src/cloudflare/worker/miniflare-worker-options.ts +14 -12
  40. package/src/fs/file-system-state-store.ts +8 -1
  41. package/templates/astro/package.json +1 -1
  42. package/templates/nuxt/package.json +1 -1
  43. package/templates/react-router/package.json +1 -1
  44. package/templates/rwsdk/package.json +1 -1
  45. package/templates/sveltekit/package.json +1 -1
  46. package/templates/tanstack-start/package.json +1 -1
  47. package/templates/typescript/package.json +1 -1
  48. package/templates/vite/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ import type { Context } from "../context.ts";
2
+ import { Resource } from "../resource.ts";
3
+ import { logger } from "../util/logger.ts";
4
+ import { handleApiError } from "./api-error.ts";
5
+ import {
6
+ createCloudflareApi,
7
+ type CloudflareApi,
8
+ type CloudflareApiOptions,
9
+ } from "./api.ts";
10
+ import type { Zone } from "./zone.ts";
11
+
12
+ /**
13
+ * Certificate Authority options for Advanced Certificate Packs
14
+ */
15
+ export type CertificateAuthority = "google" | "lets_encrypt" | "ssl_com";
16
+
17
+ /**
18
+ * Validation method for certificate verification
19
+ */
20
+ export type ValidationMethod = "txt" | "http" | "email";
21
+
22
+ /**
23
+ * Validity period options for certificates
24
+ */
25
+ export type ValidityDays = 14 | 30 | 90 | 365;
26
+
27
+ /**
28
+ * Certificate pack status values during lifecycle
29
+ */
30
+ export type CertificatePackStatus =
31
+ | "initializing"
32
+ | "pending_validation"
33
+ | "deleted"
34
+ | "pending_issuance"
35
+ | "pending_deployment"
36
+ | "pending_deletion"
37
+ | "pending_expiration"
38
+ | "expired"
39
+ | "active"
40
+ | "initializing_timed_out"
41
+ | "validation_timed_out"
42
+ | "issuance_timed_out"
43
+ | "deployment_timed_out"
44
+ | "deletion_timed_out"
45
+ | "pending_cleanup"
46
+ | "staging_deployment"
47
+ | "staging_active"
48
+ | "deactivating"
49
+ | "inactive"
50
+ | "backup_issued"
51
+ | "holding_deployment";
52
+
53
+ /**
54
+ * Properties for creating a Certificate Pack
55
+ */
56
+ export interface CertificatePackProps extends CloudflareApiOptions {
57
+ /**
58
+ * The zone to create the certificate pack for
59
+ * Can be a Zone resource, zone ID string, or omitted to auto-infer from hosts
60
+ */
61
+ zone?: string | Zone;
62
+
63
+ /**
64
+ * Certificate Authority to use for issuing the certificate
65
+ * - google: Google Trust Services (Enterprise features)
66
+ * - lets_encrypt: Let's Encrypt (Free, shorter validity periods)
67
+ * - ssl_com: SSL.com (Commercial certificates with extended validation)
68
+ *
69
+ * **Note:** This property is immutable after creation. To change the CA,
70
+ * you must delete and recreate the certificate pack.
71
+ */
72
+ certificateAuthority: CertificateAuthority;
73
+
74
+ /**
75
+ * List of hostnames to include in the certificate
76
+ * Maximum 50 hosts, must include the zone apex (root domain)
77
+ * Supports wildcards (e.g., "*.example.com")
78
+ *
79
+ * **Note:** This property is immutable after creation.
80
+ */
81
+ hosts: string[];
82
+
83
+ /**
84
+ * Certificate type - only "advanced" is supported
85
+ *
86
+ * **Note:** This property is immutable after creation.
87
+ * @default "advanced"
88
+ */
89
+ type?: "advanced";
90
+
91
+ /**
92
+ * Method used to validate domain ownership
93
+ * - txt: DNS TXT record validation
94
+ * - http: HTTP file validation
95
+ * - email: Email validation
96
+ *
97
+ * **Note:** This property is immutable after creation.
98
+ */
99
+ validationMethod: ValidationMethod;
100
+
101
+ /**
102
+ * Certificate validity period in days
103
+ * Available options: 14, 30, 90, or 365 days
104
+ *
105
+ * **Note:** This property is immutable after creation.
106
+ */
107
+ validityDays: ValidityDays;
108
+
109
+ /**
110
+ * Whether to add Cloudflare branding subdomain as Common Name
111
+ * Adds sni.cloudflaressl.com subdomain when enabled
112
+ *
113
+ * **Note:** This is the only property that can be updated after creation.
114
+ * @default false
115
+ */
116
+ cloudflareBranding?: boolean;
117
+
118
+ /**
119
+ * Whether to delete the certificate pack
120
+ * If set to false, the pack will remain but the resource will be removed from state
121
+ *
122
+ * @default true
123
+ */
124
+ delete?: boolean;
125
+ }
126
+
127
+ /**
128
+ * Output returned after Certificate Pack creation/update
129
+ */
130
+ export interface CertificatePack
131
+ extends Resource<"cloudflare::CertificatePack"> {
132
+ /**
133
+ * The unique ID of the certificate pack
134
+ */
135
+ id: string;
136
+
137
+ /**
138
+ * Certificate Authority used for the certificate
139
+ */
140
+ certificateAuthority: CertificateAuthority;
141
+
142
+ /**
143
+ * Whether Cloudflare branding is enabled
144
+ */
145
+ cloudflareBranding: boolean;
146
+
147
+ /**
148
+ * List of hostnames included in the certificate
149
+ */
150
+ hosts: string[];
151
+
152
+ /**
153
+ * Current status of the certificate pack
154
+ */
155
+ status: CertificatePackStatus;
156
+
157
+ /**
158
+ * Certificate type
159
+ */
160
+ type: "advanced";
161
+
162
+ /**
163
+ * Validation method used for domain verification
164
+ */
165
+ validationMethod: ValidationMethod;
166
+
167
+ /**
168
+ * Certificate validity period in days
169
+ */
170
+ validityDays: ValidityDays;
171
+
172
+ /**
173
+ * Zone ID the certificate pack belongs to
174
+ */
175
+ zoneId: string;
176
+
177
+ /**
178
+ * Zone name (domain)
179
+ */
180
+ zoneName: string;
181
+ }
182
+
183
+ /**
184
+ * Creates and manages Cloudflare Advanced Certificate Packs.
185
+ *
186
+ * Advanced Certificate Packs provide flexible SSL/TLS certificates with
187
+ * multiple Certificate Authority options, custom validity periods, and
188
+ * support for up to 50 hostnames per certificate.
189
+ *
190
+ * **Important Notes:**
191
+ * - Requires a paid Cloudflare plan (not available on Free plans)
192
+ * - Certificate provisioning can take up to 10 minutes
193
+ * - Most properties are immutable after creation (only cloudflareBranding can be updated)
194
+ * - To change immutable properties, you must delete and recreate the certificate pack
195
+ *
196
+ * @example
197
+ * // Create a basic certificate pack with Let's Encrypt
198
+ * const basicCert = await CertificatePack("my-cert", {
199
+ * zone: myZone,
200
+ * certificateAuthority: "lets_encrypt",
201
+ * hosts: ["example.com", "www.example.com"],
202
+ * validationMethod: "txt",
203
+ * validityDays: 90
204
+ * });
205
+ *
206
+ * @example
207
+ * // Create an enterprise certificate with Google Trust Services
208
+ * const enterpriseCert = await CertificatePack("enterprise-cert", {
209
+ * zone: "example.com",
210
+ * certificateAuthority: "google",
211
+ * hosts: ["example.com", "*.example.com", "api.example.com"],
212
+ * validationMethod: "txt",
213
+ * validityDays: 365,
214
+ * cloudflareBranding: true
215
+ * });
216
+ *
217
+ * @example
218
+ * // Create a wildcard certificate with SSL.com
219
+ * const wildcardCert = await CertificatePack("wildcard-cert", {
220
+ * zone: myZone,
221
+ * certificateAuthority: "ssl_com",
222
+ * hosts: ["example.com", "*.example.com"],
223
+ * validationMethod: "email",
224
+ * validityDays: 365
225
+ * });
226
+ *
227
+ * @example
228
+ * // Create a certificate for multiple subdomains
229
+ * const multiDomainCert = await CertificatePack("multi-cert", {
230
+ * zone: "example.com",
231
+ * certificateAuthority: "lets_encrypt",
232
+ * hosts: [
233
+ * "example.com",
234
+ * "www.example.com",
235
+ * "api.example.com",
236
+ * "admin.example.com",
237
+ * "blog.example.com"
238
+ * ],
239
+ * validationMethod: "http",
240
+ * validityDays: 90
241
+ * });
242
+ *
243
+ * @see https://developers.cloudflare.com/api/resources/ssl/subresources/certificate_packs/
244
+ */
245
+ export const CertificatePack = Resource(
246
+ "cloudflare::CertificatePack",
247
+ async function (
248
+ this: Context<CertificatePack>,
249
+ _id: string,
250
+ props: CertificatePackProps,
251
+ ): Promise<CertificatePack> {
252
+ // Create Cloudflare API client with automatic account discovery
253
+ const api = await createCloudflareApi(props);
254
+
255
+ // Resolve zone ID and zone name
256
+ let zoneId: string;
257
+ let zoneName: string;
258
+
259
+ if (props.zone) {
260
+ // Zone provided - use it
261
+ if (typeof props.zone === "string") {
262
+ zoneId = props.zone;
263
+ // Try to get zone name from API for better error messages
264
+ try {
265
+ const zoneResponse = await api.get(`/zones/${zoneId}`);
266
+ if (zoneResponse.ok) {
267
+ const zoneData = (await zoneResponse.json()) as {
268
+ result: { name: string };
269
+ };
270
+ zoneName = zoneData.result.name;
271
+ } else {
272
+ zoneName = zoneId; // Fallback to ID
273
+ }
274
+ } catch {
275
+ zoneName = zoneId; // Fallback to ID
276
+ }
277
+ } else {
278
+ zoneId = props.zone.id;
279
+ zoneName = props.zone.name || props.zone.id;
280
+ }
281
+ } else {
282
+ // Auto-infer zone from the first host
283
+ if (props.hosts.length === 0) {
284
+ throw new Error(
285
+ "At least one host must be specified when zone is not provided",
286
+ );
287
+ }
288
+
289
+ logger.log(`Auto-inferring zone from hostname: ${props.hosts[0]}`);
290
+ const zoneInfo = await findZoneForHostname(api, props.hosts[0]);
291
+ zoneId = zoneInfo.zoneId;
292
+ zoneName = zoneInfo.zoneName;
293
+ logger.log(`Auto-inferred zone: ${zoneName} (${zoneId})`);
294
+ }
295
+
296
+ if (this.phase === "delete") {
297
+ if (this.output?.id && props.delete !== false) {
298
+ const deleteResponse = await api.delete(
299
+ `/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
300
+ );
301
+
302
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
303
+ await handleApiError(
304
+ deleteResponse,
305
+ "delete",
306
+ "certificate pack",
307
+ this.output.id,
308
+ );
309
+ }
310
+ } else {
311
+ logger.warn("Certificate pack not found, skipping delete");
312
+ }
313
+ return this.destroy();
314
+ }
315
+
316
+ if (this.phase === "update" && this.output?.id) {
317
+ // Validate immutable properties
318
+ const currentPack = this.output;
319
+
320
+ if (props.certificateAuthority !== currentPack.certificateAuthority) {
321
+ throw new Error(
322
+ `Cannot change certificateAuthority from '${currentPack.certificateAuthority}' to '${props.certificateAuthority}'. Certificate Authority is immutable after creation. You must delete and recreate the certificate pack.`,
323
+ );
324
+ }
325
+
326
+ if (
327
+ JSON.stringify(props.hosts.sort()) !==
328
+ JSON.stringify(currentPack.hosts.sort())
329
+ ) {
330
+ throw new Error(
331
+ `Cannot change hosts from [${currentPack.hosts.join(", ")}] to [${props.hosts.join(", ")}]. Hosts are immutable after creation. You must delete and recreate the certificate pack.`,
332
+ );
333
+ }
334
+
335
+ if (props.validationMethod !== currentPack.validationMethod) {
336
+ throw new Error(
337
+ `Cannot change validationMethod from '${currentPack.validationMethod}' to '${props.validationMethod}'. Validation method is immutable after creation. You must delete and recreate the certificate pack.`,
338
+ );
339
+ }
340
+
341
+ if (props.validityDays !== currentPack.validityDays) {
342
+ throw new Error(
343
+ `Cannot change validityDays from ${currentPack.validityDays} to ${props.validityDays}. Validity period is immutable after creation. You must delete and recreate the certificate pack.`,
344
+ );
345
+ }
346
+
347
+ const type = props.type || "advanced";
348
+ if (type !== currentPack.type) {
349
+ throw new Error(
350
+ `Cannot change type from '${currentPack.type}' to '${type}'. Type is immutable after creation. You must delete and recreate the certificate pack.`,
351
+ );
352
+ }
353
+
354
+ // Only cloudflareBranding can be updated
355
+ if (props.cloudflareBranding !== currentPack.cloudflareBranding) {
356
+ logger.log(
357
+ `Updating certificate pack cloudflare branding from ${currentPack.cloudflareBranding} to ${props.cloudflareBranding}`,
358
+ );
359
+
360
+ const updateResponse = await api.patch(
361
+ `/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
362
+ {
363
+ cloudflare_branding: props.cloudflareBranding || false,
364
+ },
365
+ );
366
+
367
+ if (!updateResponse.ok) {
368
+ await handleApiError(
369
+ updateResponse,
370
+ "update",
371
+ "certificate pack",
372
+ this.output.id,
373
+ );
374
+ }
375
+ }
376
+
377
+ // Get updated certificate pack details
378
+ const response = await api.get(
379
+ `/zones/${zoneId}/ssl/certificate_packs/${this.output.id}`,
380
+ );
381
+
382
+ if (!response.ok) {
383
+ await handleApiError(
384
+ response,
385
+ "get",
386
+ "certificate pack",
387
+ this.output.id,
388
+ );
389
+ }
390
+
391
+ const updatedPack = (
392
+ (await response.json()) as { result: CloudflareCertificatePack }
393
+ ).result;
394
+
395
+ return this({
396
+ id: updatedPack.id,
397
+ certificateAuthority: updatedPack.certificate_authority,
398
+ cloudflareBranding: updatedPack.cloudflare_branding,
399
+ hosts: updatedPack.hosts,
400
+ status: updatedPack.status,
401
+ type: updatedPack.type,
402
+ validationMethod: updatedPack.validation_method,
403
+ validityDays: updatedPack.validity_days,
404
+ zoneId: updatedPack.zone_id,
405
+ zoneName: zoneName,
406
+ });
407
+ }
408
+
409
+ // Create new certificate pack
410
+ if (props.hosts.length === 0) {
411
+ throw new Error("At least one host must be specified");
412
+ }
413
+
414
+ if (props.hosts.length > 50) {
415
+ throw new Error("Maximum 50 hosts are allowed per certificate pack");
416
+ }
417
+
418
+ // Validate that zone apex is included
419
+ const hasZoneApex = props.hosts.some(
420
+ (host) => host === zoneName || (zoneName && host === zoneName),
421
+ );
422
+
423
+ if (!hasZoneApex && zoneName) {
424
+ logger.warn(
425
+ `Zone apex '${zoneName}' is not included in hosts. This may cause certificate validation issues.`,
426
+ );
427
+ }
428
+
429
+ // Check for existing certificate pack that matches our configuration
430
+ const existingPack = await findMatchingCertificatePack(api, zoneId, props);
431
+
432
+ if (existingPack) {
433
+ // Adopt the existing certificate pack
434
+ logger.log(
435
+ `Adopting existing certificate pack ${existingPack.id} instead of creating a new one`,
436
+ );
437
+
438
+ return this({
439
+ id: existingPack.id,
440
+ certificateAuthority: existingPack.certificate_authority,
441
+ cloudflareBranding: existingPack.cloudflare_branding,
442
+ hosts: existingPack.hosts,
443
+ status: existingPack.status,
444
+ type: existingPack.type,
445
+ validationMethod: existingPack.validation_method,
446
+ validityDays: existingPack.validity_days,
447
+ zoneId: existingPack.zone_id,
448
+ zoneName: zoneName,
449
+ });
450
+ }
451
+
452
+ logger.log(
453
+ `Creating certificate pack with ${props.hosts.length} hosts using ${props.certificateAuthority}`,
454
+ );
455
+
456
+ const createResponse = await api.post(
457
+ `/zones/${zoneId}/ssl/certificate_packs/order`,
458
+ {
459
+ certificate_authority: props.certificateAuthority,
460
+ cloudflare_branding: props.cloudflareBranding || false,
461
+ hosts: props.hosts,
462
+ type: props.type || "advanced",
463
+ validation_method: props.validationMethod,
464
+ validity_days: props.validityDays,
465
+ },
466
+ );
467
+
468
+ if (!createResponse.ok) {
469
+ const errorText = await createResponse.text();
470
+
471
+ // Provide helpful error messages for common issues
472
+ if (errorText.includes("subscription")) {
473
+ throw new Error(
474
+ `Failed to create certificate pack: Advanced Certificate Packs require a paid Cloudflare plan. Please upgrade your subscription to use this feature.\n\nOriginal error: ${errorText}`,
475
+ );
476
+ }
477
+
478
+ if (errorText.includes("quota")) {
479
+ throw new Error(
480
+ `Failed to create certificate pack: Certificate pack quota exceeded. Please check your account limits.\n\nOriginal error: ${errorText}`,
481
+ );
482
+ }
483
+
484
+ // Throw generic error for other cases
485
+ throw new Error(
486
+ `Failed to create certificate pack: ${createResponse.statusText}\n\n${errorText}`,
487
+ );
488
+ }
489
+
490
+ const createdPack = (
491
+ (await createResponse.json()) as { result: CloudflareCertificatePack }
492
+ ).result;
493
+
494
+ logger.log(
495
+ `Certificate pack created with ID ${createdPack.id}. Status: ${createdPack.status}. Note: Certificate provisioning can take up to 10 minutes.`,
496
+ );
497
+
498
+ return this({
499
+ id: createdPack.id,
500
+ certificateAuthority: createdPack.certificate_authority,
501
+ cloudflareBranding: createdPack.cloudflare_branding,
502
+ hosts: createdPack.hosts,
503
+ status: createdPack.status,
504
+ type: createdPack.type,
505
+ validationMethod: createdPack.validation_method,
506
+ validityDays: createdPack.validity_days,
507
+ zoneId: createdPack.zone_id,
508
+ zoneName: zoneName,
509
+ });
510
+ },
511
+ );
512
+
513
+ /**
514
+ * Cloudflare Certificate Pack API response format
515
+ */
516
+ interface CloudflareCertificatePack {
517
+ id: string;
518
+ certificate_authority: CertificateAuthority;
519
+ cloudflare_branding: boolean;
520
+ hosts: string[];
521
+ status: CertificatePackStatus;
522
+ type: "advanced";
523
+ validation_method: ValidationMethod;
524
+ validity_days: ValidityDays;
525
+ zone_id: string;
526
+ }
527
+
528
+ /**
529
+ * Helper function to wait for certificate pack to reach active status
530
+ * Useful for testing or when you need to ensure the certificate is ready
531
+ *
532
+ * @param api CloudflareApi instance
533
+ * @param zoneId Zone ID
534
+ * @param certificatePackId Certificate pack ID
535
+ * @param timeoutMs Maximum time to wait in milliseconds (default: 15 minutes)
536
+ * @returns Promise resolving to the final certificate pack status
537
+ *
538
+ * @example
539
+ * // Wait for certificate to become active
540
+ * const finalStatus = await waitForCertificatePackActive(
541
+ * api,
542
+ * zoneId,
543
+ * certificatePack.id,
544
+ * 10 * 60 * 1000 // 10 minutes
545
+ * );
546
+ * console.log(`Certificate pack is now: ${finalStatus}`);
547
+ */
548
+ export async function waitForCertificatePackActive(
549
+ api: CloudflareApi,
550
+ zoneId: string,
551
+ certificatePackId: string,
552
+ timeoutMs: number = 15 * 60 * 1000, // 15 minutes default
553
+ ): Promise<CertificatePackStatus> {
554
+ const startTime = Date.now();
555
+ const pollInterval = 30 * 1000; // Poll every 30 seconds
556
+
557
+ while (Date.now() - startTime < timeoutMs) {
558
+ const response = await api.get(
559
+ `/zones/${zoneId}/ssl/certificate_packs/${certificatePackId}`,
560
+ );
561
+
562
+ if (!response.ok) {
563
+ throw new Error(
564
+ `Failed to check certificate pack status: ${response.statusText}`,
565
+ );
566
+ }
567
+
568
+ const pack = (
569
+ (await response.json()) as { result: CloudflareCertificatePack }
570
+ ).result;
571
+
572
+ // Return immediately if active or in a final error state
573
+ if (pack.status === "active") {
574
+ return pack.status;
575
+ }
576
+
577
+ if (
578
+ pack.status.includes("timed_out") ||
579
+ pack.status === "expired" ||
580
+ pack.status === "deleted"
581
+ ) {
582
+ return pack.status;
583
+ }
584
+
585
+ // Wait before next poll
586
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
587
+ }
588
+
589
+ throw new Error(
590
+ `Certificate pack did not become active within ${timeoutMs / 1000 / 60} minutes`,
591
+ );
592
+ }
593
+
594
+ /**
595
+ * Helper function to find zone ID from a hostname
596
+ * Searches for the zone that matches the hostname or its parent domains
597
+ *
598
+ * @param api CloudflareApi instance
599
+ * @param hostname The hostname to find the zone for
600
+ * @returns Promise resolving to the zone ID and zone name
601
+ */
602
+ async function findZoneForHostname(
603
+ api: CloudflareApi,
604
+ hostname: string,
605
+ ): Promise<{ zoneId: string; zoneName: string }> {
606
+ // Remove wildcard prefix if present
607
+ const cleanHostname = hostname.replace(/^\*\./, "");
608
+
609
+ // Get all zones and find the best match
610
+ const response = await api.get("/zones");
611
+
612
+ if (!response.ok) {
613
+ throw new Error(`Failed to list zones: ${response.statusText}`);
614
+ }
615
+
616
+ const zonesData = (await response.json()) as {
617
+ result: Array<{ id: string; name: string }>;
618
+ };
619
+
620
+ // Find the zone that best matches the hostname
621
+ // We look for the longest matching zone name (most specific)
622
+ let bestMatch: { zoneId: string; zoneName: string } | null = null;
623
+ let longestMatch = 0;
624
+
625
+ for (const zone of zonesData.result) {
626
+ if (
627
+ cleanHostname === zone.name ||
628
+ cleanHostname.endsWith(`.${zone.name}`)
629
+ ) {
630
+ if (zone.name.length > longestMatch) {
631
+ longestMatch = zone.name.length;
632
+ bestMatch = { zoneId: zone.id, zoneName: zone.name };
633
+ }
634
+ }
635
+ }
636
+
637
+ if (!bestMatch) {
638
+ throw new Error(
639
+ `Could not find zone for hostname '${hostname}'. Available zones: ${zonesData.result.map((z) => z.name).join(", ")}`,
640
+ );
641
+ }
642
+
643
+ return bestMatch;
644
+ }
645
+
646
+ /**
647
+ * Helper function to find existing certificate packs that match the given configuration
648
+ * Used to adopt existing certificates instead of creating duplicates
649
+ *
650
+ * @param api CloudflareApi instance
651
+ * @param zoneId Zone ID to search in
652
+ * @param props Certificate pack properties to match
653
+ * @returns Promise resolving to matching certificate pack or null if none found
654
+ */
655
+ async function findMatchingCertificatePack(
656
+ api: CloudflareApi,
657
+ zoneId: string,
658
+ props: CertificatePackProps,
659
+ ): Promise<CloudflareCertificatePack | null> {
660
+ const response = await api.get(`/zones/${zoneId}/ssl/certificate_packs`);
661
+
662
+ if (!response.ok) {
663
+ throw new Error(`Failed to list certificate packs: ${response.statusText}`);
664
+ }
665
+
666
+ const packsData = (await response.json()) as {
667
+ result: CloudflareCertificatePack[];
668
+ };
669
+
670
+ // Find a certificate pack that matches our configuration
671
+ for (const pack of packsData.result) {
672
+ // Skip deleted or expired packs
673
+ if (pack.status === "deleted" || pack.status === "expired") {
674
+ continue;
675
+ }
676
+
677
+ // Check if the configuration matches
678
+ if (
679
+ pack.certificate_authority === props.certificateAuthority &&
680
+ pack.validation_method === props.validationMethod &&
681
+ pack.validity_days === props.validityDays &&
682
+ pack.type === (props.type || "advanced")
683
+ ) {
684
+ // Check if all requested hosts are covered by this certificate pack
685
+ const packHosts = new Set(pack.hosts);
686
+ const allHostsCovered = props.hosts.every((host) => packHosts.has(host));
687
+
688
+ if (allHostsCovered) {
689
+ logger.log(
690
+ `Found existing certificate pack ${pack.id} that covers all requested hosts`,
691
+ );
692
+ return pack;
693
+ }
694
+ }
695
+ }
696
+
697
+ return null;
698
+ }