create-svc 0.1.18 → 0.1.20

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.20",
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,9 @@ 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("gcloudWithRetry");
94
+ expect(deployScript).toContain("CLOUDFLARE_API_TOKEN");
90
95
  expect(deployScript).toContain('"domain-mappings",');
91
96
  expect(deployScript).toContain('"--region",');
92
97
  expect(deployScript).toContain("assertProductionDomainAvailable");
@@ -152,6 +157,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
152
157
  expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
153
158
  expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
154
159
  expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
160
+ expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
155
161
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
156
162
 
157
163
  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",
@@ -8,6 +8,7 @@ import {
8
8
  ensureProductionDomainMapping,
9
9
  ensureSecretAccessor,
10
10
  gcloud,
11
+ gcloudWithRetry,
11
12
  imageUrl,
12
13
  parseDeployArgs,
13
14
  requireCommand,
@@ -74,7 +75,7 @@ export async function deploy(args = Bun.argv.slice(2)) {
74
75
  );
75
76
 
76
77
  await runStep("Granting public invoker access", () =>
77
- gcloud([
78
+ gcloudWithRetry([
78
79
  "run",
79
80
  "services",
80
81
  "add-iam-policy-binding",
@@ -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;
@@ -114,6 +115,22 @@ export function gcloud(args: string[], options: CommandOptions = {}) {
114
115
  return run("gcloud", normalized, options);
115
116
  }
116
117
 
118
+ export function gcloudWithRetry(args: string[], options: CommandOptions = {}) {
119
+ let lastError: unknown;
120
+ for (let attempt = 1; attempt <= 12; attempt += 1) {
121
+ try {
122
+ return gcloud(args, options);
123
+ } catch (error) {
124
+ lastError = error;
125
+ if (attempt === 12 || !isRetryableGcloudError(error)) {
126
+ break;
127
+ }
128
+ Bun.sleepSync(5_000);
129
+ }
130
+ }
131
+ throw lastError;
132
+ }
133
+
117
134
  export async function runStep<T>(label: string, task: () => Promise<T> | T) {
118
135
  const indicator = spinner();
119
136
  indicator.start(label);
@@ -175,6 +192,7 @@ export function ensureServiceAccount(email: string) {
175
192
 
176
193
  const accountId = email.split("@")[0] ?? email;
177
194
  gcloud(["iam", "service-accounts", "create", accountId, "--project", config.project.id, "--display-name", accountId]);
195
+ waitForServiceAccount(email);
178
196
  }
179
197
 
180
198
  export function deleteServiceAccount(email: string) {
@@ -182,11 +200,11 @@ export function deleteServiceAccount(email: string) {
182
200
  }
183
201
 
184
202
  export function ensureProjectRole(member: string, role: string) {
185
- gcloud(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
203
+ gcloudWithRetry(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
186
204
  }
187
205
 
188
206
  export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
189
- gcloud([
207
+ gcloudWithRetry([
190
208
  "iam",
191
209
  "service-accounts",
192
210
  "add-iam-policy-binding",
@@ -228,7 +246,17 @@ export function accessSecretVersion(secretName: string) {
228
246
  }
229
247
 
230
248
  export function ensureSecretAccessor(secretName: string, member: string) {
231
- gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
249
+ gcloudWithRetry([
250
+ "secrets",
251
+ "add-iam-policy-binding",
252
+ secretName,
253
+ "--project",
254
+ config.project.id,
255
+ "--member",
256
+ member,
257
+ "--role",
258
+ "roles/secretmanager.secretAccessor",
259
+ ]);
232
260
  }
233
261
 
234
262
  export function listSecrets() {
@@ -485,12 +513,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
485
513
  if (existing) {
486
514
  const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
487
515
  if (!mappedService || mappedService === serviceName) {
516
+ ensureCloudflareDnsRecord(existing);
488
517
  return;
489
518
  }
490
519
  throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
491
520
  }
492
521
 
493
- gcloud([
522
+ const result = gcloud([
494
523
  "beta",
495
524
  "run",
496
525
  "domain-mappings",
@@ -504,6 +533,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
504
533
  "--region",
505
534
  config.region,
506
535
  ]);
536
+ const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
537
+ ensureCloudflareDnsRecord(created);
507
538
  }
508
539
 
509
540
  export function describeProductionDomainMapping():
@@ -561,6 +592,7 @@ export function assertServiceNameAvailable(serviceName: string) {
561
592
  }
562
593
 
563
594
  export function deleteProductionDomainMapping() {
595
+ deleteCloudflareDnsRecord();
564
596
  gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
565
597
  allowFailure: true,
566
598
  });
@@ -573,6 +605,190 @@ export function listCloudRunServices() {
573
605
  .filter(Boolean);
574
606
  }
575
607
 
608
+ function parseDomainMappingOutput(stdout: string) {
609
+ if (!stdout.trim().startsWith("{")) {
610
+ return undefined;
611
+ }
612
+ try {
613
+ return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
614
+ } catch {
615
+ return undefined;
616
+ }
617
+ }
618
+
619
+ function ensureCloudflareDnsRecord(
620
+ mapping:
621
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
622
+ | undefined
623
+ ) {
624
+ const desired = desiredCloudflareRecord(mapping);
625
+ const zoneId = cloudflareZoneId();
626
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
627
+ const conflicting = records.find((record) => record.type !== desired.type);
628
+ if (conflicting) {
629
+ throw new Error(
630
+ `Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
631
+ );
632
+ }
633
+ const existing = records.find((record) => record.type === desired.type);
634
+ if (!existing) {
635
+ cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
636
+ return;
637
+ }
638
+ if (existing.content === desired.content && existing.proxied === desired.proxied) {
639
+ return;
640
+ }
641
+ cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
642
+ }
643
+
644
+ function deleteCloudflareDnsRecord() {
645
+ const token = resolveCloudflareApiToken({ required: false });
646
+ if (!token) {
647
+ return;
648
+ }
649
+ const zoneId = cloudflareZoneId(token);
650
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
651
+ for (const record of records) {
652
+ cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
653
+ }
654
+ }
655
+
656
+ function desiredCloudflareRecord(
657
+ mapping:
658
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
659
+ | undefined
660
+ ) {
661
+ const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
662
+ const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
663
+ return {
664
+ type: "CNAME",
665
+ name: config.domain.hostname,
666
+ content,
667
+ ttl: CLOUDFLARE_DNS_TTL_AUTO,
668
+ proxied: false,
669
+ };
670
+ }
671
+
672
+ function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
673
+ const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
674
+ const zone = response.result?.[0] as { id?: string } | undefined;
675
+ if (!zone?.id) {
676
+ throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
677
+ }
678
+ return zone.id;
679
+ }
680
+
681
+ function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
682
+ const response = cloudflareFetch(
683
+ "GET",
684
+ `/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
685
+ undefined,
686
+ token
687
+ );
688
+ return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
689
+ }
690
+
691
+ function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
692
+ const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
693
+ method,
694
+ headers: {
695
+ authorization: `Bearer ${token}`,
696
+ "content-type": "application/json",
697
+ accept: "application/json",
698
+ },
699
+ body: body === undefined ? undefined : JSON.stringify(body),
700
+ });
701
+ if (response.status < 200 || response.status >= 300) {
702
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
703
+ }
704
+ const parsed = response.body ? JSON.parse(response.body) : {};
705
+ if (parsed.success === false) {
706
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
707
+ }
708
+ return parsed;
709
+ }
710
+
711
+ function resolveCloudflareApiToken(options: { required: boolean }) {
712
+ const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
713
+ if (direct) {
714
+ return direct;
715
+ }
716
+
717
+ const vault = Bun.which("vault");
718
+ if (vault) {
719
+ const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
720
+ const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
721
+ const result = Bun.spawnSync(
722
+ [vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
723
+ {
724
+ cwd: process.cwd(),
725
+ env: process.env,
726
+ stdout: "pipe",
727
+ stderr: "pipe",
728
+ }
729
+ );
730
+ if (result.success && result.stdout) {
731
+ return decoder.decode(result.stdout).trim();
732
+ }
733
+ }
734
+
735
+ if (!options.required) {
736
+ return "";
737
+ }
738
+ throw new Error(
739
+ [
740
+ "CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
741
+ `Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
742
+ ].join(" ")
743
+ );
744
+ }
745
+
746
+ function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
747
+ const script = [
748
+ "const url = process.argv[1];",
749
+ "const init = JSON.parse(process.argv[2]);",
750
+ "const response = await fetch(url, init);",
751
+ "const body = await response.text();",
752
+ "console.log(JSON.stringify({ status: response.status, body }));",
753
+ ].join("\n");
754
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
755
+ cwd: process.cwd(),
756
+ env: process.env,
757
+ stdout: "pipe",
758
+ stderr: "pipe",
759
+ });
760
+ if (!result.success) {
761
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
762
+ throw new Error(`Cloudflare request process failed\n${stderr}`);
763
+ }
764
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
765
+ }
766
+
767
+ function waitForServiceAccount(email: string) {
768
+ for (let attempt = 1; attempt <= 12; attempt += 1) {
769
+ if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.project.id], { allowFailure: true }).success) {
770
+ return;
771
+ }
772
+ Bun.sleepSync(5_000);
773
+ }
774
+ throw new Error(`service account ${email} was created but is not yet readable`);
775
+ }
776
+
777
+ function isRetryableGcloudError(error: unknown) {
778
+ if (!(error instanceof CommandError)) {
779
+ return false;
780
+ }
781
+ const output = `${error.stdout}\n${error.stderr}`.toLowerCase();
782
+ return (
783
+ output.includes("does not exist") ||
784
+ output.includes("not found") ||
785
+ output.includes("permission denied") ||
786
+ output.includes("failed_precondition") ||
787
+ output.includes("try again") ||
788
+ output.includes("retry")
789
+ );
790
+ }
791
+
576
792
  export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
577
793
  const result = gcloud(
578
794
  ["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",