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,13 +1,16 @@
1
1
  export const config = {
2
2
  serviceName: "{{SERVICE_NAME}}",
3
+ profile: "{{PROFILE}}",
4
+ example: {
5
+ kind: "{{EXAMPLE_KIND}}",
6
+ domain: "{{EXAMPLE_DOMAIN}}",
7
+ label: "{{EXAMPLE_LABEL}}",
8
+ },
3
9
  runtime: "{{RUNTIME}}",
4
10
  framework: "{{FRAMEWORK}}",
5
11
  region: "{{REGION}}",
6
12
  artifactRepository: "cloud-run",
7
13
  runtimeServiceAccount: "{{RUNTIME_SERVICE_ACCOUNT}}",
8
- deployerServiceAccount: "{{DEPLOYER_SERVICE_ACCOUNT}}",
9
- workloadIdentityPoolId: "{{WIF_POOL_ID}}",
10
- workloadIdentityProviderId: "{{WIF_PROVIDER_ID}}",
11
14
  project: {
12
15
  mode: "{{GCP_PROJECT_MODE}}",
13
16
  id: "{{PROJECT_ID}}",
@@ -16,10 +19,21 @@ export const config = {
16
19
  billingAccount: "{{BILLING_ACCOUNT}}",
17
20
  quotaProjectId: "{{QUOTA_PROJECT_ID}}",
18
21
  },
19
- github: {
20
- repo: "{{GITHUB_REPO}}",
21
- visibility: "{{GITHUB_VISIBILITY}}",
22
- createIfMissing: {{GITHUB_CREATE_IF_MISSING}},
22
+ domain: {
23
+ hostname: "{{API_HOSTNAME}}",
24
+ baseDomain: "{{API_BASE_DOMAIN}}",
25
+ },
26
+ auth: {
27
+ issuer: "https://auth.anmho.com/api/auth",
28
+ audience: "api://{{SERVICE_ID}}",
29
+ jwksUrl: "https://auth.anmho.com/api/auth/jwks",
30
+ },
31
+ temporal: {
32
+ enabled: false,
33
+ address: "localhost:7233",
34
+ namespace: "default",
35
+ taskQueue: "{{SERVICE_ID}}",
36
+ apiKeySecretName: "{{SERVICE_ID}}-temporal-api-key",
23
37
  },
24
38
  neon: {
25
39
  projectId: "{{NEON_PROJECT_ID}}",
@@ -42,16 +56,4 @@ export const config = {
42
56
  ],
43
57
  } as const;
44
58
 
45
- export const githubVariables = {
46
- GCP_PROJECT_ID: "{{PROJECT_ID}}",
47
- GCP_REGION: "{{REGION}}",
48
- CLOUD_RUN_SERVICE: "{{SERVICE_NAME}}",
49
- CREATE_SVC_RUNTIME: "{{RUNTIME}}",
50
- CREATE_SVC_FRAMEWORK: "{{FRAMEWORK}}",
51
- NEON_PROJECT_ID: "{{NEON_PROJECT_ID}}",
52
- NEON_BASE_BRANCH_ID: "{{NEON_BASE_BRANCH_ID}}",
53
- NEON_DATABASE_NAME: "{{NEON_DATABASE_NAME}}",
54
- } as const;
55
-
56
59
  export type DeployEnvironment = "main" | "preview" | "personal";
57
-
@@ -1,10 +1,11 @@
1
1
  import { config } from "./config";
2
2
  import { bootstrap } from "./bootstrap";
3
- import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches } from "./neon";
3
+ import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches, resolveNeonConfig } from "./neon";
4
4
  import {
5
5
  addSecretVersion,
6
6
  deleteService,
7
7
  ensureArtifactRepository,
8
+ ensureProductionDomainMapping,
8
9
  ensureSecretAccessor,
9
10
  gcloud,
10
11
  imageUrl,
@@ -13,7 +14,7 @@ import {
13
14
  resolveDeploymentTarget,
14
15
  runMain,
15
16
  runStep,
16
- serviceUrl,
17
+ serviceOrigin,
17
18
  writeRenderedManifest,
18
19
  } from "./lib";
19
20
 
@@ -27,6 +28,7 @@ export async function deploy(args = Bun.argv.slice(2)) {
27
28
  }
28
29
 
29
30
  const target = resolveDeploymentTarget(options.environment, options.name);
31
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
30
32
 
31
33
  if (options.destroy) {
32
34
  if (options.environment === "main") {
@@ -35,10 +37,10 @@ export async function deploy(args = Bun.argv.slice(2)) {
35
37
 
36
38
  await runStep(`Deleting Cloud Run service ${target.serviceName}`, () => deleteService(target.serviceName));
37
39
  await runStep(`Deleting Neon branch ${target.branchName}`, async () => {
38
- const branches = await listBranches(config.neon.projectId);
39
- const branch = branches.find((candidate) => candidate.name === target.branchName);
40
+ const branches = await listBranches(neon.projectId);
41
+ const branch = branches.find((candidate: { name: string }) => candidate.name === target.branchName);
40
42
  if (branch) {
41
- await deleteBranch(config.neon.projectId, branch.id);
43
+ await deleteBranch(neon.projectId, branch.id);
42
44
  }
43
45
  });
44
46
  return `Destroyed ${target.serviceName}`;
@@ -46,21 +48,20 @@ export async function deploy(args = Bun.argv.slice(2)) {
46
48
 
47
49
  await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
48
50
 
49
- let branchId = config.neon.baseBranchId;
51
+ let branchId: string = neon.baseBranchId;
50
52
  if (options.environment !== "main") {
51
53
  const branch = await runStep(`Ensuring Neon branch ${target.branchName}`, () =>
52
- ensureBranch(config.neon.projectId, target.branchName, config.neon.baseBranchId)
54
+ ensureBranch(neon.projectId, target.branchName, neon.baseBranchId)
53
55
  );
54
56
  branchId = branch.id;
55
57
  }
56
58
 
57
59
  await runStep("Publishing environment database secret", async () => {
58
- await ensureDatabase(config.neon.projectId, branchId, config.neon.databaseName);
59
- const connectionUri = await getConnectionUri(config.neon.projectId, branchId, config.neon.databaseName, config.neon.roleName);
60
+ await ensureDatabase(neon.projectId, branchId, neon.databaseName);
61
+ const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
60
62
  addSecretVersion(target.databaseSecretName, connectionUri);
61
63
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
62
64
  });
63
-
64
65
  const image = imageUrl();
65
66
  await runStep("Building container image", () =>
66
67
  gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
@@ -89,7 +90,11 @@ export async function deploy(args = Bun.argv.slice(2)) {
89
90
  ])
90
91
  );
91
92
 
92
- return serviceUrl(target.serviceName);
93
+ if (target.environment === "main") {
94
+ await runStep(`Ensuring production domain mapping for ${config.domain.hostname}`, () => ensureProductionDomainMapping(target.serviceName));
95
+ }
96
+
97
+ return serviceOrigin(target);
93
98
  }
94
99
 
95
100
  if (import.meta.main) {
@@ -4,6 +4,7 @@ import { config } from "./config";
4
4
  type CommandOptions = {
5
5
  allowFailure?: boolean;
6
6
  input?: string;
7
+ env?: Record<string, string | undefined>;
7
8
  };
8
9
 
9
10
  type DeployArgs = {
@@ -15,16 +16,23 @@ type DeployArgs = {
15
16
 
16
17
  type CleanupArgs = {
17
18
  destroyProject: boolean;
18
- destroyRepo: boolean;
19
+ force: boolean;
19
20
  };
20
21
 
21
- type DeploymentTarget = {
22
+ export type DeploymentTarget = {
22
23
  environment: "main" | "preview" | "personal";
23
24
  serviceName: string;
24
25
  branchName: string;
25
26
  databaseSecretName: string;
26
27
  };
27
28
 
29
+ type GcpResourceWithLabels = {
30
+ metadata?: {
31
+ labels?: Record<string, string>;
32
+ };
33
+ labels?: Record<string, string>;
34
+ };
35
+
28
36
  type CommandResult = {
29
37
  success: boolean;
30
38
  stdout: string;
@@ -33,6 +41,7 @@ type CommandResult = {
33
41
  };
34
42
 
35
43
  const decoder = new TextDecoder();
44
+ const encoder = new TextEncoder();
36
45
 
37
46
  export class CommandError extends Error {
38
47
  command: string;
@@ -58,11 +67,27 @@ export function requireCommand(name: string) {
58
67
  }
59
68
  }
60
69
 
70
+ export function requireGcloudAuth() {
71
+ const activeAccount = gcloud(["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"], {
72
+ allowFailure: true,
73
+ }).stdout.trim();
74
+
75
+ if (!activeAccount) {
76
+ throw new Error(
77
+ [
78
+ "gcloud is installed but no active Google Cloud account is available.",
79
+ "Run `gcloud auth login` on this machine before using service create, deploy, doctor, dns, or destroy.",
80
+ "If you also rely on Application Default Credentials for other tooling, run `gcloud auth application-default login` as well.",
81
+ ].join(" ")
82
+ );
83
+ }
84
+ }
85
+
61
86
  export function run(command: string, args: string[], options: CommandOptions = {}): CommandResult {
62
87
  const result = Bun.spawnSync([command, ...args], {
63
88
  cwd: process.cwd(),
64
- env: process.env,
65
- stdin: options.input,
89
+ env: { ...process.env, ...options.env },
90
+ stdin: options.input === undefined ? undefined : encoder.encode(options.input),
66
91
  stdout: "pipe",
67
92
  stderr: "pipe",
68
93
  });
@@ -89,10 +114,6 @@ export function gcloud(args: string[], options: CommandOptions = {}) {
89
114
  return run("gcloud", normalized, options);
90
115
  }
91
116
 
92
- export function gh(args: string[], options: CommandOptions = {}) {
93
- return run("gh", args, options);
94
- }
95
-
96
117
  export async function runStep<T>(label: string, task: () => Promise<T> | T) {
97
118
  const indicator = spinner();
98
119
  indicator.start(label);
@@ -107,7 +128,7 @@ export async function runStep<T>(label: string, task: () => Promise<T> | T) {
107
128
  }
108
129
  }
109
130
 
110
- export async function runMain(name: string, task: () => Promise<string | void>) {
131
+ export async function runMain(name: string, task: () => Promise<string | void> | string | void) {
111
132
  intro(name);
112
133
 
113
134
  try {
@@ -140,6 +161,9 @@ export function ensureProject() {
140
161
  }
141
162
 
142
163
  export function attachBilling() {
164
+ if (config.project.mode === "use_existing") {
165
+ return "Using existing project billing";
166
+ }
143
167
  gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
144
168
  }
145
169
 
@@ -180,7 +204,17 @@ export function ensureSecret(secretName: string) {
180
204
  return;
181
205
  }
182
206
 
183
- gcloud(["secrets", "create", secretName, "--project", config.project.id, "--replication-policy", "automatic"]);
207
+ gcloud([
208
+ "secrets",
209
+ "create",
210
+ secretName,
211
+ "--project",
212
+ config.project.id,
213
+ "--replication-policy",
214
+ "automatic",
215
+ "--labels",
216
+ ownershipLabelsArg(),
217
+ ]);
184
218
  }
185
219
 
186
220
  export function addSecretVersion(secretName: string, value: string) {
@@ -188,6 +222,10 @@ export function addSecretVersion(secretName: string, value: string) {
188
222
  gcloud(["secrets", "versions", "add", secretName, "--project", config.project.id, "--data-file=-"], { input: value });
189
223
  }
190
224
 
225
+ export function accessSecretVersion(secretName: string) {
226
+ return gcloud(["secrets", "versions", "access", "latest", "--secret", secretName, "--project", config.project.id]).stdout;
227
+ }
228
+
191
229
  export function ensureSecretAccessor(secretName: string, member: string) {
192
230
  gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
193
231
  }
@@ -204,6 +242,11 @@ export function deleteSecret(secretName: string) {
204
242
  gcloud(["secrets", "delete", secretName, "--project", config.project.id, "--quiet"], { allowFailure: true });
205
243
  }
206
244
 
245
+ export function describeSecret(secretName: string): GcpResourceWithLabels | undefined {
246
+ const result = gcloud(["secrets", "describe", secretName, "--project", config.project.id, "--format=json"], { allowFailure: true });
247
+ return parseOptionalJson(result.stdout, result.success);
248
+ }
249
+
207
250
  export function ensureArtifactRepository() {
208
251
  if (
209
252
  gcloud(
@@ -232,114 +275,6 @@ export function projectNumber() {
232
275
  return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
233
276
  }
234
277
 
235
- export function workloadIdentityPoolResource() {
236
- return `projects/${projectNumber()}/locations/global/workloadIdentityPools/${config.workloadIdentityPoolId}`;
237
- }
238
-
239
- export function workloadIdentityProviderResource() {
240
- return `${workloadIdentityPoolResource()}/providers/${config.workloadIdentityProviderId}`;
241
- }
242
-
243
- export function ensureWorkloadIdentityPool() {
244
- if (
245
- gcloud(["iam", "workload-identity-pools", "describe", config.workloadIdentityPoolId, "--project", config.project.id, "--location", "global"], {
246
- allowFailure: true,
247
- }).success
248
- ) {
249
- return;
250
- }
251
-
252
- gcloud([
253
- "iam",
254
- "workload-identity-pools",
255
- "create",
256
- config.workloadIdentityPoolId,
257
- "--project",
258
- config.project.id,
259
- "--location",
260
- "global",
261
- "--display-name",
262
- "GitHub Actions",
263
- ]);
264
- }
265
-
266
- export function ensureWorkloadIdentityProvider() {
267
- if (
268
- gcloud(
269
- [
270
- "iam",
271
- "workload-identity-pools",
272
- "providers",
273
- "describe",
274
- config.workloadIdentityProviderId,
275
- "--project",
276
- config.project.id,
277
- "--location",
278
- "global",
279
- "--workload-identity-pool",
280
- config.workloadIdentityPoolId,
281
- ],
282
- { allowFailure: true }
283
- ).success
284
- ) {
285
- return;
286
- }
287
-
288
- gcloud([
289
- "iam",
290
- "workload-identity-pools",
291
- "providers",
292
- "create-oidc",
293
- config.workloadIdentityProviderId,
294
- "--project",
295
- config.project.id,
296
- "--location",
297
- "global",
298
- "--workload-identity-pool",
299
- config.workloadIdentityPoolId,
300
- "--display-name",
301
- `${config.serviceName} GitHub`,
302
- "--issuer-uri",
303
- "https://token.actions.githubusercontent.com",
304
- "--attribute-mapping",
305
- "google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner",
306
- "--attribute-condition",
307
- `assertion.repository=='${config.github.repo}'`,
308
- ]);
309
- }
310
-
311
- export function deleteWorkloadIdentityProvider() {
312
- gcloud(
313
- [
314
- "iam",
315
- "workload-identity-pools",
316
- "providers",
317
- "delete",
318
- config.workloadIdentityProviderId,
319
- "--project",
320
- config.project.id,
321
- "--location",
322
- "global",
323
- "--workload-identity-pool",
324
- config.workloadIdentityPoolId,
325
- "--quiet",
326
- ],
327
- { allowFailure: true }
328
- );
329
- }
330
-
331
- export function setGithubVariable(name: string, value: string) {
332
- gh(["variable", "set", name, "--repo", config.github.repo, "--body", value]);
333
- }
334
-
335
- export function deleteGithubVariable(name: string) {
336
- gh(["variable", "delete", name, "--repo", config.github.repo], { allowFailure: true });
337
- }
338
-
339
- export function deleteGithubRepository() {
340
- gh(["repo", "delete", config.github.repo, "--yes"]);
341
- }
342
-
343
278
  export function imageTag() {
344
279
  const gitSha = run("git", ["rev-parse", "--short", "HEAD"], { allowFailure: true }).stdout;
345
280
  return gitSha || `${Date.now()}`;
@@ -408,7 +343,7 @@ export function parseDeployArgs(argv: string[]): DeployArgs {
408
343
  export function parseCleanupArgs(argv: string[]): CleanupArgs {
409
344
  const parsed: CleanupArgs = {
410
345
  destroyProject: false,
411
- destroyRepo: false,
346
+ force: false,
412
347
  };
413
348
 
414
349
  for (const token of argv) {
@@ -416,9 +351,8 @@ export function parseCleanupArgs(argv: string[]): CleanupArgs {
416
351
  parsed.destroyProject = true;
417
352
  continue;
418
353
  }
419
-
420
- if (token === "--repo") {
421
- parsed.destroyRepo = true;
354
+ if (token === "--force") {
355
+ parsed.force = true;
422
356
  continue;
423
357
  }
424
358
  }
@@ -460,24 +394,61 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
460
394
 
461
395
  export async function renderManifest(image: string, target: DeploymentTarget) {
462
396
  const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
397
+ const temporal = resolveTemporalRuntimeConfig();
463
398
  const values = {
464
399
  SERVICE_NAME: target.serviceName,
400
+ SERVICE_ID: config.serviceName,
465
401
  RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
466
402
  IMAGE_URL: image,
467
403
  DATABASE_URL_SECRET: target.databaseSecretName,
468
404
  SERVICE_RUNTIME: config.runtime,
469
405
  SERVICE_FRAMEWORK: config.framework,
406
+ TEMPORAL_ENABLED: String(temporal.enabled),
407
+ TEMPORAL_ADDRESS: temporal.address,
408
+ TEMPORAL_NAMESPACE: temporal.namespace,
409
+ TEMPORAL_TASK_QUEUE: temporal.taskQueue,
410
+ TEMPORAL_API_KEY_ENV: temporal.apiKeySecretName
411
+ ? [
412
+ " - name: TEMPORAL_API_KEY",
413
+ " valueFrom:",
414
+ " secretKeyRef:",
415
+ ` name: ${temporal.apiKeySecretName}`,
416
+ " key: latest",
417
+ ].join("\n")
418
+ : "",
419
+ AUTH_ISSUER: config.auth.issuer,
420
+ AUTH_AUDIENCE: config.auth.audience,
421
+ AUTH_JWKS_URL: config.auth.jwksUrl,
470
422
  };
471
423
 
472
424
  return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key: string) => {
473
425
  const value = values[key as keyof typeof values];
474
- if (!value) {
426
+ if (value === undefined) {
475
427
  throw new Error(`missing manifest value for ${key}`);
476
428
  }
477
429
  return value;
478
430
  });
479
431
  }
480
432
 
433
+ export function resolveTemporalRuntimeConfig() {
434
+ const enabledOverride = process.env.TEMPORAL_ENABLED?.trim();
435
+ const address = process.env.TEMPORAL_ADDRESS?.trim() || config.temporal.address;
436
+ const namespace = process.env.TEMPORAL_NAMESPACE?.trim() || config.temporal.namespace;
437
+ const taskQueue = process.env.TEMPORAL_TASK_QUEUE?.trim() || config.temporal.taskQueue;
438
+ const apiKeySecretName = process.env.TEMPORAL_API_KEY_SECRET?.trim() || (process.env.TEMPORAL_API_KEY?.trim() ? config.temporal.apiKeySecretName : "");
439
+ const enabled = enabledOverride
440
+ ? ["1", "true", "yes", "on"].includes(enabledOverride.toLowerCase())
441
+ : Boolean(process.env.TEMPORAL_ADDRESS?.trim() || process.env.TEMPORAL_API_KEY?.trim() || process.env.TEMPORAL_API_KEY_SECRET?.trim());
442
+
443
+ return {
444
+ enabled,
445
+ address,
446
+ namespace,
447
+ taskQueue,
448
+ apiKeySecretName,
449
+ };
450
+ }
451
+
481
452
  export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
482
453
  const rendered = await renderManifest(image, target);
483
454
  const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
@@ -491,6 +462,109 @@ export function serviceUrl(serviceName: string) {
491
462
  ).stdout;
492
463
  }
493
464
 
465
+ export function serviceDomain(target: DeploymentTarget) {
466
+ if (target.environment === "main") {
467
+ return config.domain.hostname;
468
+ }
469
+
470
+ return `${target.serviceName}-${config.project.id}-${config.region}.a.run.app`;
471
+ }
472
+
473
+ export function serviceOrigin(target: DeploymentTarget) {
474
+ if (target.environment === "main") {
475
+ return `https://${config.domain.hostname}`;
476
+ }
477
+
478
+ const url = serviceUrl(target.serviceName);
479
+ return url || `https://${serviceDomain(target)}`;
480
+ }
481
+
482
+ export function ensureProductionDomainMapping(serviceName: string) {
483
+ const existing = describeProductionDomainMapping();
484
+ if (existing) {
485
+ const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
486
+ if (!mappedService || mappedService === serviceName) {
487
+ return;
488
+ }
489
+ throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
490
+ }
491
+
492
+ gcloud([
493
+ "beta",
494
+ "run",
495
+ "domain-mappings",
496
+ "create",
497
+ "--service",
498
+ serviceName,
499
+ "--domain",
500
+ config.domain.hostname,
501
+ "--project",
502
+ config.project.id,
503
+ "--region",
504
+ config.region,
505
+ ]);
506
+ }
507
+
508
+ export function describeProductionDomainMapping():
509
+ | { spec?: { routeName?: string }; status?: { resourceRecords?: Array<{ rrdata?: string }> } }
510
+ | undefined {
511
+ const result = gcloud(
512
+ [
513
+ "beta",
514
+ "run",
515
+ "domain-mappings",
516
+ "describe",
517
+ "--domain",
518
+ config.domain.hostname,
519
+ "--project",
520
+ config.project.id,
521
+ "--region",
522
+ config.region,
523
+ "--format=json",
524
+ ],
525
+ { allowFailure: true }
526
+ );
527
+ if (!result.success || !result.stdout) {
528
+ return undefined;
529
+ }
530
+
531
+ try {
532
+ return JSON.parse(result.stdout);
533
+ } catch {
534
+ throw new Error(`Unable to parse Cloud Run domain mapping for ${config.domain.hostname}`);
535
+ }
536
+ }
537
+
538
+ export function assertProductionDomainAvailable(serviceName: string) {
539
+ const existing = describeProductionDomainMapping();
540
+ if (!existing) {
541
+ return;
542
+ }
543
+
544
+ const mappedService = existing.spec?.routeName;
545
+ if (mappedService && mappedService !== serviceName) {
546
+ throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; choose a different service_id before provisioning resources`);
547
+ }
548
+
549
+ throw new Error(`${config.domain.hostname} already has a domain mapping; use service deploy to redeploy or service dns to repair it`);
550
+ }
551
+
552
+ export function assertServiceNameAvailable(serviceName: string) {
553
+ const result = gcloud(
554
+ ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(metadata.name)"],
555
+ { allowFailure: true }
556
+ );
557
+ if (result.success) {
558
+ throw new Error(`${serviceName} already exists in Cloud Run; use service deploy to redeploy or service destroy to remove owned resources`);
559
+ }
560
+ }
561
+
562
+ export function deleteProductionDomainMapping() {
563
+ gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
564
+ allowFailure: true,
565
+ });
566
+ }
567
+
494
568
  export function listCloudRunServices() {
495
569
  return gcloud(["run", "services", "list", "--project", config.project.id, "--region", config.region, "--format=value(metadata.name)"]).stdout
496
570
  .split("\n")
@@ -498,6 +572,14 @@ export function listCloudRunServices() {
498
572
  .filter(Boolean);
499
573
  }
500
574
 
575
+ export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
576
+ const result = gcloud(
577
+ ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
578
+ { allowFailure: true }
579
+ );
580
+ return parseOptionalJson(result.stdout, result.success);
581
+ }
582
+
501
583
  export function deleteService(serviceName: string) {
502
584
  gcloud(["run", "services", "delete", serviceName, "--project", config.project.id, "--region", config.region, "--quiet"], {
503
585
  allowFailure: true,
@@ -515,3 +597,30 @@ function slugify(value: string) {
515
597
  .replace(/[^a-z0-9]+/g, "-")
516
598
  .replace(/^-+|-+$/g, "");
517
599
  }
600
+
601
+ export function assertOwnedResource(name: string, resource: GcpResourceWithLabels | undefined) {
602
+ if (!resource) {
603
+ throw new Error(`${name} does not exist`);
604
+ }
605
+
606
+ const labels = resource.metadata?.labels ?? resource.labels ?? {};
607
+ if (labels.managed_by !== "create-service" || labels.service_id !== config.serviceName) {
608
+ throw new Error(`${name} is missing ownership labels for service_id=${config.serviceName}`);
609
+ }
610
+ }
611
+
612
+ function ownershipLabelsArg() {
613
+ return `managed_by=create-service,service_id=${config.serviceName}`;
614
+ }
615
+
616
+ function parseOptionalJson<T>(stdout: string, success: boolean): T | undefined {
617
+ if (!success || !stdout) {
618
+ return undefined;
619
+ }
620
+
621
+ try {
622
+ return JSON.parse(stdout) as T;
623
+ } catch {
624
+ throw new Error("Unable to parse gcloud JSON response");
625
+ }
626
+ }