create-svc 0.1.10 → 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 (168) hide show
  1. package/README.md +46 -43
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +12 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +195 -30
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +231 -40
  14. package/src/scaffold.ts +84 -29
  15. package/src/vault.test.ts +61 -1
  16. package/src/vault.ts +77 -15
  17. package/templates/shared/.github/workflows/ci.yml +2 -1
  18. package/templates/shared/.github/workflows/deploy.yml +2 -0
  19. package/templates/shared/README.md +124 -47
  20. package/templates/shared/grafana/alerts.yaml +54 -0
  21. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  22. package/templates/shared/scripts/authctl.ts +231 -0
  23. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  24. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  25. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  26. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  27. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  28. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  29. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  30. package/templates/shared/scripts/dev.ts +22 -0
  31. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  32. package/templates/shared/scripts/local-docker.ts +63 -0
  33. package/templates/shared/scripts/local-env.ts +27 -0
  34. package/templates/shared/scripts/seed.ts +73 -0
  35. package/templates/shared/scripts/wait-for-db.ts +32 -0
  36. package/templates/shared/service.config.ts +59 -0
  37. package/templates/shared/service.yaml +24 -44
  38. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  39. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  40. package/templates/targets/workers/Makefile +33 -0
  41. package/templates/targets/workers/README.md +75 -0
  42. package/templates/targets/workers/package.json +35 -0
  43. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  44. package/templates/targets/workers/src/auth.ts +178 -0
  45. package/templates/targets/workers/src/index.ts +198 -0
  46. package/templates/targets/workers/src/storage.ts +370 -0
  47. package/templates/targets/workers/test/app.test.ts +108 -0
  48. package/templates/targets/workers/tsconfig.json +11 -0
  49. package/templates/targets/workers/wrangler.toml +24 -0
  50. package/templates/variants/bun-connectrpc/Makefile +14 -8
  51. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  52. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  53. package/templates/variants/bun-connectrpc/package.json +12 -5
  54. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  55. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  56. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  57. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  58. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  59. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  60. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  61. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  62. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  63. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  64. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  65. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  66. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  67. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  68. package/templates/variants/bun-hono/Makefile +14 -8
  69. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  70. package/templates/variants/bun-hono/package.json +12 -5
  71. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  72. package/templates/variants/bun-hono/src/auth.ts +181 -0
  73. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  74. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  75. package/templates/variants/bun-hono/src/index.ts +65 -180
  76. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  77. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  78. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  79. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  80. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  81. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  82. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  83. package/templates/variants/go-chi/Makefile +27 -11
  84. package/templates/variants/go-chi/atlas.hcl +8 -0
  85. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  86. package/templates/variants/go-chi/go.mod +1 -3
  87. package/templates/variants/go-chi/internal/app/service.go +202 -685
  88. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  89. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  90. package/templates/variants/go-chi/internal/config/config.go +27 -11
  91. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  92. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  93. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  94. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  95. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  96. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  97. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  98. package/templates/variants/go-chi/package.json +7 -1
  99. package/templates/variants/go-connectrpc/Makefile +26 -9
  100. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  101. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  102. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  103. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  104. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  105. package/templates/variants/go-connectrpc/go.mod +1 -1
  106. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  107. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  108. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  109. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  110. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  111. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  112. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  113. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  114. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  115. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  116. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  117. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  118. package/templates/variants/go-connectrpc/package.json +7 -1
  119. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  120. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  121. package/templates/root/.github/workflows/ci.yml +0 -26
  122. package/templates/root/.github/workflows/deploy.yml +0 -22
  123. package/templates/root/Dockerfile +0 -23
  124. package/templates/root/README.md +0 -69
  125. package/templates/root/buf.gen.yaml +0 -10
  126. package/templates/root/buf.yaml +0 -9
  127. package/templates/root/cmd/server/main.go +0 -44
  128. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  129. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  130. package/templates/root/go.mod +0 -10
  131. package/templates/root/internal/app/service.go +0 -152
  132. package/templates/root/internal/app/token_source.go +0 -50
  133. package/templates/root/internal/cloudflare/client.go +0 -160
  134. package/templates/root/internal/config/config.go +0 -55
  135. package/templates/root/internal/connectapi/handler.go +0 -79
  136. package/templates/root/internal/httpapi/routes.go +0 -93
  137. package/templates/root/internal/vault/client.go +0 -148
  138. package/templates/root/package.json +0 -12
  139. package/templates/root/protos/dns/v1/dns.proto +0 -58
  140. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  141. package/templates/root/scripts/cloudrun/config.ts +0 -50
  142. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  143. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  144. package/templates/root/service.yaml +0 -50
  145. package/templates/root/test/go.test.ts +0 -19
  146. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  147. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  148. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  149. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  150. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  151. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  152. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  153. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  154. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  155. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  156. package/templates/variants/bun-hono/src/storage.ts +0 -72
  157. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  158. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  159. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  160. package/templates/variants/go-chi/buf.yaml +0 -9
  161. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  162. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  163. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  164. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  165. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  166. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  167. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  168. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
@@ -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,6 +16,7 @@ type DeployArgs = {
15
16
 
16
17
  type CleanupArgs = {
17
18
  destroyProject: boolean;
19
+ force: boolean;
18
20
  };
19
21
 
20
22
  export type DeploymentTarget = {
@@ -24,6 +26,13 @@ export type DeploymentTarget = {
24
26
  databaseSecretName: string;
25
27
  };
26
28
 
29
+ type GcpResourceWithLabels = {
30
+ metadata?: {
31
+ labels?: Record<string, string>;
32
+ };
33
+ labels?: Record<string, string>;
34
+ };
35
+
27
36
  type CommandResult = {
28
37
  success: boolean;
29
38
  stdout: string;
@@ -67,7 +76,7 @@ export function requireGcloudAuth() {
67
76
  throw new Error(
68
77
  [
69
78
  "gcloud is installed but no active Google Cloud account is available.",
70
- "Run `gcloud auth login` on this machine before using bootstrap, deploy, or cleanup.",
79
+ "Run `gcloud auth login` on this machine before using service create, deploy, doctor, dns, or destroy.",
71
80
  "If you also rely on Application Default Credentials for other tooling, run `gcloud auth application-default login` as well.",
72
81
  ].join(" ")
73
82
  );
@@ -77,7 +86,7 @@ export function requireGcloudAuth() {
77
86
  export function run(command: string, args: string[], options: CommandOptions = {}): CommandResult {
78
87
  const result = Bun.spawnSync([command, ...args], {
79
88
  cwd: process.cwd(),
80
- env: process.env,
89
+ env: { ...process.env, ...options.env },
81
90
  stdin: options.input === undefined ? undefined : encoder.encode(options.input),
82
91
  stdout: "pipe",
83
92
  stderr: "pipe",
@@ -119,7 +128,7 @@ export async function runStep<T>(label: string, task: () => Promise<T> | T) {
119
128
  }
120
129
  }
121
130
 
122
- export async function runMain(name: string, task: () => Promise<string | void>) {
131
+ export async function runMain(name: string, task: () => Promise<string | void> | string | void) {
123
132
  intro(name);
124
133
 
125
134
  try {
@@ -152,6 +161,9 @@ export function ensureProject() {
152
161
  }
153
162
 
154
163
  export function attachBilling() {
164
+ if (config.project.mode === "use_existing") {
165
+ return "Using existing project billing";
166
+ }
155
167
  gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
156
168
  }
157
169
 
@@ -192,7 +204,17 @@ export function ensureSecret(secretName: string) {
192
204
  return;
193
205
  }
194
206
 
195
- 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
+ ]);
196
218
  }
197
219
 
198
220
  export function addSecretVersion(secretName: string, value: string) {
@@ -200,6 +222,10 @@ export function addSecretVersion(secretName: string, value: string) {
200
222
  gcloud(["secrets", "versions", "add", secretName, "--project", config.project.id, "--data-file=-"], { input: value });
201
223
  }
202
224
 
225
+ export function accessSecretVersion(secretName: string) {
226
+ return gcloud(["secrets", "versions", "access", "latest", "--secret", secretName, "--project", config.project.id]).stdout;
227
+ }
228
+
203
229
  export function ensureSecretAccessor(secretName: string, member: string) {
204
230
  gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
205
231
  }
@@ -216,6 +242,11 @@ export function deleteSecret(secretName: string) {
216
242
  gcloud(["secrets", "delete", secretName, "--project", config.project.id, "--quiet"], { allowFailure: true });
217
243
  }
218
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
+
219
250
  export function ensureArtifactRepository() {
220
251
  if (
221
252
  gcloud(
@@ -240,24 +271,6 @@ export function ensureArtifactRepository() {
240
271
  ]);
241
272
  }
242
273
 
243
- export function ensureStorageBucket() {
244
- if (gcloud(["storage", "buckets", "describe", `gs://${config.storage.attachmentBucket}`, "--project", config.project.id], { allowFailure: true }).success) {
245
- return;
246
- }
247
-
248
- gcloud([
249
- "storage",
250
- "buckets",
251
- "create",
252
- `gs://${config.storage.attachmentBucket}`,
253
- "--project",
254
- config.project.id,
255
- "--location",
256
- config.region,
257
- "--uniform-bucket-level-access",
258
- ]);
259
- }
260
-
261
274
  export function projectNumber() {
262
275
  return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
263
276
  }
@@ -330,6 +343,7 @@ export function parseDeployArgs(argv: string[]): DeployArgs {
330
343
  export function parseCleanupArgs(argv: string[]): CleanupArgs {
331
344
  const parsed: CleanupArgs = {
332
345
  destroyProject: false,
346
+ force: false,
333
347
  };
334
348
 
335
349
  for (const token of argv) {
@@ -337,6 +351,10 @@ export function parseCleanupArgs(argv: string[]): CleanupArgs {
337
351
  parsed.destroyProject = true;
338
352
  continue;
339
353
  }
354
+ if (token === "--force") {
355
+ parsed.force = true;
356
+ continue;
357
+ }
340
358
  }
341
359
 
342
360
  return parsed;
@@ -374,42 +392,63 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
374
392
  };
375
393
  }
376
394
 
377
- export function runtimeSecretNames(target: DeploymentTarget) {
378
- return {
379
- CLERK_SECRET_KEY: `${target.serviceName}-clerk-secret-key`,
380
- CLERK_WEBHOOK_SECRET: `${target.serviceName}-clerk-webhook-secret`,
381
- STRIPE_SECRET_KEY: `${target.serviceName}-stripe-secret-key`,
382
- STRIPE_WEBHOOK_SECRET: `${target.serviceName}-stripe-webhook-secret`,
383
- REVENUECAT_API_KEY: `${target.serviceName}-revenuecat-api-key`,
384
- REVENUECAT_WEBHOOK_SECRET: `${target.serviceName}-revenuecat-webhook-secret`,
385
- RESEND_API_KEY: `${target.serviceName}-resend-api-key`,
386
- POSTHOG_API_KEY: `${target.serviceName}-posthog-api-key`,
387
- } as const;
388
- }
389
-
390
395
  export async function renderManifest(image: string, target: DeploymentTarget) {
391
396
  const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
397
+ const temporal = resolveTemporalRuntimeConfig();
392
398
  const values = {
393
399
  SERVICE_NAME: target.serviceName,
400
+ SERVICE_ID: config.serviceName,
394
401
  RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
395
402
  IMAGE_URL: image,
396
403
  DATABASE_URL_SECRET: target.databaseSecretName,
397
- ...runtimeSecretNames(target),
398
404
  SERVICE_RUNTIME: config.runtime,
399
405
  SERVICE_FRAMEWORK: config.framework,
400
- ATTACHMENT_BUCKET: config.storage.attachmentBucket,
401
- ATTACHMENT_PUBLIC_BASE_URL: config.storage.attachmentPublicBaseUrl,
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,
402
422
  };
403
423
 
404
424
  return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key: string) => {
405
425
  const value = values[key as keyof typeof values];
406
- if (!value) {
426
+ if (value === undefined) {
407
427
  throw new Error(`missing manifest value for ${key}`);
408
428
  }
409
429
  return value;
410
430
  });
411
431
  }
412
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
+
413
452
  export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
414
453
  const rendered = await renderManifest(image, target);
415
454
  const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
@@ -441,8 +480,13 @@ export function serviceOrigin(target: DeploymentTarget) {
441
480
  }
442
481
 
443
482
  export function ensureProductionDomainMapping(serviceName: string) {
444
- if (gcloud(["beta", "run", "domain-mappings", "describe", "--domain", config.domain.hostname, "--project", config.project.id], { allowFailure: true }).success) {
445
- return;
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`);
446
490
  }
447
491
 
448
492
  gcloud([
@@ -461,6 +505,60 @@ export function ensureProductionDomainMapping(serviceName: string) {
461
505
  ]);
462
506
  }
463
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
+
464
562
  export function deleteProductionDomainMapping() {
465
563
  gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
466
564
  allowFailure: true,
@@ -474,6 +572,14 @@ export function listCloudRunServices() {
474
572
  .filter(Boolean);
475
573
  }
476
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
+
477
583
  export function deleteService(serviceName: string) {
478
584
  gcloud(["run", "services", "delete", serviceName, "--project", config.project.id, "--region", config.region, "--quiet"], {
479
585
  allowFailure: true,
@@ -491,3 +597,30 @@ function slugify(value: string) {
491
597
  .replace(/[^a-z0-9]+/g, "-")
492
598
  .replace(/^-+|-+$/g, "");
493
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
+ }
@@ -13,6 +13,11 @@ type NeonBranch = {
13
13
  name: string;
14
14
  };
15
15
 
16
+ type NeonDatabase = {
17
+ name: string;
18
+ ownerName: string;
19
+ };
20
+
16
21
  type ResolvedNeonConfig = {
17
22
  projectId: string;
18
23
  baseBranchId: string;
@@ -110,6 +115,18 @@ export async function listBranches(projectId: string) {
110
115
  .sort((left: NeonBranch, right: NeonBranch) => left.name.localeCompare(right.name));
111
116
  }
112
117
 
118
+ export async function listDatabases(projectId: string, branchId: string) {
119
+ const payload = await (await neonClient()).listProjectBranchDatabases(projectId, branchId);
120
+ const databases = ((payload.data as { databases?: Array<{ name?: string; owner_name?: string }> } | undefined)?.databases ?? []);
121
+ return databases
122
+ .map((database: { name?: string; owner_name?: string }) => ({
123
+ name: database.name ?? "",
124
+ ownerName: database.owner_name ?? "",
125
+ }))
126
+ .filter((database: NeonDatabase): database is NeonDatabase => Boolean(database.name))
127
+ .sort((left: NeonDatabase, right: NeonDatabase) => left.name.localeCompare(right.name));
128
+ }
129
+
113
130
  export async function resolveNeonConfig(): Promise<ResolvedNeonConfig> {
114
131
  const configuredProjectId = config.neon.projectId.trim();
115
132
  const configuredBaseBranchId = config.neon.baseBranchId.trim();
@@ -172,6 +189,7 @@ export async function ensureDatabase(projectId: string, branchId: string, databa
172
189
  }
173
190
 
174
191
  export async function deleteDatabase(projectId: string, branchId: string, databaseName: string) {
192
+ await assertDatabaseOwned(projectId, branchId, databaseName);
175
193
  try {
176
194
  await (await neonClient()).deleteProjectBranchDatabase(projectId, branchId, databaseName);
177
195
  } catch (error) {
@@ -213,6 +231,11 @@ export async function ensureBranch(projectId: string, branchName: string, parent
213
231
  }
214
232
 
215
233
  export async function deleteBranch(projectId: string, branchId: string) {
234
+ const branch = (await listBranches(projectId)).find((candidate) => candidate.id === branchId);
235
+ if (!branch) {
236
+ return;
237
+ }
238
+ assertDisposableBranchName(branch.name);
216
239
  try {
217
240
  await (await neonClient()).deleteProjectBranch(projectId, branchId);
218
241
  } catch (error) {
@@ -224,6 +247,28 @@ export async function deleteBranch(projectId: string, branchId: string) {
224
247
  }
225
248
  }
226
249
 
250
+ async function assertDatabaseOwned(projectId: string, branchId: string, databaseName: string) {
251
+ if (databaseName !== config.neon.databaseName) {
252
+ throw new Error(`Refusing to delete Neon database ${databaseName}; expected ${config.neon.databaseName}`);
253
+ }
254
+
255
+ const database = (await listDatabases(projectId, branchId)).find((candidate) => candidate.name === databaseName);
256
+ if (!database) {
257
+ return;
258
+ }
259
+
260
+ if (database.ownerName && database.ownerName !== config.neon.roleName) {
261
+ throw new Error(`Refusing to delete Neon database ${databaseName}; owner is ${database.ownerName}, expected ${config.neon.roleName}`);
262
+ }
263
+ }
264
+
265
+ function assertDisposableBranchName(branchName: string) {
266
+ if (branchName.startsWith(`${config.neon.previewBranchPrefix}-`) || branchName.startsWith(`${config.neon.personalBranchPrefix}-`)) {
267
+ return;
268
+ }
269
+ throw new Error(`Refusing to delete Neon branch ${branchName}; it is not owned by ${config.serviceName}`);
270
+ }
271
+
227
272
  export async function getConnectionUri(projectId: string, branchId: string, databaseName: string, roleName: string) {
228
273
  const payload = await (await neonClient()).getConnectionUri({
229
274
  projectId,
@@ -0,0 +1,22 @@
1
+ import { readLocalEnv } from "./local-env";
2
+ import { ensureLocalPostgres } from "./local-docker";
3
+
4
+ const command = Bun.argv.slice(2);
5
+
6
+ if (command.length === 0) {
7
+ throw new Error("Usage: bun run ./scripts/dev.ts <command...>");
8
+ }
9
+
10
+ await ensureLocalPostgres();
11
+
12
+ const child = Bun.spawn(command, {
13
+ stdin: "inherit",
14
+ stdout: "inherit",
15
+ stderr: "inherit",
16
+ env: {
17
+ ...Bun.env,
18
+ ...(await readLocalEnv()),
19
+ },
20
+ });
21
+
22
+ process.exit(await child.exited);
@@ -0,0 +1,3 @@
1
+ import { ensureLocalPostgres } from "./local-docker";
2
+
3
+ await ensureLocalPostgres();
@@ -0,0 +1,63 @@
1
+ export async function ensureLocalPostgres() {
2
+ await ensureDockerRunning();
3
+ await run(["docker", "compose", "up", "-d"], { label: "start local postgres" });
4
+ }
5
+
6
+ async function ensureDockerRunning() {
7
+ if (await dockerInfo()) {
8
+ return;
9
+ }
10
+
11
+ await openDocker();
12
+ const deadline = Date.now() + 120_000;
13
+
14
+ while (Date.now() < deadline) {
15
+ if (await dockerInfo()) {
16
+ return;
17
+ }
18
+ await Bun.sleep(2_000);
19
+ }
20
+
21
+ throw new Error("Docker did not become ready within 120 seconds. Open Docker Desktop and retry.");
22
+ }
23
+
24
+ async function dockerInfo() {
25
+ const result = await Bun.spawn(["docker", "info"], {
26
+ stdout: "ignore",
27
+ stderr: "ignore",
28
+ }).exited;
29
+ return result === 0;
30
+ }
31
+
32
+ async function openDocker() {
33
+ if (process.platform === "darwin") {
34
+ await run(["open", "-a", "Docker"], { label: "open Docker Desktop" });
35
+ return;
36
+ }
37
+
38
+ if (process.platform === "win32") {
39
+ await run(["powershell.exe", "-NoProfile", "-Command", "Start-Process 'Docker Desktop'"], {
40
+ label: "open Docker Desktop",
41
+ optional: true,
42
+ });
43
+ return;
44
+ }
45
+
46
+ await run(["systemctl", "--user", "start", "docker-desktop"], {
47
+ label: "open Docker Desktop",
48
+ optional: true,
49
+ });
50
+ }
51
+
52
+ async function run(command: string[], options: { label: string; optional?: boolean }) {
53
+ const result = await Bun.spawn(command, {
54
+ stdin: "ignore",
55
+ stdout: "inherit",
56
+ stderr: "inherit",
57
+ env: Bun.env,
58
+ }).exited;
59
+
60
+ if (result !== 0 && !options.optional) {
61
+ throw new Error(`${options.label} failed with exit code ${result}`);
62
+ }
63
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ export async function readLocalEnv() {
5
+ if (!existsSync(".env.local")) {
6
+ return {};
7
+ }
8
+
9
+ const values: Record<string, string> = {};
10
+ const text = await readFile(".env.local", "utf8");
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#")) {
14
+ continue;
15
+ }
16
+
17
+ const separator = trimmed.indexOf("=");
18
+ if (separator === -1) {
19
+ continue;
20
+ }
21
+
22
+ const key = trimmed.slice(0, separator).trim();
23
+ const rawValue = trimmed.slice(separator + 1).trim();
24
+ values[key] = rawValue.replace(/^['"]|['"]$/g, "");
25
+ }
26
+ return values;
27
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { SQL } from "bun";
4
+
5
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
6
+ if (!databaseUrl) {
7
+ throw new Error("DATABASE_URL is required");
8
+ }
9
+
10
+ const stage = normalizeStage(Bun.env.SERVICE_STAGE || Bun.env.APP_ENV || Bun.env.NODE_ENV || "local");
11
+ if (stage === "prod" && Bun.env.SEED_PROD !== "true") {
12
+ console.log("Skipping production seed data. Set SEED_PROD=true to apply production seeds.");
13
+ process.exit(0);
14
+ }
15
+
16
+ const sql = new SQL(databaseUrl);
17
+
18
+ try {
19
+ const entries = seedEntries(stage);
20
+ for (const entry of entries) {
21
+ await sql`
22
+ insert into waitlist_entries (id, email, name, company, source, status)
23
+ values (${entry.id}, ${entry.email}, ${entry.name}, ${entry.company}, ${entry.source}, ${entry.status})
24
+ on conflict (email) do update set
25
+ name = excluded.name,
26
+ company = excluded.company,
27
+ source = excluded.source,
28
+ updated_at = now()
29
+ `;
30
+
31
+ await sql`
32
+ insert into waitlist_triggers (id, type, entry_id, status, payload_json)
33
+ values (${`${entry.id}-trigger`}, ${"seed"}, ${entry.id}, ${"queued"}, ${JSON.stringify({ stage, email: entry.email })})
34
+ on conflict (id) do nothing
35
+ `;
36
+ }
37
+
38
+ console.log(`Seeded ${entries.length} waitlist entr${entries.length === 1 ? "y" : "ies"} for ${stage}.`);
39
+ } finally {
40
+ await sql.close();
41
+ }
42
+
43
+ function normalizeStage(value: string) {
44
+ const normalized = value.trim().toLowerCase();
45
+ if (normalized === "production" || normalized === "main") {
46
+ return "prod";
47
+ }
48
+ if (normalized === "development") {
49
+ return "local";
50
+ }
51
+ return normalized || "local";
52
+ }
53
+
54
+ function seedEntries(stage: string) {
55
+ return [
56
+ {
57
+ id: `seed-${stage}-founder`,
58
+ email: `founder+${stage}@example.com`,
59
+ name: "Founder Example",
60
+ company: "Example Co",
61
+ source: `seed:${stage}`,
62
+ status: "joined",
63
+ },
64
+ {
65
+ id: `seed-${stage}-operator`,
66
+ email: `operator+${stage}@example.com`,
67
+ name: "Operator Example",
68
+ company: "Example Co",
69
+ source: `seed:${stage}`,
70
+ status: "joined",
71
+ },
72
+ ];
73
+ }
@@ -0,0 +1,32 @@
1
+ import { SQL } from "bun";
2
+ import { readLocalEnv } from "./local-env";
3
+
4
+ const env = {
5
+ ...Bun.env,
6
+ ...(await readLocalEnv()),
7
+ };
8
+ const databaseUrl = env.DATABASE_URL?.trim();
9
+
10
+ if (!databaseUrl) {
11
+ throw new Error("DATABASE_URL is required");
12
+ }
13
+
14
+ const client = new SQL(databaseUrl);
15
+ const deadline = Date.now() + 45_000;
16
+ let lastError: unknown;
17
+
18
+ while (Date.now() < deadline) {
19
+ try {
20
+ await client.unsafe("select 1");
21
+ process.exit(0);
22
+ } catch (error) {
23
+ lastError = error;
24
+ await Bun.sleep(1_000);
25
+ }
26
+ }
27
+
28
+ throw new Error(`Timed out waiting for Postgres: ${formatError(lastError)}`);
29
+
30
+ function formatError(error: unknown) {
31
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
32
+ }