create-svc 0.1.8 → 0.1.10

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 (91) hide show
  1. package/README.md +142 -13
  2. package/package.json +9 -4
  3. package/src/cli.test.ts +29 -8
  4. package/src/cli.ts +103 -70
  5. package/src/naming.test.ts +4 -2
  6. package/src/naming.ts +9 -1
  7. package/src/neon.ts +10 -8
  8. package/src/post-scaffold.ts +7 -28
  9. package/src/profiles.ts +28 -0
  10. package/src/scaffold.test.ts +126 -15
  11. package/src/scaffold.ts +94 -23
  12. package/src/vault.test.ts +62 -5
  13. package/src/vault.ts +24 -4
  14. package/templates/shared/README.md +143 -26
  15. package/templates/shared/docker-compose.yml +19 -0
  16. package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
  17. package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
  18. package/templates/shared/scripts/cloudrun/config.ts +14 -19
  19. package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
  20. package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
  21. package/templates/shared/scripts/cloudrun/lib.ts +88 -112
  22. package/templates/shared/scripts/cloudrun/neon.ts +100 -14
  23. package/templates/shared/service.yaml +44 -1
  24. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  25. package/templates/variants/bun-connectrpc/Makefile +4 -1
  26. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
  27. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
  28. package/templates/variants/bun-connectrpc/package.json +17 -0
  29. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  31. package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
  32. package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
  33. package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
  34. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  35. package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
  36. package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
  37. package/templates/variants/bun-connectrpc/src/index.ts +294 -22
  38. package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
  39. package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
  40. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  41. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
  42. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  43. package/templates/variants/bun-hono/Makefile +4 -1
  44. package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
  45. package/templates/variants/bun-hono/package.json +13 -0
  46. package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
  47. package/templates/variants/bun-hono/src/chat/service.ts +384 -0
  48. package/templates/variants/bun-hono/src/chat/types.ts +142 -0
  49. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  50. package/templates/variants/bun-hono/src/db/repository.ts +479 -0
  51. package/templates/variants/bun-hono/src/db/schema.ts +75 -0
  52. package/templates/variants/bun-hono/src/index.ts +254 -8
  53. package/templates/variants/bun-hono/src/storage.ts +72 -0
  54. package/templates/variants/bun-hono/src/webhooks.ts +35 -0
  55. package/templates/variants/bun-hono/test/app.test.ts +60 -6
  56. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
  57. package/templates/variants/bun-hono/tsconfig.json +1 -0
  58. package/templates/variants/go-chi/Makefile +6 -2
  59. package/templates/variants/go-chi/buf.gen.yaml +2 -0
  60. package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
  61. package/templates/variants/go-chi/cmd/server/main.go +16 -15
  62. package/templates/variants/go-chi/go.mod +3 -0
  63. package/templates/variants/go-chi/internal/app/service.go +763 -71
  64. package/templates/variants/go-chi/internal/config/config.go +22 -7
  65. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
  66. package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
  67. package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
  68. package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
  69. package/templates/variants/go-chi/test/go.test.ts +4 -1
  70. package/templates/variants/go-connectrpc/Makefile +6 -2
  71. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  72. package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
  73. package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
  74. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
  75. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
  76. package/templates/variants/go-connectrpc/go.mod +4 -0
  77. package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
  78. package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
  79. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
  80. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
  81. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
  82. package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
  83. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
  84. package/templates/shared/.env.example +0 -10
  85. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  86. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  87. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  88. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  89. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  90. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  91. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -2,62 +2,179 @@
2
2
 
3
3
  Generated by `create-svc`.
4
4
 
5
- This scaffold targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
5
+ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
6
6
 
7
7
  - one generated `service.yaml` manifest
8
- - Bun-based `bootstrap` and `deploy` helpers
9
- - GitHub Actions for CI, `main` deploys, PR previews, and personal environments
8
+ - a lightweight `{{EXAMPLE_LABEL}}` example surface
9
+ - local Docker Compose Postgres for first-run development
10
+ - a local `svc-cloudrun` CLI for bootstrap, deploy, and cleanup
10
11
  - GCP project bootstrap with billing and quota-project-aware `gcloud` calls
11
- - Neon main, preview, and personal branch provisioning
12
+ - Neon-backed remote database provisioning during bootstrap and deploy
13
+ - GCS-backed image attachments
14
+ - typed HTTP webhook ingestion
15
+ - a production API origin at `https://{{API_HOSTNAME}}`
16
+
17
+ The default happy path is standalone. Terraform is optional: advanced users can
18
+ precreate shared foundations and point this package at them, but a generated app
19
+ does not need Terraform state, Terraform plans, a control plane, or a platform
20
+ console to bootstrap and deploy.
12
21
 
13
22
  ## Commands
14
23
 
15
24
  ```bash
16
- bun dev
17
- bun gen
18
- bun lint
19
- bun test
20
- bun run bootstrap
21
- bun run deploy
22
- bun run deploy -- --environment personal --name <slug>
23
- bun run deploy -- --destroy --environment personal --name <slug>
25
+ {{COMMAND_DEV}}
26
+ {{COMMAND_MIGRATE}}
27
+ {{COMMAND_GEN}}
28
+ {{COMMAND_LINT}}
29
+ {{COMMAND_TEST}}
30
+ {{COMMAND_BOOTSTRAP}}
31
+ {{COMMAND_DEPLOY}}
32
+ {{COMMAND_DEPLOY_PERSONAL}}
33
+ {{COMMAND_DEPLOY_DESTROY}}
34
+ {{COMMAND_CLEANUP}}
35
+ {{COMMAND_CLEANUP_PROJECT}}
36
+ ```
37
+
38
+ ## Local development
39
+
40
+ The scaffold writes a ready-to-use `.env.local` and includes a local Postgres service in `docker-compose.yml`.
41
+
42
+ First local run:
43
+
44
+ ```bash
45
+ docker compose up -d
46
+ {{COMMAND_MIGRATE}}
47
+ {{COMMAND_DEV}}
24
48
  ```
25
49
 
26
- ## Configuration
50
+ Local runtime uses:
51
+
52
+ - `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
53
+ - `ATTACHMENT_BUCKET`
54
+ - `ATTACHMENT_PUBLIC_BASE_URL`
55
+
56
+ The local attachment settings are generated so startup works out of the box. Attachment upload endpoints still rely on GCS credentials when you exercise signed-upload flows locally.
57
+
58
+ ## Remote provisioning
27
59
 
28
60
  The generated Cloud Run config lives in [scripts/cloudrun/config.ts](scripts/cloudrun/config.ts).
29
61
 
30
- Bootstrap and deploy use:
62
+ Bootstrap, deploy, and cleanup use:
31
63
 
64
+ - known-good CLIs first, especially `gcloud`
32
65
  - `gcloud`
33
- - `gh`
34
- - `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` + `VAULT_TOKEN`
66
+ - `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
67
+ - the package-local CLI via `npx --no-install svc-cloudrun ...`
35
68
 
36
- ## Environment setup
69
+ Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
37
70
 
38
- For project-specific Vault settings, prefer repo-local config over shell startup files:
71
+ Authenticate `gcloud` on the machine before running provisioning commands:
39
72
 
40
73
  ```bash
41
- cp .env.example .env.local
74
+ gcloud auth login
42
75
  ```
43
76
 
44
- Then edit `.env.local` with your Vault address and secret path overrides.
77
+ If you also want local Application Default Credentials available for other tools, run:
45
78
 
46
- For the token itself, prefer a live shell session:
79
+ ```bash
80
+ gcloud auth application-default login
81
+ ```
82
+
83
+ The generated backend scripts still use `gcloud` as the primary control plane even when ADC is present.
84
+
85
+ For the Neon admin credential, prefer a normal Vault login flow:
47
86
 
48
87
  ```bash
49
88
  vault login
50
- export VAULT_TOKEN="$(vault print token)"
51
89
  ```
52
90
 
53
- or however your existing Vault login flow exposes `VAULT_TOKEN`.
91
+ The scaffold will use, in order:
92
+
93
+ 1. `VAULT_TOKEN`
94
+ 2. `VAULT_TOKEN_FILE`
95
+ 3. `~/.vault-token`
54
96
 
55
97
  That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
56
98
 
57
- Optional Vault overrides for Neon admin key lookup:
99
+ Optional remote-only Vault overrides for Neon admin key lookup:
58
100
 
59
101
  - `VAULT_SECRET_MOUNT` default `secret`
60
- - `VAULT_NEON_API_KEY_PATH` default `provider/neon-api-key`
61
- - `VAULT_NEON_API_KEY_FIELD` default `value`
102
+ - `VAULT_NEON_API_KEY_PATH` default `prod/providers/neon`
103
+ - `VAULT_NEON_API_KEY_FIELD` default `api_key`
62
104
 
63
105
  The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to bootstrap and deploy.
106
+
107
+ For attachments, the generated Cloud Run manifest injects:
108
+
109
+ - `ATTACHMENT_BUCKET`
110
+ - `ATTACHMENT_PUBLIC_BASE_URL`
111
+
112
+ Webhook signature hooks are provider-specific and optional in v1. The generic adapter will honor:
113
+
114
+ - `WEBHOOK_<PROVIDER>_SECRET`
115
+
116
+ ## One-command production bootstrap
117
+
118
+ The one-command production bootstrap path is designed for a fresh standalone service.
119
+
120
+ When generated through `create-svc` with `--bootstrap`, the intended flow is:
121
+
122
+ ```bash
123
+ bun create svc {{SERVICE_NAME}} --bootstrap --yes
124
+ ```
125
+
126
+ That command scaffolds this package, runs bootstrap, deploys the production
127
+ Cloud Run service, and fails loudly with resumable instructions if a required
128
+ cloud or provider credential is missing. The generated package can also be run
129
+ manually:
130
+
131
+ ```bash
132
+ {{COMMAND_BOOTSTRAP}}
133
+ {{COMMAND_DEPLOY}}
134
+ ```
135
+
136
+ Bootstrap reads provider credentials from environment variables or from Vault
137
+ when `VAULT_ADDR` and a Vault token are available. Runtime secrets are delivered
138
+ to Cloud Run through app-project Secret Manager.
139
+
140
+ ## Production API
141
+
142
+ The main environment is intended to be public at:
143
+
144
+ ```bash
145
+ https://{{API_HOSTNAME}}
146
+ ```
147
+
148
+ Preview and personal environments keep using deterministic Cloud Run hostnames in v1 so the generated backend stays easy to use inside standalone repos or monorepos.
149
+
150
+ ## Generated backend domain
151
+
152
+ The generated microservice profile is a waitlist/launch service example. It is
153
+ kept deliberately small so the integration plumbing is easy to remove or adapt:
154
+
155
+ - public launch/waitlist submission
156
+ - protected admin APIs
157
+ - logo or social-image upload plumbing
158
+ - provider webhook ingestion
159
+ - Pro entitlement checks for paid capabilities
160
+
161
+ The current backend plumbing includes:
162
+
163
+ - `users`
164
+ - `conversations`
165
+ - `conversation_participants`
166
+ - `messages`
167
+ - `attachments`
168
+ - `webhook_events`
169
+
170
+ HTTP variants expose REST endpoints for chat CRUD, attachment upload/finalize, and `/webhooks/:provider`.
171
+
172
+ ConnectRPC variants expose typed unary chat and attachment RPCs, while keeping webhook ingress on plain HTTP.
173
+
174
+ Transcript reads are newest-first and cursor-paginated:
175
+
176
+ - REST: `GET /v1/conversations/{conversationId}/messages?cursor=...&limit=...`
177
+ - ConnectRPC: `ListMessages(conversation_id, cursor, limit)`
178
+ - default page size `50`, max page size `100`
179
+ - message payloads include lightweight attachment metadata only
180
+ {{LOCAL_INTROSPECTION_NOTE}}
@@ -0,0 +1,19 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_DB: {{LOCAL_DATABASE_NAME}}
6
+ POSTGRES_USER: {{LOCAL_DATABASE_USER}}
7
+ POSTGRES_PASSWORD: {{LOCAL_DATABASE_PASSWORD}}
8
+ ports:
9
+ - "127.0.0.1:{{LOCAL_DATABASE_PORT}}:5432"
10
+ volumes:
11
+ - postgres-data:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U {{LOCAL_DATABASE_USER}} -d {{LOCAL_DATABASE_NAME}}"]
14
+ interval: 5s
15
+ timeout: 5s
16
+ retries: 10
17
+
18
+ volumes:
19
+ postgres-data:
@@ -1,5 +1,6 @@
1
- import { config, githubVariables } from "./config";
2
- import { ensureDatabase, getConnectionUri } from "./neon";
1
+ import { config } from "./config";
2
+ import { publishProviderRuntimeSecrets } from "./integrations";
3
+ import { ensureDatabase, getConnectionUri, resolveNeonConfig } from "./neon";
3
4
  import {
4
5
  addSecretVersion,
5
6
  attachBilling,
@@ -8,79 +9,51 @@ import {
8
9
  ensureProjectRole,
9
10
  ensureSecretAccessor,
10
11
  ensureServiceAccount,
11
- ensureServiceAccountRole,
12
- ensureWorkloadIdentityPool,
13
- ensureWorkloadIdentityProvider,
12
+ ensureStorageBucket,
14
13
  gcloud,
15
14
  requireCommand,
15
+ requireGcloudAuth,
16
16
  resolveDeploymentTarget,
17
17
  runMain,
18
18
  runStep,
19
- setGithubVariable,
20
- workloadIdentityPoolResource,
21
- workloadIdentityProviderResource,
22
19
  } from "./lib";
23
20
 
24
21
  export async function bootstrap() {
25
22
  requireCommand("gcloud");
26
- requireCommand("gh");
23
+ requireGcloudAuth();
27
24
 
28
25
  await runStep("Ensuring GCP project", () => ensureProject());
29
26
  await runStep("Attaching billing", () => attachBilling());
30
27
  await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
31
28
 
32
- await runStep("Ensuring runtime and deployer service accounts", () => {
29
+ await runStep("Ensuring runtime service account", () => {
33
30
  ensureServiceAccount(config.runtimeServiceAccount);
34
- ensureServiceAccount(config.deployerServiceAccount);
35
31
  });
36
32
 
37
33
  await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
34
+ await runStep("Ensuring attachment storage bucket", () => ensureStorageBucket());
38
35
 
39
36
  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
37
  ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
45
- ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
46
38
  });
47
39
 
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
- }
40
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
61
41
 
62
42
  const target = resolveDeploymentTarget("main");
63
- await runStep("Ensuring Neon database", () => ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName));
43
+ await runStep("Ensuring Neon database", () => ensureDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
64
44
 
65
45
  await runStep("Publishing database secret", async () => {
66
46
  const connectionUri = await getConnectionUri(
67
- config.neon.projectId,
68
- config.neon.baseBranchId,
69
- config.neon.databaseName,
70
- config.neon.roleName
47
+ neon.projectId,
48
+ neon.baseBranchId,
49
+ neon.databaseName,
50
+ neon.roleName
71
51
  );
72
52
  addSecretVersion(target.databaseSecretName, connectionUri);
73
53
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
74
54
  });
75
55
 
76
- await runStep("Configuring GitHub repository variables", () => {
77
- for (const [name, value] of Object.entries(githubVariables)) {
78
- setGithubVariable(name, value);
79
- }
80
-
81
- setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
82
- setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
83
- });
56
+ await runStep("Publishing provider runtime secrets", () => publishProviderRuntimeSecrets(target));
84
57
  }
85
58
 
86
59
  if (import.meta.main) {
@@ -1,18 +1,17 @@
1
1
  import { log } from "@clack/prompts";
2
- import { config, githubVariables } from "./config";
3
- import { deleteBranch, deleteDatabase, listBranches } from "./neon";
2
+ import { config } from "./config";
3
+ import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
4
4
  import {
5
- deleteGithubRepository,
6
- deleteGithubVariable,
7
5
  deleteProject,
6
+ deleteProductionDomainMapping,
8
7
  deleteSecret,
9
8
  deleteService,
10
9
  deleteServiceAccount,
11
- deleteWorkloadIdentityProvider,
12
10
  listCloudRunServices,
13
11
  listSecrets,
14
12
  parseCleanupArgs,
15
13
  requireCommand,
14
+ requireGcloudAuth,
16
15
  runMain,
17
16
  runStep,
18
17
  } from "./lib";
@@ -27,9 +26,12 @@ function matchesSecretResource(name: string) {
27
26
 
28
27
  export async function cleanup(args = Bun.argv.slice(2)) {
29
28
  requireCommand("gcloud");
29
+ requireGcloudAuth();
30
30
 
31
31
  const options = parseCleanupArgs(args);
32
32
 
33
+ await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
34
+
33
35
  const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
34
36
  const serviceNames = services.filter(matchesServiceResource);
35
37
  await runStep("Deleting Cloud Run services", () => {
@@ -46,52 +48,36 @@ export async function cleanup(args = Bun.argv.slice(2)) {
46
48
  }
47
49
  });
48
50
 
49
- if (config.neon.projectId && config.neon.baseBranchId) {
50
- const branches = await runStep("Finding Neon branches", () => listBranches(config.neon.projectId));
51
+ try {
52
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
53
+ const branches = await runStep("Finding Neon branches", () => listBranches(neon.projectId));
51
54
  const disposableBranches = branches.filter(
52
- (branch) => branch.name.startsWith(`${config.neon.previewBranchPrefix}-`) || branch.name.startsWith(`${config.neon.personalBranchPrefix}-`)
55
+ (branch: { name: string }) =>
56
+ branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
53
57
  );
54
58
 
55
59
  await runStep("Deleting Neon preview and personal branches", async () => {
56
60
  for (const branch of disposableBranches) {
57
- await deleteBranch(config.neon.projectId, branch.id);
61
+ await deleteBranch(neon.projectId, branch.id);
58
62
  }
59
63
  });
60
64
 
61
- await runStep("Deleting Neon service database", () =>
62
- deleteDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName)
63
- );
64
- } else {
65
+ await runStep("Deleting Neon service database", () => deleteDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
66
+ } catch (error) {
65
67
  log.step("Skipping Neon cleanup because Neon is not configured");
68
+ log.step(error instanceof Error ? error.message : String(error));
66
69
  }
67
70
 
68
71
  await runStep("Deleting service-specific identity resources", () => {
69
- deleteWorkloadIdentityProvider();
70
72
  deleteServiceAccount(config.runtimeServiceAccount);
71
- deleteServiceAccount(config.deployerServiceAccount);
72
73
  });
73
74
 
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
75
  if (options.destroyProject) {
91
76
  await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
92
77
  return `Deleted project ${config.project.id}`;
93
78
  }
94
79
 
80
+ log.step(`Production API hostname released: ${config.domain.hostname}`);
95
81
  return `Cleanup finished for ${config.serviceName}`;
96
82
  }
97
83
 
@@ -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,13 @@ 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
+ storage: {
27
+ attachmentBucket: "{{ATTACHMENT_BUCKET}}",
28
+ attachmentPublicBaseUrl: "{{ATTACHMENT_PUBLIC_BASE_URL}}",
23
29
  },
24
30
  neon: {
25
31
  projectId: "{{NEON_PROJECT_ID}}",
@@ -38,20 +44,9 @@ export const config = {
38
44
  "iamcredentials.googleapis.com",
39
45
  "secretmanager.googleapis.com",
40
46
  "serviceusage.googleapis.com",
47
+ "storage.googleapis.com",
41
48
  "sts.googleapis.com",
42
49
  ],
43
50
  } as const;
44
51
 
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
52
  export type DeployEnvironment = "main" | "preview" | "personal";
57
-
@@ -1,10 +1,12 @@
1
1
  import { config } from "./config";
2
2
  import { bootstrap } from "./bootstrap";
3
- import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches } from "./neon";
3
+ import { publishProviderRuntimeSecrets } from "./integrations";
4
+ import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches, resolveNeonConfig } from "./neon";
4
5
  import {
5
6
  addSecretVersion,
6
7
  deleteService,
7
8
  ensureArtifactRepository,
9
+ ensureProductionDomainMapping,
8
10
  ensureSecretAccessor,
9
11
  gcloud,
10
12
  imageUrl,
@@ -13,7 +15,7 @@ import {
13
15
  resolveDeploymentTarget,
14
16
  runMain,
15
17
  runStep,
16
- serviceUrl,
18
+ serviceOrigin,
17
19
  writeRenderedManifest,
18
20
  } from "./lib";
19
21
 
@@ -27,6 +29,7 @@ export async function deploy(args = Bun.argv.slice(2)) {
27
29
  }
28
30
 
29
31
  const target = resolveDeploymentTarget(options.environment, options.name);
32
+ const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
30
33
 
31
34
  if (options.destroy) {
32
35
  if (options.environment === "main") {
@@ -35,10 +38,10 @@ export async function deploy(args = Bun.argv.slice(2)) {
35
38
 
36
39
  await runStep(`Deleting Cloud Run service ${target.serviceName}`, () => deleteService(target.serviceName));
37
40
  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);
41
+ const branches = await listBranches(neon.projectId);
42
+ const branch = branches.find((candidate: { name: string }) => candidate.name === target.branchName);
40
43
  if (branch) {
41
- await deleteBranch(config.neon.projectId, branch.id);
44
+ await deleteBranch(neon.projectId, branch.id);
42
45
  }
43
46
  });
44
47
  return `Destroyed ${target.serviceName}`;
@@ -46,21 +49,23 @@ export async function deploy(args = Bun.argv.slice(2)) {
46
49
 
47
50
  await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
48
51
 
49
- let branchId = config.neon.baseBranchId;
52
+ let branchId: string = neon.baseBranchId;
50
53
  if (options.environment !== "main") {
51
54
  const branch = await runStep(`Ensuring Neon branch ${target.branchName}`, () =>
52
- ensureBranch(config.neon.projectId, target.branchName, config.neon.baseBranchId)
55
+ ensureBranch(neon.projectId, target.branchName, neon.baseBranchId)
53
56
  );
54
57
  branchId = branch.id;
55
58
  }
56
59
 
57
60
  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);
61
+ await ensureDatabase(neon.projectId, branchId, neon.databaseName);
62
+ const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
60
63
  addSecretVersion(target.databaseSecretName, connectionUri);
61
64
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
62
65
  });
63
66
 
67
+ await runStep("Publishing environment provider secrets", () => publishProviderRuntimeSecrets(target));
68
+
64
69
  const image = imageUrl();
65
70
  await runStep("Building container image", () =>
66
71
  gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
@@ -89,7 +94,11 @@ export async function deploy(args = Bun.argv.slice(2)) {
89
94
  ])
90
95
  );
91
96
 
92
- return serviceUrl(target.serviceName);
97
+ if (target.environment === "main") {
98
+ await runStep(`Ensuring production domain mapping for ${config.domain.hostname}`, () => ensureProductionDomainMapping(target.serviceName));
99
+ }
100
+
101
+ return serviceOrigin(target);
93
102
  }
94
103
 
95
104
  if (import.meta.main) {
@@ -0,0 +1,111 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { config } from "./config";
4
+ import {
5
+ addSecretVersion,
6
+ ensureSecretAccessor,
7
+ runtimeSecretNames,
8
+ type DeploymentTarget,
9
+ } from "./lib";
10
+
11
+ type ProviderSecret = {
12
+ envName: keyof ReturnType<typeof runtimeSecretNames>;
13
+ provider: string;
14
+ field: string;
15
+ };
16
+
17
+ const PROVIDER_SECRETS: ProviderSecret[] = [
18
+ { envName: "CLERK_SECRET_KEY", provider: "clerk", field: "secret_key" },
19
+ { envName: "CLERK_WEBHOOK_SECRET", provider: "clerk", field: "webhook_secret" },
20
+ { envName: "STRIPE_SECRET_KEY", provider: "stripe", field: "secret_key" },
21
+ { envName: "STRIPE_WEBHOOK_SECRET", provider: "stripe", field: "webhook_secret" },
22
+ { envName: "REVENUECAT_API_KEY", provider: "revenuecat", field: "api_key" },
23
+ { envName: "REVENUECAT_WEBHOOK_SECRET", provider: "revenuecat", field: "webhook_secret" },
24
+ { envName: "RESEND_API_KEY", provider: "resend", field: "api_key" },
25
+ { envName: "POSTHOG_API_KEY", provider: "posthog", field: "api_key" },
26
+ ];
27
+
28
+ export async function publishProviderRuntimeSecrets(target: DeploymentTarget) {
29
+ const secretNames = runtimeSecretNames(target);
30
+ const missing: string[] = [];
31
+
32
+ for (const secret of PROVIDER_SECRETS) {
33
+ const value = await resolveProviderSecret(secret);
34
+ if (!value) {
35
+ missing.push(formatMissingSecret(secret));
36
+ continue;
37
+ }
38
+
39
+ addSecretVersion(secretNames[secret.envName], value);
40
+ ensureSecretAccessor(secretNames[secret.envName], `serviceAccount:${config.runtimeServiceAccount}`);
41
+ }
42
+
43
+ if (missing.length > 0) {
44
+ throw new Error(
45
+ [
46
+ "Provider bootstrap credentials are required for the strict production bootstrap path.",
47
+ "Set the missing environment variables or write the matching Vault fields, then rerun the same bootstrap/deploy command.",
48
+ ...missing.map((item) => `- ${item}`),
49
+ ].join("\n")
50
+ );
51
+ }
52
+ }
53
+
54
+ async function resolveProviderSecret(secret: ProviderSecret) {
55
+ const direct = process.env[secret.envName]?.trim();
56
+ if (direct) {
57
+ return direct;
58
+ }
59
+
60
+ const addr = process.env.VAULT_ADDR?.trim() ?? "";
61
+ const token = await resolveVaultToken();
62
+ if (!addr || !token) {
63
+ return "";
64
+ }
65
+
66
+ const mount = process.env.VAULT_SECRET_MOUNT?.trim() ?? "secret";
67
+ const path = providerVaultPath(secret.provider);
68
+ const normalizedAddr = addr.replace(/\/+$/g, "");
69
+ const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
70
+ const response = await fetch(`${normalizedAddr}/v1/${normalizedMount}/data/${path}`, {
71
+ headers: {
72
+ "X-Vault-Token": token,
73
+ },
74
+ });
75
+
76
+ if (!response.ok) {
77
+ return "";
78
+ }
79
+
80
+ const payload = (await response.json()) as {
81
+ data?: {
82
+ data?: Record<string, string | undefined>;
83
+ };
84
+ };
85
+
86
+ return payload.data?.data?.[secret.field]?.trim() ?? "";
87
+ }
88
+
89
+ async function resolveVaultToken() {
90
+ const direct = process.env.VAULT_TOKEN?.trim();
91
+ if (direct) {
92
+ return direct;
93
+ }
94
+
95
+ const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(process.env.HOME?.trim() || homedir(), ".vault-token");
96
+
97
+ try {
98
+ return (await Bun.file(tokenFile).text()).trim();
99
+ } catch {
100
+ return "";
101
+ }
102
+ }
103
+
104
+ function providerVaultPath(provider: string) {
105
+ const override = process.env[`VAULT_${provider.toUpperCase()}_PATH`]?.trim();
106
+ return (override || `prod/providers/${provider}`).replace(/^\/+/g, "");
107
+ }
108
+
109
+ function formatMissingSecret(secret: ProviderSecret) {
110
+ return `${secret.envName} or Vault secret/${providerVaultPath(secret.provider)} field ${secret.field}`;
111
+ }