create-svc 0.1.18 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -20,6 +20,8 @@ type PostScaffoldCommand = {
20
20
 
21
21
  const decoder = new TextDecoder();
22
22
  const encoder = new TextEncoder();
23
+ const DEPLOYMENT_VERIFY_ATTEMPTS = 36;
24
+ const DEPLOYMENT_VERIFY_DELAY_MS = 10_000;
23
25
 
24
26
  export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
25
27
  if (config.autoDeploy) {
@@ -28,7 +30,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
28
30
  run(command.command, command.args, { cwd });
29
31
  }
30
32
  for (const command of buildDeploymentVerificationCommands(config)) {
31
- run(command.command, command.args, { cwd, quiet: true });
33
+ runWithRetries(command, { cwd, quiet: true }, DEPLOYMENT_VERIFY_ATTEMPTS, DEPLOYMENT_VERIFY_DELAY_MS);
32
34
  }
33
35
  return { message: "Dependencies installed, service created, service deployed, and production health verified" };
34
36
  }
@@ -36,6 +38,22 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
36
38
  return { message: "Backend package generated" };
37
39
  }
38
40
 
41
+ function runWithRetries(command: PostScaffoldCommand, options: CommandOptions, attempts: number, delayMs: number) {
42
+ let lastError: unknown;
43
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
44
+ try {
45
+ return run(command.command, command.args, options);
46
+ } catch (error) {
47
+ lastError = error;
48
+ if (attempt === attempts) {
49
+ break;
50
+ }
51
+ Bun.sleepSync(delayMs);
52
+ }
53
+ }
54
+ throw lastError;
55
+ }
56
+
39
57
  export function buildDeploymentVerificationCommands(
40
58
  config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime"> & Partial<Pick<ScaffoldConfig, "target">>
41
59
  ): PostScaffoldCommand[] {
@@ -59,6 +59,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
59
59
  expect(serviceConfig).toContain('service_id: "dns-api"');
60
60
  expect(serviceConfig).toContain('target: "cloudrun"');
61
61
  expect(serviceConfig).toContain('module: "buf.build/anmho/dns-api"');
62
+ expect(serviceConfig).toContain('cloudflare_vault_path: "prod/providers/cloudflare"');
62
63
  expect(serviceConfig).toContain('issuer: "https://auth.anmho.com/api/auth"');
63
64
  expect(serviceConfig).toContain('audience: "api://dns-api"');
64
65
  expect(serviceConfig).toContain('vault_path_prefix: "prod/apps/dns-api/server/oauth-clients"');
@@ -79,6 +80,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
79
80
  expect(configScript).toContain('baseBranchName: "main"');
80
81
  expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
81
82
  expect(configScript).toContain('hostname: "api.dns-api.anmho.com"');
83
+ expect(configScript).toContain('cloudflareVaultPath: "prod/providers/cloudflare"');
82
84
  expect(configScript).not.toContain("github:");
83
85
  expect(configScript).not.toContain("attachmentBucket");
84
86
 
@@ -87,6 +89,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
87
89
  expect(deployScript).toContain('projectMode === "use_existing"');
88
90
  expect(deployScript).toContain("serviceDomain");
89
91
  expect(deployScript).toContain("ensureProductionDomainMapping");
92
+ expect(deployScript).toContain("ensureCloudflareDnsRecord");
93
+ expect(deployScript).toContain("CLOUDFLARE_API_TOKEN");
90
94
  expect(deployScript).toContain('"domain-mappings",');
91
95
  expect(deployScript).toContain('"--region",');
92
96
  expect(deployScript).toContain("assertProductionDomainAvailable");
@@ -152,6 +156,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
152
156
  expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
153
157
  expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
154
158
  expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
159
+ expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
155
160
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
156
161
 
157
162
  const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
package/src/scaffold.ts CHANGED
@@ -293,6 +293,8 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
293
293
  "VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
294
294
  "VAULT_NEON_API_KEY_PATH=prod/providers/neon",
295
295
  "VAULT_NEON_API_KEY_FIELD=api_key",
296
+ "VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare",
297
+ "VAULT_CLOUDFLARE_API_TOKEN_FIELD=api_token",
296
298
  "",
297
299
  ].join("\n"),
298
300
  replacements
@@ -22,6 +22,9 @@ export const config = {
22
22
  domain: {
23
23
  hostname: "{{API_HOSTNAME}}",
24
24
  baseDomain: "{{API_BASE_DOMAIN}}",
25
+ cloudflareApiBaseUrl: "https://api.cloudflare.com/client/v4",
26
+ cloudflareVaultPath: "prod/providers/cloudflare",
27
+ cloudflareVaultField: "api_token",
25
28
  },
26
29
  auth: {
27
30
  issuer: "https://auth.anmho.com/api/auth",
@@ -42,6 +42,7 @@ type CommandResult = {
42
42
 
43
43
  const decoder = new TextDecoder();
44
44
  const encoder = new TextEncoder();
45
+ const CLOUDFLARE_DNS_TTL_AUTO = 1;
45
46
 
46
47
  export class CommandError extends Error {
47
48
  command: string;
@@ -485,12 +486,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
485
486
  if (existing) {
486
487
  const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
487
488
  if (!mappedService || mappedService === serviceName) {
489
+ ensureCloudflareDnsRecord(existing);
488
490
  return;
489
491
  }
490
492
  throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
491
493
  }
492
494
 
493
- gcloud([
495
+ const result = gcloud([
494
496
  "beta",
495
497
  "run",
496
498
  "domain-mappings",
@@ -504,6 +506,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
504
506
  "--region",
505
507
  config.region,
506
508
  ]);
509
+ const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
510
+ ensureCloudflareDnsRecord(created);
507
511
  }
508
512
 
509
513
  export function describeProductionDomainMapping():
@@ -561,6 +565,7 @@ export function assertServiceNameAvailable(serviceName: string) {
561
565
  }
562
566
 
563
567
  export function deleteProductionDomainMapping() {
568
+ deleteCloudflareDnsRecord();
564
569
  gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
565
570
  allowFailure: true,
566
571
  });
@@ -573,6 +578,165 @@ export function listCloudRunServices() {
573
578
  .filter(Boolean);
574
579
  }
575
580
 
581
+ function parseDomainMappingOutput(stdout: string) {
582
+ if (!stdout.trim().startsWith("{")) {
583
+ return undefined;
584
+ }
585
+ try {
586
+ return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
587
+ } catch {
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ function ensureCloudflareDnsRecord(
593
+ mapping:
594
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
595
+ | undefined
596
+ ) {
597
+ const desired = desiredCloudflareRecord(mapping);
598
+ const zoneId = cloudflareZoneId();
599
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
600
+ const conflicting = records.find((record) => record.type !== desired.type);
601
+ if (conflicting) {
602
+ throw new Error(
603
+ `Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
604
+ );
605
+ }
606
+ const existing = records.find((record) => record.type === desired.type);
607
+ if (!existing) {
608
+ cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
609
+ return;
610
+ }
611
+ if (existing.content === desired.content && existing.proxied === desired.proxied) {
612
+ return;
613
+ }
614
+ cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
615
+ }
616
+
617
+ function deleteCloudflareDnsRecord() {
618
+ const token = resolveCloudflareApiToken({ required: false });
619
+ if (!token) {
620
+ return;
621
+ }
622
+ const zoneId = cloudflareZoneId(token);
623
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
624
+ for (const record of records) {
625
+ cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
626
+ }
627
+ }
628
+
629
+ function desiredCloudflareRecord(
630
+ mapping:
631
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
632
+ | undefined
633
+ ) {
634
+ const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
635
+ const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
636
+ return {
637
+ type: "CNAME",
638
+ name: config.domain.hostname,
639
+ content,
640
+ ttl: CLOUDFLARE_DNS_TTL_AUTO,
641
+ proxied: false,
642
+ };
643
+ }
644
+
645
+ function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
646
+ const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
647
+ const zone = response.result?.[0] as { id?: string } | undefined;
648
+ if (!zone?.id) {
649
+ throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
650
+ }
651
+ return zone.id;
652
+ }
653
+
654
+ function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
655
+ const response = cloudflareFetch(
656
+ "GET",
657
+ `/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
658
+ undefined,
659
+ token
660
+ );
661
+ return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
662
+ }
663
+
664
+ function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
665
+ const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
666
+ method,
667
+ headers: {
668
+ authorization: `Bearer ${token}`,
669
+ "content-type": "application/json",
670
+ accept: "application/json",
671
+ },
672
+ body: body === undefined ? undefined : JSON.stringify(body),
673
+ });
674
+ if (response.status < 200 || response.status >= 300) {
675
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
676
+ }
677
+ const parsed = response.body ? JSON.parse(response.body) : {};
678
+ if (parsed.success === false) {
679
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
680
+ }
681
+ return parsed;
682
+ }
683
+
684
+ function resolveCloudflareApiToken(options: { required: boolean }) {
685
+ const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
686
+ if (direct) {
687
+ return direct;
688
+ }
689
+
690
+ const vault = Bun.which("vault");
691
+ if (vault) {
692
+ const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
693
+ const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
694
+ const result = Bun.spawnSync(
695
+ [vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
696
+ {
697
+ cwd: process.cwd(),
698
+ env: process.env,
699
+ stdout: "pipe",
700
+ stderr: "pipe",
701
+ }
702
+ );
703
+ if (result.success && result.stdout) {
704
+ return decoder.decode(result.stdout).trim();
705
+ }
706
+ }
707
+
708
+ if (!options.required) {
709
+ return "";
710
+ }
711
+ throw new Error(
712
+ [
713
+ "CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
714
+ `Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
715
+ ].join(" ")
716
+ );
717
+ }
718
+
719
+ function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
720
+ const script = [
721
+ "const url = process.argv[1];",
722
+ "const init = JSON.parse(process.argv[2]);",
723
+ "const response = await fetch(url, init);",
724
+ "const body = await response.text();",
725
+ "console.log(JSON.stringify({ status: response.status, body }));",
726
+ ].join("\n");
727
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
728
+ cwd: process.cwd(),
729
+ env: process.env,
730
+ stdout: "pipe",
731
+ stderr: "pipe",
732
+ });
733
+ if (!result.success) {
734
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
735
+ throw new Error(`Cloudflare request process failed\n${stderr}`);
736
+ }
737
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
738
+ }
739
+
576
740
  export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
577
741
  const result = gcloud(
578
742
  ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
@@ -7,6 +7,9 @@ export default {
7
7
  dns: {
8
8
  hostname: "{{API_HOSTNAME}}",
9
9
  base_domain: "{{API_BASE_DOMAIN}}",
10
+ cloudflare_api_base_url: "https://api.cloudflare.com/client/v4",
11
+ cloudflare_vault_path: "prod/providers/cloudflare",
12
+ cloudflare_vault_field: "api_token",
10
13
  },
11
14
  ownership: {
12
15
  managed_by: "create-service",
@@ -38,6 +41,7 @@ export default {
38
41
  vault: {
39
42
  mount: "secret",
40
43
  neon_path: "prod/providers/neon",
44
+ cloudflare_path: "prod/providers/cloudflare",
41
45
  grafana_path: "prod/providers/grafana",
42
46
  clerk_m2m_path: "prod/providers/clerk-m2m",
43
47
  temporal_path: "prod/providers/temporal",