create-svc 0.1.9 → 0.1.11

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 (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -1,5 +1,5 @@
1
- import { config, githubVariables } from "./config";
2
- import { ensureDatabase, getConnectionUri } from "./neon";
1
+ import { config } from "./config";
2
+ import { ensureDatabase, getConnectionUri, resolveNeonConfig } from "./neon";
3
3
  import {
4
4
  addSecretVersion,
5
5
  attachBilling,
@@ -8,79 +8,61 @@ import {
8
8
  ensureProjectRole,
9
9
  ensureSecretAccessor,
10
10
  ensureServiceAccount,
11
- ensureServiceAccountRole,
12
- ensureWorkloadIdentityPool,
13
- ensureWorkloadIdentityProvider,
14
11
  gcloud,
15
12
  requireCommand,
13
+ requireGcloudAuth,
16
14
  resolveDeploymentTarget,
15
+ resolveTemporalRuntimeConfig,
17
16
  runMain,
18
17
  runStep,
19
- setGithubVariable,
20
- workloadIdentityPoolResource,
21
- workloadIdentityProviderResource,
22
18
  } from "./lib";
23
19
 
24
20
  export async function bootstrap() {
25
21
  requireCommand("gcloud");
26
- requireCommand("gh");
22
+ requireGcloudAuth();
27
23
 
28
24
  await runStep("Ensuring GCP project", () => ensureProject());
29
25
  await runStep("Attaching billing", () => attachBilling());
30
26
  await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
31
27
 
32
- await runStep("Ensuring runtime and deployer service accounts", () => {
28
+ await runStep("Ensuring runtime service account", () => {
33
29
  ensureServiceAccount(config.runtimeServiceAccount);
34
- ensureServiceAccount(config.deployerServiceAccount);
35
30
  });
36
31
 
37
32
  await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
38
-
39
33
  await runStep("Granting project roles", () => {
40
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/run.admin");
41
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/cloudbuild.builds.editor");
42
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/artifactregistry.writer");
43
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/serviceusage.serviceUsageConsumer");
44
34
  ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
45
- ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
46
35
  });
47
36
 
48
- await runStep("Ensuring Workload Identity setup", () => {
49
- ensureWorkloadIdentityPool();
50
- ensureWorkloadIdentityProvider();
51
- ensureServiceAccountRole(
52
- config.deployerServiceAccount,
53
- `principalSet://iam.googleapis.com/${workloadIdentityPoolResource()}/attribute.repository/${config.github.repo}`,
54
- "roles/iam.workloadIdentityUser"
55
- );
56
- });
57
-
58
- if (!config.neon.projectId || !config.neon.baseBranchId) {
59
- throw new Error("Neon project and base branch must be configured before bootstrap");
60
- }
37
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
61
38
 
62
39
  const target = resolveDeploymentTarget("main");
63
- await runStep("Ensuring Neon database", () => ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName));
40
+ await runStep("Ensuring Neon database", () => ensureDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
64
41
 
65
42
  await runStep("Publishing database secret", async () => {
66
43
  const connectionUri = await getConnectionUri(
67
- config.neon.projectId,
68
- config.neon.baseBranchId,
69
- config.neon.databaseName,
70
- config.neon.roleName
44
+ neon.projectId,
45
+ neon.baseBranchId,
46
+ neon.databaseName,
47
+ neon.roleName
71
48
  );
72
49
  addSecretVersion(target.databaseSecretName, connectionUri);
73
50
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
74
51
  });
75
52
 
76
- await runStep("Configuring GitHub repository variables", () => {
77
- for (const [name, value] of Object.entries(githubVariables)) {
78
- setGithubVariable(name, value);
79
- }
53
+ await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
54
+ }
80
55
 
81
- setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
82
- setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
83
- });
56
+ function publishTemporalSecrets() {
57
+ const temporal = resolveTemporalRuntimeConfig();
58
+ const apiKey = process.env.TEMPORAL_API_KEY?.trim();
59
+ if (!apiKey || !temporal.apiKeySecretName) {
60
+ return "No Temporal API key configured";
61
+ }
62
+
63
+ addSecretVersion(temporal.apiKeySecretName, apiKey);
64
+ ensureSecretAccessor(temporal.apiKeySecretName, `serviceAccount:${config.runtimeServiceAccount}`);
65
+ return temporal.apiKeySecretName;
84
66
  }
85
67
 
86
68
  if (import.meta.main) {
@@ -1,18 +1,22 @@
1
- import { log } from "@clack/prompts";
2
- import { config, githubVariables } from "./config";
3
- import { deleteBranch, deleteDatabase, listBranches } from "./neon";
1
+ import { confirm, isCancel, log } from "@clack/prompts";
2
+ import { config } from "./config";
3
+ import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
4
4
  import {
5
- deleteGithubRepository,
6
- deleteGithubVariable,
5
+ assertOwnedResource,
7
6
  deleteProject,
7
+ deleteProductionDomainMapping,
8
8
  deleteSecret,
9
9
  deleteService,
10
10
  deleteServiceAccount,
11
- deleteWorkloadIdentityProvider,
11
+ describeCloudRunService,
12
+ describeProductionDomainMapping,
13
+ describeSecret,
12
14
  listCloudRunServices,
13
15
  listSecrets,
14
16
  parseCleanupArgs,
15
17
  requireCommand,
18
+ requireGcloudAuth,
19
+ run,
16
20
  runMain,
17
21
  runStep,
18
22
  } from "./lib";
@@ -22,18 +26,29 @@ function matchesServiceResource(name: string) {
22
26
  }
23
27
 
24
28
  function matchesSecretResource(name: string) {
25
- return name === `${config.serviceName}-database-url` || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
29
+ return (
30
+ name === `${config.serviceName}-database-url` ||
31
+ name === config.temporal.apiKeySecretName ||
32
+ name.startsWith(`${config.serviceName}-pr-`) ||
33
+ name.startsWith(`${config.serviceName}-dev-`)
34
+ );
26
35
  }
27
36
 
28
37
  export async function cleanup(args = Bun.argv.slice(2)) {
29
38
  requireCommand("gcloud");
39
+ requireGcloudAuth();
30
40
 
31
41
  const options = parseCleanupArgs(args);
42
+ await requireDestroyConfirmation(options.force);
43
+
44
+ await runStep(`Verifying production domain mapping ${config.domain.hostname}`, () => assertProductionDomainMappingOwned());
45
+ await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
32
46
 
33
47
  const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
34
48
  const serviceNames = services.filter(matchesServiceResource);
35
49
  await runStep("Deleting Cloud Run services", () => {
36
50
  for (const serviceName of serviceNames) {
51
+ assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
37
52
  deleteService(serviceName);
38
53
  }
39
54
  });
@@ -42,59 +57,90 @@ export async function cleanup(args = Bun.argv.slice(2)) {
42
57
  const secretNames = secrets.filter(matchesSecretResource);
43
58
  await runStep("Deleting service secrets", () => {
44
59
  for (const secretName of secretNames) {
60
+ assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
45
61
  deleteSecret(secretName);
46
62
  }
47
63
  });
48
64
 
49
- if (config.neon.projectId && config.neon.baseBranchId) {
50
- const branches = await runStep("Finding Neon branches", () => listBranches(config.neon.projectId));
65
+ try {
66
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
67
+ const branches = await runStep("Finding Neon branches", () => listBranches(neon.projectId));
51
68
  const disposableBranches = branches.filter(
52
- (branch) => branch.name.startsWith(`${config.neon.previewBranchPrefix}-`) || branch.name.startsWith(`${config.neon.personalBranchPrefix}-`)
69
+ (branch: { name: string }) =>
70
+ branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
53
71
  );
54
72
 
55
73
  await runStep("Deleting Neon preview and personal branches", async () => {
56
74
  for (const branch of disposableBranches) {
57
- await deleteBranch(config.neon.projectId, branch.id);
75
+ await deleteBranch(neon.projectId, branch.id);
58
76
  }
59
77
  });
60
78
 
61
- await runStep("Deleting Neon service database", () =>
62
- deleteDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName)
63
- );
64
- } else {
79
+ await runStep("Deleting Neon service database", () => deleteDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
80
+ } catch (error) {
65
81
  log.step("Skipping Neon cleanup because Neon is not configured");
82
+ log.step(error instanceof Error ? error.message : String(error));
66
83
  }
67
84
 
85
+ await runStep("Deleting Grafana resources", async () => deleteGrafanaResources());
86
+
68
87
  await runStep("Deleting service-specific identity resources", () => {
69
- deleteWorkloadIdentityProvider();
70
88
  deleteServiceAccount(config.runtimeServiceAccount);
71
- deleteServiceAccount(config.deployerServiceAccount);
72
89
  });
73
90
 
74
- if (Bun.which("gh")) {
75
- await runStep("Deleting GitHub repository variables", () => {
76
- for (const name of [...Object.keys(githubVariables), "GCP_WIF_PROVIDER", "GCP_DEPLOYER_SERVICE_ACCOUNT"]) {
77
- deleteGithubVariable(name);
78
- }
79
- });
80
-
81
- if (options.destroyRepo) {
82
- await runStep(`Deleting GitHub repository ${config.github.repo}`, () => deleteGithubRepository());
83
- }
84
- } else if (options.destroyRepo) {
85
- throw new Error("gh is required to delete the GitHub repository");
86
- } else {
87
- log.step("Skipping GitHub cleanup because gh is not installed");
88
- }
89
-
90
91
  if (options.destroyProject) {
91
92
  await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
92
93
  return `Deleted project ${config.project.id}`;
93
94
  }
94
95
 
95
- return `Cleanup finished for ${config.serviceName}`;
96
+ log.step(`Production API hostname released: ${config.domain.hostname}`);
97
+ return `Destroy finished for ${config.serviceName}`;
98
+ }
99
+
100
+ async function deleteGrafanaResources() {
101
+ if (!(await Bun.file("./grafana").exists())) {
102
+ return "No grafana directory configured";
103
+ }
104
+ if (!Bun.which("gcx")) {
105
+ return "gcx is not installed; Grafana resources were not deleted";
106
+ }
107
+
108
+ run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"]);
109
+ return "Grafana resources deleted from local manifests";
110
+ }
111
+
112
+ function assertProductionDomainMappingOwned() {
113
+ const mapping = describeProductionDomainMapping();
114
+ if (!mapping) {
115
+ return;
116
+ }
117
+
118
+ const routeName = mapping.spec?.routeName;
119
+ if (routeName !== config.serviceName) {
120
+ throw new Error(`${config.domain.hostname} maps to ${routeName || "an unknown service"}; refusing to delete ambiguous DNS mapping`);
121
+ }
122
+
123
+ assertOwnedResource(`Cloud Run service ${routeName}`, describeCloudRunService(routeName));
124
+ }
125
+
126
+ async function requireDestroyConfirmation(force: boolean) {
127
+ if (force) {
128
+ return;
129
+ }
130
+
131
+ if (!process.stdin.isTTY) {
132
+ throw new Error("service destroy requires --force when running non-interactively");
133
+ }
134
+
135
+ const answer = await confirm({
136
+ message: `Destroy resources owned by ${config.serviceName}?`,
137
+ initialValue: false,
138
+ });
139
+ if (isCancel(answer) || !answer) {
140
+ throw new Error("Destroy cancelled");
141
+ }
96
142
  }
97
143
 
98
144
  if (import.meta.main) {
99
- await runMain("Cleanup", () => cleanup(Bun.argv.slice(2)));
145
+ await runMain("Destroy", () => cleanup(Bun.argv.slice(2)));
100
146
  }
@@ -1,17 +1,43 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { mkdir } from "node:fs/promises";
4
+ import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
3
5
  import { bootstrap } from "./bootstrap";
4
6
  import { cleanup } from "./cleanup";
5
7
  import { deploy } from "./deploy";
6
- import { runMain } from "./lib";
8
+ import { config } from "./config";
9
+ import {
10
+ accessSecretVersion,
11
+ assertProductionDomainAvailable,
12
+ assertServiceNameAvailable,
13
+ describeProductionDomainMapping,
14
+ formatError,
15
+ gcloud,
16
+ ensureProductionDomainMapping,
17
+ requireCommand,
18
+ requireGcloudAuth,
19
+ resolveDeploymentTarget,
20
+ run,
21
+ runMain,
22
+ runStep,
23
+ serviceOrigin,
24
+ } from "./lib";
7
25
 
8
26
  async function main(argv = Bun.argv.slice(2)) {
9
27
  const [command, ...rest] = argv;
10
28
 
11
- if (command === "bootstrap") {
12
- await runMain("Bootstrap", async () => {
29
+ if (command === "create") {
30
+ await runMain("Create", async () => {
31
+ assertServiceNameAvailable(config.serviceName);
32
+ assertProductionDomainAvailable(config.serviceName);
33
+ await runStep("Registering auth resource server", () => ensureAuthResourceServer());
13
34
  await bootstrap();
14
- return "Bootstrap finished";
35
+ const target = resolveDeploymentTarget("main");
36
+ const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
37
+ await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
38
+ const origin = await deploy(["--ci"]);
39
+ await runOptionalBunScript("seed", { DATABASE_URL: databaseUrl });
40
+ return `Created ${origin}`;
15
41
  });
16
42
  return;
17
43
  }
@@ -21,12 +47,303 @@ async function main(argv = Bun.argv.slice(2)) {
21
47
  return;
22
48
  }
23
49
 
24
- if (command === "cleanup") {
25
- await runMain("Cleanup", () => cleanup(rest));
50
+ if (command === "migrate") {
51
+ await runMain("Migrate", () => runLanguageTask("migrate"));
26
52
  return;
27
53
  }
28
54
 
29
- throw new Error("Usage: svc-cloudrun <bootstrap|deploy|cleanup> [args]");
55
+ if (command === "seed") {
56
+ await runMain("Seed", () => runOptionalBunScript("seed"));
57
+ return;
58
+ }
59
+
60
+ if (command === "dashboards") {
61
+ await runMain("Dashboards", () => runDashboards());
62
+ return;
63
+ }
64
+
65
+ if (command === "dns") {
66
+ await runMain("DNS", () => repairDns());
67
+ return;
68
+ }
69
+
70
+ if (command === "doctor") {
71
+ await runMain("Doctor", () => runDoctor());
72
+ return;
73
+ }
74
+
75
+ if (command === "auth") {
76
+ await runMain("Auth", () => runAuthCommand(rest));
77
+ return;
78
+ }
79
+
80
+ if (command === "destroy") {
81
+ await runMain("Destroy", () => cleanup(rest));
82
+ return;
83
+ }
84
+
85
+ if (command === "sdk") {
86
+ await runMain("SDK", () => runSdk(rest));
87
+ return;
88
+ }
89
+
90
+ throw new Error("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
91
+ }
92
+
93
+ function runLanguageTask(task: "migrate", env?: Record<string, string | undefined>) {
94
+ if (config.runtime === "bun") {
95
+ run("bun", ["run", `./scripts/${task}.ts`], { env });
96
+ return `${task} finished`;
97
+ }
98
+
99
+ if (task === "migrate") {
100
+ run("make", ["migrate"], { env });
101
+ return `${task} finished`;
102
+ }
103
+
104
+ throw new Error(`${task} is not available for ${config.runtime}`);
105
+ }
106
+
107
+ async function runOptionalBunScript(name: string, env?: Record<string, string | undefined>) {
108
+ const scriptPath = `./scripts/${name}.ts`;
109
+ if (!(await Bun.file(scriptPath).exists())) {
110
+ return `${name} script is not configured`;
111
+ }
112
+
113
+ run("bun", ["run", scriptPath], { env });
114
+ return `${name} finished`;
115
+ }
116
+
117
+ function runDashboards() {
118
+ requireCommand("gcx");
119
+ run("gcx", ["dev", "lint", "run", "./grafana", "-o", "compact"]);
120
+ run("gcx", ["resources", "push", "--path", "./grafana"]);
121
+ return "Dashboards pushed";
122
+ }
123
+
124
+ function repairDns() {
125
+ ensureProductionDomainMapping(config.serviceName);
126
+ return `DNS mapping ready for https://${config.domain.hostname}`;
127
+ }
128
+
129
+ async function runDoctor() {
130
+ const results: Array<{ name: string; status: "pass" | "warn" | "fail"; detail: string }> = [];
131
+ const target = resolveDeploymentTarget("main");
132
+
133
+ await record(results, "bun CLI", "fail", () => checkCommand("bun"));
134
+ await record(results, "gcloud CLI", "fail", () => checkCommand("gcloud"));
135
+ await record(results, "gcloud auth", "fail", () => {
136
+ requireGcloudAuth();
137
+ return "active account available";
138
+ });
139
+ await record(results, "GCP project", "fail", () => {
140
+ gcloud(["projects", "describe", config.project.id, "--format=value(projectId)"]);
141
+ return config.project.id;
142
+ });
143
+ await record(results, "Cloud Run service", "fail", () => {
144
+ const serviceName = gcloud([
145
+ "run",
146
+ "services",
147
+ "describe",
148
+ target.serviceName,
149
+ "--project",
150
+ config.project.id,
151
+ "--region",
152
+ config.region,
153
+ "--format=value(metadata.name)",
154
+ ]).stdout;
155
+ return serviceName || target.serviceName;
156
+ });
157
+ await record(results, "runtime database secret", "fail", () => {
158
+ const value = accessSecretVersion(target.databaseSecretName);
159
+ if (!value.startsWith("postgres://") && !value.startsWith("postgresql://")) {
160
+ throw new Error(`${target.databaseSecretName} does not look like a Postgres URL`);
161
+ }
162
+ return target.databaseSecretName;
163
+ });
164
+ await record(results, "DNS mapping", "fail", () => {
165
+ const mapping = describeProductionDomainMapping();
166
+ const mappedService = mapping?.spec?.routeName;
167
+ if (mappedService !== target.serviceName) {
168
+ throw new Error(`${config.domain.hostname} maps to ${mappedService || "nothing"}, expected ${target.serviceName}`);
169
+ }
170
+ return `${config.domain.hostname} -> ${target.serviceName}`;
171
+ });
172
+ await record(results, "deployment health", "fail", async () => {
173
+ const response = await fetchWithTimeout(`${serviceOrigin(target)}/healthz`, 5_000);
174
+ if (!response.ok) {
175
+ throw new Error(`GET /healthz returned ${response.status}`);
176
+ }
177
+ return "GET /healthz ok";
178
+ });
179
+ await record(results, "migration assets", "fail", async () => {
180
+ if (!(await Bun.file("./migrations/0000_init.sql").exists())) {
181
+ throw new Error("missing migrations/0000_init.sql");
182
+ }
183
+ return "migrations/0000_init.sql";
184
+ });
185
+ if ((config.runtime as string) === "go") {
186
+ await record(results, "Atlas CLI", "fail", () => checkCommand("atlas"));
187
+ await record(results, "Atlas config", "fail", async () => {
188
+ if (!(await Bun.file("./atlas.hcl").exists())) {
189
+ throw new Error("missing atlas.hcl");
190
+ }
191
+ return "atlas.hcl";
192
+ });
193
+ }
194
+ await record(results, "dashboard tooling", "warn", () => {
195
+ if (!Bun.which("gcx")) {
196
+ throw new Error("gcx is not installed");
197
+ }
198
+ return "gcx available";
199
+ });
200
+ await record(results, "dashboard artifacts", "warn", async () => {
201
+ if (!(await Bun.file("./grafana").exists()) && !(await Bun.file("./dashboards").exists())) {
202
+ throw new Error("no grafana/ or dashboards/ directory found");
203
+ }
204
+ return "dashboard directory found";
205
+ });
206
+ await record(results, "authctl", "warn", () => runAuthDoctor().detail);
207
+ await record(results, "Temporal/Cron", "warn", async () => {
208
+ const hasBunTemporal = await Bun.file("./src/temporal/worker.ts").exists();
209
+ const hasGoTemporal = await Bun.file("./internal/temporal/worker.go").exists();
210
+ if (!hasBunTemporal && !hasGoTemporal) {
211
+ throw new Error("Temporal worker config is not present in this scaffold yet");
212
+ }
213
+ return "Temporal worker config present";
214
+ });
215
+
216
+ if ((config.framework as string) === "connectrpc") {
217
+ await record(results, "ConnectRPC proto", "fail", async () => {
218
+ if (!(await Bun.file("./buf.yaml").exists())) {
219
+ throw new Error("missing buf.yaml");
220
+ }
221
+ if (!(await Bun.file("./protos/waitlist/v1/waitlist.proto").exists())) {
222
+ throw new Error("missing waitlist proto");
223
+ }
224
+ return "waitlist proto present";
225
+ });
226
+ await record(results, "Buf CLI", "warn", () => checkCommand("buf"));
227
+ await record(results, "generated SDK artifacts", "warn", async () => {
228
+ const bunGen = await Bun.file("./gen/protos/waitlist/v1/waitlist_pb.ts").exists();
229
+ const goGen = await Bun.file("./gen/waitlist/v1/waitlist.pb.go").exists();
230
+ if (!bunGen && !goGen) {
231
+ throw new Error("generated SDK artifacts are missing; run service sdk build");
232
+ }
233
+ return "local generated artifacts present";
234
+ });
235
+ await record(results, "SDK mode", "warn", async () => {
236
+ const text = await Bun.file(".service/sdk.json").text();
237
+ const state = JSON.parse(text) as { mode?: string; module?: string };
238
+ if (state.mode !== "local" && state.mode !== "remote") {
239
+ throw new Error("SDK mode must be local or remote");
240
+ }
241
+ return `${state.mode}: ${state.module || bufModule()}`;
242
+ });
243
+ }
244
+
245
+ const output = results.map(formatDoctorResult).join("\n");
246
+ const failures = results.filter((result) => result.status === "fail");
247
+ if (failures.length > 0) {
248
+ throw new Error(`Doctor found ${failures.length} failing check(s)\n${output}`);
249
+ }
250
+ return output;
251
+ }
252
+
253
+ async function record(
254
+ results: Array<{ name: string; status: "pass" | "warn" | "fail"; detail: string }>,
255
+ name: string,
256
+ failureStatus: "warn" | "fail",
257
+ check: () => string | Promise<string>
258
+ ) {
259
+ try {
260
+ results.push({ name, status: "pass", detail: await check() });
261
+ } catch (error) {
262
+ results.push({ name, status: failureStatus, detail: formatError(error) });
263
+ }
264
+ }
265
+
266
+ function checkCommand(name: string) {
267
+ const path = Bun.which(name);
268
+ if (!path) {
269
+ throw new Error(`${name} is not installed`);
270
+ }
271
+ return path;
272
+ }
273
+
274
+ async function fetchWithTimeout(url: string, timeoutMs: number) {
275
+ return await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
276
+ }
277
+
278
+ function formatDoctorResult(result: { name: string; status: "pass" | "warn" | "fail"; detail: string }) {
279
+ const marker = result.status === "pass" ? "PASS" : result.status === "warn" ? "WARN" : "FAIL";
280
+ return `[${marker}] ${result.name}: ${result.detail}`;
281
+ }
282
+
283
+ async function runSdk(args: string[]) {
284
+ if ((config.framework as string) !== "connectrpc") {
285
+ throw new Error("SDK commands are only available for ConnectRPC services");
286
+ }
287
+
288
+ const [subcommand] = args;
289
+ if (subcommand === "publish") {
290
+ requireCommand("buf");
291
+ run("buf", ["push"]);
292
+ return "Schema pushed to Buf Schema Registry";
293
+ }
294
+
295
+ if (subcommand === "build") {
296
+ if (config.runtime === "bun") {
297
+ run("bun", ["run", "gen"]);
298
+ } else {
299
+ run("make", ["gen"]);
300
+ }
301
+ await writeSdkMode("local");
302
+ return "Local SDK artifacts generated and selected";
303
+ }
304
+
305
+ if (subcommand === "use-local") {
306
+ await assertLocalSdkArtifacts();
307
+ await writeSdkMode("local");
308
+ return "Local SDK artifacts selected";
309
+ }
310
+
311
+ if (subcommand === "use-remote") {
312
+ await writeSdkMode("remote");
313
+ return `Remote Buf SDK selected: ${bufModule()}`;
314
+ }
315
+
316
+ throw new Error("Usage: service sdk <build|publish|use-local|use-remote>");
317
+ }
318
+
319
+ async function assertLocalSdkArtifacts() {
320
+ const bunArtifacts = await Bun.file("./gen/protos/waitlist/v1/waitlist_pb.ts").exists();
321
+ const goArtifacts = await Bun.file("./gen/waitlist/v1/waitlist.pb.go").exists();
322
+ if (!bunArtifacts && !goArtifacts) {
323
+ throw new Error("Local SDK artifacts are missing. Run `service sdk build` first.");
324
+ }
325
+ }
326
+
327
+ async function writeSdkMode(mode: "local" | "remote") {
328
+ await mkdir(".service", { recursive: true });
329
+ const localPath = config.runtime === "bun" ? "./gen/protos/waitlist/v1" : "./gen/waitlist/v1";
330
+ await Bun.write(
331
+ ".service/sdk.json",
332
+ `${JSON.stringify(
333
+ {
334
+ mode,
335
+ module: bufModule(),
336
+ localPath,
337
+ updatedAt: new Date().toISOString(),
338
+ },
339
+ null,
340
+ 2
341
+ )}\n`
342
+ );
343
+ }
344
+
345
+ function bufModule() {
346
+ return `buf.build/anmho/${config.serviceName}`;
30
347
  }
31
348
 
32
349
  if (import.meta.main) {