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
package/src/scaffold.ts CHANGED
@@ -2,30 +2,34 @@ import { mkdir, readdir } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  compactIdentifier,
5
+ compactDatabaseName,
6
+ deriveLocalPostgresPort,
7
+ type DeployTarget,
5
8
  type Framework,
6
9
  type GcpProjectMode,
7
10
  type Runtime,
8
11
  } from "./naming";
12
+ import { exampleForProfile, type Profile } from "./profiles";
13
+ import type { GitBootstrapConfig } from "./git-bootstrap";
9
14
 
10
15
  export type ScaffoldConfig = {
11
16
  directory: string;
12
17
  serviceName: string;
18
+ modulePath: string;
19
+ target: DeployTarget;
13
20
  runtime: Runtime;
14
21
  framework: Framework;
22
+ profile: Profile;
15
23
  region: string;
16
24
  gcpProjectMode: GcpProjectMode;
17
25
  gcpProject: string;
18
26
  gcpProjectName: string;
19
27
  billingAccount: string;
20
28
  quotaProjectId: string;
21
- githubRepo: string;
22
- githubVisibility: "public" | "private";
23
- createGithubRepo: boolean;
24
29
  autoDeploy: boolean;
25
- neonProjectId: string;
26
- neonBaseBranchId: string;
27
- neonBaseBranchName: string;
30
+ git: GitBootstrapConfig;
28
31
  neonDatabaseName: string;
32
+ apiHostname: string;
29
33
  generatorRoot: string;
30
34
  };
31
35
 
@@ -46,14 +50,20 @@ export async function scaffoldProject(config: ScaffoldConfig) {
46
50
  await ensureTargetDirectory(targetDir);
47
51
 
48
52
  const replacements = buildReplacements(config);
49
- const sharedTemplateRoot = resolve(config.generatorRoot, "templates", "shared");
50
- const variantTemplateRoot = resolve(config.generatorRoot, "templates", "variants", `${config.runtime}-${config.framework}`);
53
+ const templateRoots = [
54
+ { kind: "shared" as const, root: resolve(config.generatorRoot, "templates", "shared") },
55
+ { kind: "variant" as const, root: resolve(config.generatorRoot, "templates", "variants", `${config.runtime}-${config.framework}`) },
56
+ { kind: "target" as const, root: resolve(config.generatorRoot, "templates", "targets", config.target) },
57
+ ];
51
58
 
52
- for (const templateRoot of [sharedTemplateRoot, variantTemplateRoot]) {
53
- const files = await collectTemplateFiles(templateRoot);
59
+ for (const template of templateRoots) {
60
+ const files = await collectTemplateFiles(template.root);
54
61
 
55
62
  for (const relativePath of files) {
56
- const sourcePath = join(templateRoot, relativePath);
63
+ if (shouldSkipForTarget(config.target, template.kind, relativePath)) {
64
+ continue;
65
+ }
66
+ const sourcePath = join(template.root, relativePath);
57
67
  const destinationPath = join(targetDir, relativePath);
58
68
  const raw = await Bun.file(sourcePath).text();
59
69
  const rendered = renderTemplate(raw, replacements);
@@ -62,6 +72,45 @@ export async function scaffoldProject(config: ScaffoldConfig) {
62
72
  await Bun.write(destinationPath, rendered);
63
73
  }
64
74
  }
75
+
76
+ await writeLocalEnvFile(targetDir, replacements);
77
+ }
78
+
79
+ function shouldSkipForTarget(target: DeployTarget, templateKind: "shared" | "variant" | "target", relativePath: string) {
80
+ if (target === "workers") {
81
+ if (templateKind === "target") {
82
+ return false;
83
+ }
84
+
85
+ if (relativePath === "Dockerfile" || relativePath === "docker-compose.yml") {
86
+ return true;
87
+ }
88
+
89
+ if (templateKind === "shared") {
90
+ return (
91
+ relativePath === "service.yaml" ||
92
+ relativePath === "scripts/dev.ts" ||
93
+ relativePath === "scripts/ensure-local-db.ts" ||
94
+ relativePath === "scripts/local-docker.ts" ||
95
+ relativePath === "scripts/local-env.ts" ||
96
+ relativePath === "scripts/seed.ts" ||
97
+ relativePath === "scripts/wait-for-db.ts" ||
98
+ relativePath.startsWith("scripts/cloudrun/")
99
+ );
100
+ }
101
+
102
+ return (
103
+ relativePath.startsWith("src/db/") ||
104
+ relativePath.startsWith("src/temporal/") ||
105
+ relativePath.startsWith("src/waitlist/") ||
106
+ relativePath.startsWith("test/") ||
107
+ relativePath.startsWith("migrations/") ||
108
+ relativePath === "scripts/codegen.ts" ||
109
+ relativePath === "scripts/migrate.ts"
110
+ );
111
+ }
112
+
113
+ return relativePath.startsWith("scripts/workers/") || relativePath === "wrangler.toml";
65
114
  }
66
115
 
67
116
  async function ensureTargetDirectory(targetDir: string) {
@@ -85,12 +134,23 @@ export async function assertTargetDirectoryIsEmpty(targetDir: string) {
85
134
 
86
135
  async function collectTemplateFiles(root: string, relative = ""): Promise<string[]> {
87
136
  const cwd = join(root, relative);
88
- const entries = await readdir(cwd, { withFileTypes: true });
137
+ let entries;
138
+ try {
139
+ entries = await readdir(cwd, { withFileTypes: true });
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
142
+ return [];
143
+ }
144
+ throw error;
145
+ }
89
146
  const files: string[] = [];
90
147
 
91
148
  for (const entry of entries) {
92
149
  const nextRelative = relative ? join(relative, entry.name) : entry.name;
93
150
  if (entry.isDirectory()) {
151
+ if (entry.name === "node_modules" || entry.name === ".git") {
152
+ continue;
153
+ }
94
154
  files.push(...(await collectTemplateFiles(root, nextRelative)));
95
155
  continue;
96
156
  }
@@ -101,19 +161,22 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
101
161
  }
102
162
 
103
163
  function buildReplacements(config: ScaffoldConfig) {
104
- const [githubOwner = "anmho"] = config.githubRepo.split("/");
105
- const modulePath = `github.com/${config.githubRepo}`;
164
+ const example = exampleForProfile(config.profile);
106
165
  const serviceAccountBase = compactIdentifier(config.serviceName, 21);
107
166
  const runtimeServiceAccount = `${serviceAccountBase}-runtime@${config.gcpProject}.iam.gserviceaccount.com`;
108
- const deployerServiceAccount = `${serviceAccountBase}-deployer@${config.gcpProject}.iam.gserviceaccount.com`;
109
- const wifPoolId = "github";
110
- const wifProviderId = compactIdentifier(config.serviceName, 32);
111
167
  const previewBranchPrefix = `${config.serviceName}-pr`;
112
168
  const personalBranchPrefix = `${config.serviceName}-dev`;
169
+ const localDatabaseName = compactDatabaseName(config.serviceName);
170
+ const localDatabasePort = deriveLocalPostgresPort(config.serviceName);
171
+ const authIssuer = "https://auth.anmho.com/api/auth";
172
+ const authAudience = `api://${config.serviceName}`;
173
+ const authJwksUrl = `${authIssuer}/jwks`;
113
174
 
114
175
  return {
115
176
  SERVICE_NAME: config.serviceName,
116
- MODULE_PATH: modulePath,
177
+ SERVICE_ID: config.serviceName,
178
+ MODULE_PATH: config.modulePath,
179
+ TARGET: config.target,
117
180
  PROJECT_ID: config.gcpProject,
118
181
  PROJECT_NAME: config.gcpProjectName,
119
182
  REGION: config.region,
@@ -121,28 +184,91 @@ function buildReplacements(config: ScaffoldConfig) {
121
184
  PROJECT_CREATE_IF_MISSING: String(config.gcpProjectMode === "create_new"),
122
185
  BILLING_ACCOUNT: config.billingAccount,
123
186
  QUOTA_PROJECT_ID: config.quotaProjectId,
124
- GITHUB_REPO: config.githubRepo,
125
- GITHUB_OWNER: githubOwner,
126
- GITHUB_VISIBILITY: config.githubVisibility,
127
- GITHUB_CREATE_IF_MISSING: String(config.createGithubRepo),
128
187
  AUTO_DEPLOY: String(config.autoDeploy),
129
188
  RUNTIME: config.runtime,
130
189
  FRAMEWORK: config.framework,
190
+ PROFILE: config.profile,
191
+ EXAMPLE_KIND: example.kind,
192
+ EXAMPLE_DOMAIN: example.domain,
193
+ EXAMPLE_LABEL: example.label,
131
194
  CLOUD_RUN_SERVICE: config.serviceName,
132
- NEON_PROJECT_ID: config.neonProjectId,
133
- NEON_BASE_BRANCH_ID: config.neonBaseBranchId,
134
- NEON_BASE_BRANCH_NAME: config.neonBaseBranchName,
195
+ NEON_PROJECT_ID: "",
196
+ NEON_BASE_BRANCH_ID: "",
197
+ NEON_BASE_BRANCH_NAME: "main",
135
198
  NEON_DATABASE_NAME: config.neonDatabaseName,
136
199
  NEON_ROLE_NAME: "neondb_owner",
137
200
  NEON_PREVIEW_BRANCH_PREFIX: previewBranchPrefix,
138
201
  NEON_PERSONAL_BRANCH_PREFIX: personalBranchPrefix,
139
202
  RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
140
- DEPLOYER_SERVICE_ACCOUNT: deployerServiceAccount,
141
- WIF_POOL_ID: wifPoolId,
142
- WIF_PROVIDER_ID: wifProviderId,
203
+ API_HOSTNAME: config.apiHostname,
204
+ API_BASE_DOMAIN: "anmho.com",
205
+ AUTH_ISSUER: authIssuer,
206
+ AUTH_AUDIENCE: authAudience,
207
+ AUTH_JWKS_URL: authJwksUrl,
208
+ LOCAL_DATABASE_NAME: localDatabaseName,
209
+ LOCAL_DATABASE_PORT: localDatabasePort,
210
+ LOCAL_DATABASE_USER: "postgres",
211
+ LOCAL_DATABASE_PASSWORD: "postgres",
212
+ COMMAND_DEV: config.runtime === "bun" ? "bun run dev" : "make dev",
213
+ COMMAND_MIGRATE: config.runtime === "bun" ? "bun run migrate" : "make migrate",
214
+ COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
215
+ COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
216
+ COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
217
+ COMMAND_BOOTSTRAP: config.runtime === "bun" ? "bun run create" : "make create",
218
+ COMMAND_DEPLOY: config.runtime === "bun" ? "bun run deploy" : "make deploy",
219
+ COMMAND_AUTH_RESOURCE:
220
+ config.runtime === "bun" ? "bun run auth -- resource-server" : 'make auth ARGS="resource-server"',
221
+ COMMAND_AUTH_CLIENT:
222
+ config.runtime === "bun"
223
+ ? "bun run auth -- client create"
224
+ : 'make auth ARGS="client create"',
225
+ COMMAND_DEPLOY_PERSONAL:
226
+ config.runtime === "bun"
227
+ ? 'bun run deploy -- --environment personal --name <slug>'
228
+ : 'make deploy ARGS="--environment personal --name <slug>"',
229
+ COMMAND_DEPLOY_DESTROY:
230
+ config.runtime === "bun"
231
+ ? 'bun run destroy -- --environment personal --name <slug>'
232
+ : 'make destroy ARGS="--environment personal --name <slug>"',
233
+ COMMAND_CLEANUP: config.runtime === "bun" ? "bun run destroy" : "make destroy",
234
+ COMMAND_CLEANUP_PROJECT: config.runtime === "bun" ? "bun run destroy -- --project" : 'make destroy ARGS="--project"',
235
+ GITIGNORE_EXTRA: "",
236
+ LOCAL_INTROSPECTION_NOTE:
237
+ config.framework === "connectrpc"
238
+ ? [
239
+ "",
240
+ "## Local introspection",
241
+ "",
242
+ "When running locally, ConnectRPC variants expose introspection by default.",
243
+ "",
244
+ "- `go + connectrpc`: standard gRPC reflection for tools like `grpcurl list localhost:<port>`",
245
+ "- `bun + connectrpc`: JSON introspection at `/debug/connectrpc`",
246
+ "- override with `ENABLE_RPC_INTROSPECTION=true|false`",
247
+ ].join("\n")
248
+ : "",
143
249
  };
144
250
  }
145
251
 
252
+ async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
253
+ const envPath = join(targetDir, ".env.local");
254
+ if (await Bun.file(envPath).exists()) {
255
+ return;
256
+ }
257
+
258
+ const rendered = renderTemplate(
259
+ [
260
+ "# Generated local development defaults for create-service.",
261
+ "# This file is user-owned after scaffold and is gitignored.",
262
+ "",
263
+ "DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable",
264
+ "",
265
+ ].join("\n"),
266
+ replacements
267
+ );
268
+
269
+ await Bun.write(envPath, rendered);
270
+ }
271
+
146
272
  function renderTemplate(input: string, replacements: Record<string, string>) {
147
273
  return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key: string) => {
148
274
  const replacement = replacements[key];
package/src/vault.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { afterEach, expect, mock, test } from "bun:test";
2
2
  import { mkdir } from "node:fs/promises";
3
- import { readVaultSecret, resolveNeonApiKey } from "./vault";
3
+ import { readVaultSecret, resolveNeonApiKey, upsertVaultSecretFields } from "./vault";
4
4
 
5
5
  const originalEnv = { ...process.env };
6
6
 
@@ -14,17 +14,41 @@ test("resolveNeonApiKey prefers NEON_API_KEY from env", async () => {
14
14
  await expect(resolveNeonApiKey()).resolves.toBe("direct-token");
15
15
  });
16
16
 
17
+ test("resolveNeonApiKey reads the purpose-named Neon provider secret by default", async () => {
18
+ delete process.env.NEON_API_KEY;
19
+ process.env.VAULT_ADDR = "https://vault.example.com";
20
+ process.env.VAULT_TOKEN = "token-123";
21
+
22
+ const fetchMock = mock(async (input: string | URL | Request) => {
23
+ expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
24
+ return new Response(
25
+ JSON.stringify({
26
+ data: {
27
+ data: {
28
+ api_key: "vault-token",
29
+ },
30
+ },
31
+ }),
32
+ { status: 200 }
33
+ );
34
+ });
35
+
36
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
37
+
38
+ await expect(resolveNeonApiKey()).resolves.toBe("vault-token");
39
+ });
40
+
17
41
  test("readVaultSecret reads KV v2 secret data using existing vault login env", async () => {
18
42
  process.env.VAULT_ADDR = "https://vault.example.com";
19
43
  process.env.VAULT_TOKEN = "token-123";
20
44
 
21
45
  const fetchMock = mock(async (input: string | URL | Request) => {
22
- expect(String(input)).toBe("https://vault.example.com/v1/secret/data/provider/neon-api-key");
46
+ expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
23
47
  return new Response(
24
48
  JSON.stringify({
25
49
  data: {
26
50
  data: {
27
- value: "vault-token",
51
+ api_key: "vault-token",
28
52
  },
29
53
  },
30
54
  }),
@@ -32,12 +56,12 @@ test("readVaultSecret reads KV v2 secret data using existing vault login env", a
32
56
  );
33
57
  });
34
58
 
35
- globalThis.fetch = fetchMock as typeof fetch;
59
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
36
60
 
37
61
  await expect(
38
62
  readVaultSecret({
39
- path: "provider/neon-api-key",
40
- field: "value",
63
+ path: "prod/providers/neon",
64
+ field: "api_key",
41
65
  })
42
66
  ).resolves.toBe("vault-token");
43
67
  });
@@ -56,7 +80,7 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
56
80
  JSON.stringify({
57
81
  data: {
58
82
  data: {
59
- value: "vault-token",
83
+ api_key: "vault-token",
60
84
  },
61
85
  },
62
86
  }),
@@ -64,12 +88,72 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
64
88
  );
65
89
  });
66
90
 
67
- globalThis.fetch = fetchMock as typeof fetch;
91
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
68
92
 
69
93
  await expect(
70
94
  readVaultSecret({
71
- path: "provider/neon-api-key",
72
- field: "value",
95
+ path: "prod/providers/neon",
96
+ field: "api_key",
73
97
  })
74
98
  ).resolves.toBe("vault-token");
75
99
  });
100
+
101
+ test("upsertVaultSecretFields writes merged KV v2 data", async () => {
102
+ process.env.VAULT_ADDR = "https://vault.example.com";
103
+ process.env.VAULT_TOKEN = "token-123";
104
+
105
+ const requests: Array<{ method: string; url: string; body?: unknown }> = [];
106
+ const fetchMock = mock(async (input: string | URL | Request, init?: RequestInit) => {
107
+ const url = String(input);
108
+ requests.push({
109
+ method: init?.method ?? "GET",
110
+ url,
111
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
112
+ });
113
+
114
+ if ((init?.method ?? "GET") === "GET") {
115
+ return new Response(
116
+ JSON.stringify({
117
+ data: {
118
+ data: {
119
+ existing_field: "keep-me",
120
+ },
121
+ },
122
+ }),
123
+ { status: 200 }
124
+ );
125
+ }
126
+
127
+ return new Response(JSON.stringify({}), { status: 200 });
128
+ });
129
+
130
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
131
+
132
+ await upsertVaultSecretFields({
133
+ path: "prod/providers/clerk",
134
+ fields: {
135
+ publishable_key: "pk_live_example",
136
+ secret_key: "sk_live_example",
137
+ webhook_secret: "whsec_example",
138
+ },
139
+ });
140
+
141
+ expect(requests).toEqual([
142
+ {
143
+ method: "GET",
144
+ url: "https://vault.example.com/v1/secret/data/prod/providers/clerk",
145
+ },
146
+ {
147
+ method: "POST",
148
+ url: "https://vault.example.com/v1/secret/data/prod/providers/clerk",
149
+ body: {
150
+ data: {
151
+ existing_field: "keep-me",
152
+ publishable_key: "pk_live_example",
153
+ secret_key: "sk_live_example",
154
+ webhook_secret: "whsec_example",
155
+ },
156
+ },
157
+ },
158
+ ]);
159
+ });
package/src/vault.ts CHANGED
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
 
4
4
  const DEFAULT_VAULT_SECRET_MOUNT = "secret";
5
- const DEFAULT_NEON_API_KEY_PATH = "provider/neon-api-key";
6
- const DEFAULT_NEON_API_KEY_FIELD = "value";
5
+ const DEFAULT_NEON_API_KEY_PATH = "prod/providers/neon";
6
+ const DEFAULT_NEON_API_KEY_FIELD = "api_key";
7
7
 
8
8
  type VaultSecretOptions = {
9
9
  addr?: string;
@@ -13,6 +13,14 @@ type VaultSecretOptions = {
13
13
  field?: string;
14
14
  };
15
15
 
16
+ type VaultWriteOptions = {
17
+ addr?: string;
18
+ token?: string;
19
+ mount?: string;
20
+ path: string;
21
+ fields: Record<string, string>;
22
+ };
23
+
16
24
  export async function resolveNeonApiKey() {
17
25
  const direct = process.env.NEON_API_KEY?.trim();
18
26
  if (direct) {
@@ -26,24 +34,59 @@ export async function resolveNeonApiKey() {
26
34
  }
27
35
 
28
36
  export async function readVaultSecret(options: VaultSecretOptions = {}) {
29
- const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
30
- const token = options.token ?? (await resolveVaultToken());
37
+ const field = options.field?.trim() ?? "value";
38
+ const payload = await readVaultSecretData(options);
31
39
  const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
32
40
  const path = options.path?.trim() ?? "";
33
- const field = options.field?.trim() ?? "value";
34
-
35
- if (!addr || !token || !path) {
36
- throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
37
- }
38
-
39
- const normalizedAddr = addr.replace(/\/+$/g, "");
40
41
  const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
41
42
  const normalizedPath = path.replace(/^\/+/g, "");
42
- const url = `${normalizedAddr}/v1/${normalizedMount}/data/${normalizedPath}`;
43
+ const value = payload[field]?.trim();
44
+ if (!value) {
45
+ throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
46
+ }
47
+
48
+ return value;
49
+ }
50
+
51
+ export async function readVaultSecretFields(options: VaultSecretOptions = {}) {
52
+ return readVaultSecretData(options);
53
+ }
54
+
55
+ export async function upsertVaultSecretFields(options: VaultWriteOptions) {
56
+ const connection = await resolveVaultConnection(options);
57
+ const url = vaultKv2Url(connection);
58
+
59
+ const existing = await readVaultSecretData({ ...options, path: connection.normalizedPath }).catch((error) => {
60
+ if (error instanceof Error && error.message.startsWith("Vault read failed: 404")) {
61
+ return {};
62
+ }
63
+ throw error;
64
+ });
43
65
 
44
66
  const response = await fetch(url, {
67
+ method: "POST",
45
68
  headers: {
46
- "X-Vault-Token": token,
69
+ "Content-Type": "application/json",
70
+ "X-Vault-Token": connection.token,
71
+ },
72
+ body: JSON.stringify({
73
+ data: {
74
+ ...existing,
75
+ ...trimFields(options.fields),
76
+ },
77
+ }),
78
+ });
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`Vault write failed: ${response.status} ${response.statusText}`);
82
+ }
83
+ }
84
+
85
+ async function readVaultSecretData(options: VaultSecretOptions = {}) {
86
+ const connection = await resolveVaultConnection(options);
87
+ const response = await fetch(vaultKv2Url(connection), {
88
+ headers: {
89
+ "X-Vault-Token": connection.token,
47
90
  },
48
91
  });
49
92
 
@@ -57,12 +100,31 @@ export async function readVaultSecret(options: VaultSecretOptions = {}) {
57
100
  };
58
101
  };
59
102
 
60
- const value = payload.data?.data?.[field]?.trim();
61
- if (!value) {
62
- throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
103
+ return payload.data?.data ?? {};
104
+ }
105
+
106
+ async function resolveVaultConnection(options: Omit<VaultWriteOptions, "fields"> | VaultSecretOptions) {
107
+ const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
108
+ const token = options.token ?? (await resolveVaultToken());
109
+ const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
110
+ const path = options.path?.trim() ?? "";
111
+
112
+ if (!addr || !token || !path) {
113
+ throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
63
114
  }
64
115
 
65
- return value;
116
+ const normalizedAddr = addr.replace(/\/+$/g, "");
117
+ const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
118
+ const normalizedPath = path.replace(/^\/+/g, "");
119
+ return { normalizedAddr, normalizedMount, normalizedPath, token };
120
+ }
121
+
122
+ function vaultKv2Url(connection: Awaited<ReturnType<typeof resolveVaultConnection>>) {
123
+ return `${connection.normalizedAddr}/v1/${connection.normalizedMount}/data/${connection.normalizedPath}`;
124
+ }
125
+
126
+ function trimFields(fields: Record<string, string>) {
127
+ return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, value.trim()]));
66
128
  }
67
129
 
68
130
  async function resolveVaultToken() {
@@ -71,7 +133,8 @@ async function resolveVaultToken() {
71
133
  return direct;
72
134
  }
73
135
 
74
- const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(homedir(), ".vault-token");
136
+ const home = process.env.HOME?.trim() || homedir();
137
+ const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(home, ".vault-token");
75
138
 
76
139
  try {
77
140
  const value = (await Bun.file(tokenFile).text()).trim();
@@ -19,4 +19,5 @@ jobs:
19
19
  - run: bun install
20
20
  - run: bun lint
21
21
  - run: bun test
22
-
22
+ - if: ${{ vars.GCX_ENABLED == 'true' && github.ref == 'refs/heads/main' }}
23
+ run: bun run dashboards
@@ -28,3 +28,5 @@ jobs:
28
28
  - run: bun run deploy -- --ci
29
29
  env:
30
30
  NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
31
+ - if: ${{ vars.GCX_ENABLED == 'true' }}
32
+ run: bun run dashboards