create-svc 0.1.62 → 0.1.64

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 (67) hide show
  1. package/README.md +9 -6
  2. package/package.json +2 -1
  3. package/src/cli.test.ts +1 -0
  4. package/src/cli.ts +17 -6
  5. package/src/naming.test.ts +6 -0
  6. package/src/naming.ts +1 -1
  7. package/src/scaffold.test.ts +19 -3
  8. package/src/scaffold.ts +10 -0
  9. package/src/service-runtime/authctl.ts +32 -0
  10. package/src/service-runtime/cloudrun/bootstrap.ts +3 -4
  11. package/src/service-runtime/cloudrun/cleanup.ts +6 -1
  12. package/src/service-runtime/cloudrun/cli.ts +52 -12
  13. package/src/service-runtime/cloudrun/config.ts +3 -0
  14. package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
  15. package/src/service-runtime/cloudrun/deploy.ts +25 -0
  16. package/src/service-runtime/cloudrun/lib.test.ts +12 -1
  17. package/src/service-runtime/cloudrun/lib.ts +55 -15
  18. package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
  19. package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
  20. package/src/service-runtime/workers/cli.ts +88 -0
  21. package/src/service.test.ts +15 -2
  22. package/src/service.ts +31 -1
  23. package/templates/shared/.env.example +1 -1
  24. package/templates/shared/README.md +41 -9
  25. package/templates/shared/scripts/dev.ts +37 -5
  26. package/templates/shared/service.jsonc +8 -2
  27. package/templates/shared/service.yaml +4 -1
  28. package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
  29. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  30. package/templates/targets/workers/README.md +28 -0
  31. package/templates/targets/workers/package.json +6 -1
  32. package/templates/targets/workers/src/index.ts +36 -25
  33. package/templates/targets/workers/src/trigger.ts +81 -0
  34. package/templates/targets/workers/test/app.test.ts +46 -1
  35. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  36. package/templates/targets/workers/trigger.config.ts +24 -0
  37. package/templates/targets/workers/tsconfig.json +1 -1
  38. package/templates/targets/workers/wrangler.toml +2 -0
  39. package/templates/variants/bun-connectrpc/package.json +1 -1
  40. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  41. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  42. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  43. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  44. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  45. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  46. package/templates/variants/bun-hono/package.json +1 -1
  47. package/templates/variants/bun-hono/src/index.ts +2 -6
  48. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  49. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  50. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  51. package/templates/variants/bun-hono/src/worker.ts +21 -0
  52. package/templates/variants/go-chi/Dockerfile +2 -0
  53. package/templates/variants/go-chi/Makefile +1 -1
  54. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  55. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  56. package/templates/variants/go-chi/internal/app/service.go +35 -3
  57. package/templates/variants/go-chi/internal/config/config.go +34 -3
  58. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  59. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  60. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  61. package/templates/variants/go-connectrpc/Makefile +1 -1
  62. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  63. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  65. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  66. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  67. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
package/README.md CHANGED
@@ -20,16 +20,19 @@ npm: <https://www.npmjs.com/package/create-svc>
20
20
  ## Usage
21
21
 
22
22
  ```bash
23
- service create my-service
23
+ service new my-service
24
24
  ```
25
25
 
26
26
  That creates `./my-service` by default. To write somewhere else while keeping
27
27
  the service id as `my-service`, pass `--dir`:
28
28
 
29
29
  ```bash
30
- service create my-service --dir /Users/andrewho/repos/projects/my-service
30
+ service new my-service --dir /Users/andrewho/repos/projects/my-service
31
31
  ```
32
32
 
33
+ `service create <service_id>` remains an alias for `service new <service_id>`
34
+ when you are outside a generated service repo.
35
+
33
36
  Inside a generated service repo, the same command operates that repo:
34
37
 
35
38
  ```bash
@@ -47,7 +50,7 @@ npm install -g create-svc@latest
47
50
  For the strict one-command production path:
48
51
 
49
52
  ```bash
50
- service create my-service --yes
53
+ service new my-service --yes
51
54
  ```
52
55
 
53
56
  By default, that scaffolds the repo, installs dependencies, runs the generated
@@ -75,14 +78,14 @@ Without publishing to npm:
75
78
  ```bash
76
79
  bun install
77
80
  bun link
78
- service create my-service
81
+ service new my-service
79
82
  ```
80
83
 
81
84
  For faster iteration against your working tree:
82
85
 
83
86
  ```bash
84
87
  bun link
85
- service create my-service
88
+ service new my-service
86
89
  ```
87
90
 
88
91
  During scaffold, the generator can discover:
@@ -166,7 +169,7 @@ The generated microservice domain is a small waitlist/launch service example wit
166
169
  ```bash
167
170
  bun install
168
171
  bun test src scripts
169
- bun run index.ts create my-service
172
+ bun run index.ts new my-service
170
173
  ```
171
174
 
172
175
  Validate the generated service matrix against local Docker Compose Postgres and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "src",
15
15
  "templates",
16
16
  "!bin/generated",
17
+ "!src/.cloudrun*.yaml",
17
18
  "!templates/**/node_modules",
18
19
  "README.md"
19
20
  ],
package/src/cli.test.ts CHANGED
@@ -72,6 +72,7 @@ test("formatScaffoldHelp is compact and starts at usage", () => {
72
72
  expect(help.startsWith("Usage:\n")).toBeTrue();
73
73
  expect(help).not.toContain("\n\n\n");
74
74
  expect(help).not.toContain("│");
75
+ expect(help).toContain("service new <service_id> [options]");
75
76
  expect(help).toContain("service create <service_id> [options]");
76
77
  expect(help).toContain("--dir <path>");
77
78
  });
package/src/cli.ts CHANGED
@@ -199,10 +199,19 @@ function formatCompletionSummary(config: ScaffoldConfig, targetDir: string, gitR
199
199
  ` Auth issuer: https://auth.anmho.com/api/auth`,
200
200
  ` Auth resource: api://${config.serviceName}`,
201
201
  ` Auth token URL: https://auth.anmho.com/api/auth/oauth2/token`,
202
- ` Temporal: disabled by default`,
203
- ` Temporal address: localhost:7233`,
204
- ` Temporal task queue: ${config.serviceName}`,
205
- ` Temporal API key secret: ${config.serviceName}-temporal-api-key`,
202
+ ...(config.target === "workers"
203
+ ? [
204
+ ` Trigger.dev task: ${config.serviceName}-waitlist-follow-up`,
205
+ ` Trigger.dev project env: TRIGGER_PROJECT_REF`,
206
+ ` Trigger.dev deploy env: TRIGGER_ACCESS_TOKEN`,
207
+ ` Trigger.dev secret env: TRIGGER_SECRET_KEY`,
208
+ ]
209
+ : [
210
+ ` Temporal: enabled by default`,
211
+ ` Temporal address: localhost:7233`,
212
+ ` Temporal task queue: ${config.serviceName}`,
213
+ ` Temporal API key secret: ${config.serviceName}-temporal-api-key`,
214
+ ]),
206
215
  config.runtime === "go" ? ` Go module: ${config.modulePath}` : undefined,
207
216
  "",
208
217
  config.autoDeploy ? "Verified production after deploy:" : "After deploy, verify production with:",
@@ -1218,11 +1227,13 @@ function printHelp() {
1218
1227
  export function formatScaffoldHelp() {
1219
1228
  return [
1220
1229
  "Usage:",
1230
+ " service new <service_id> [options]",
1221
1231
  " service create <service_id> [options]",
1222
1232
  "",
1223
1233
  "Examples:",
1224
- " service create waitlist-api --target cloudrun --runtime bun --framework hono",
1225
- " service create waitlist-api --auto-deploy",
1234
+ " service new waitlist-api --target cloudrun --runtime bun --framework hono",
1235
+ " service new waitlist-api --auto-deploy",
1236
+ " service create waitlist-api --yes",
1226
1237
  "",
1227
1238
  "Options:",
1228
1239
  " --dir <path> Output directory; defaults to ./<service_id>",
@@ -32,6 +32,12 @@ test("compactDatabaseName switches to underscores", () => {
32
32
  expect(compactDatabaseName("preview-worker")).toBe("preview_worker");
33
33
  });
34
34
 
35
+ test("deriveLocalPostgresPort stays out of ephemeral port ranges", () => {
36
+ const port = Number(deriveLocalPostgresPort("preview-worker"));
37
+ expect(port).toBeGreaterThanOrEqual(15432);
38
+ expect(port).toBeLessThan(16432);
39
+ });
40
+
35
41
  test("buildGcpProjectOptions puts the shared services project first", () => {
36
42
  const options = buildGcpProjectOptions("preview-worker", "anmho-preview-worker", "preview-worker", [
37
43
  { projectId: "anmho-existing", name: "existing" },
package/src/naming.ts CHANGED
@@ -81,7 +81,7 @@ export function compactDatabaseName(serviceName: string) {
81
81
  export function deriveLocalPostgresPort(serviceName: string) {
82
82
  const normalized = slugify(serviceName) || "my-service";
83
83
  const hash = Number.parseInt(shortHash(normalized).slice(0, 4), 16);
84
- return String(55000 + (hash % 1000));
84
+ return String(15432 + (hash % 1000));
85
85
  }
86
86
 
87
87
  export function deriveDefaults(serviceName: string) {
@@ -212,7 +212,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
212
212
  expect(makefile).toContain("$(ATLAS) migrate lint --env local --latest 1");
213
213
  expect(makefile).toContain("bun run ./scripts/ensure-local-db.ts");
214
214
  expect(makefile).toContain("bun run ./scripts/wait-for-db.ts");
215
- expect(makefile).toContain("bun run ./scripts/dev.ts go run ./cmd/server");
215
+ expect(makefile).toContain("bun run ./scripts/dev.ts go run ./cmd/server --worker go run ./cmd/worker");
216
216
  expect(await Bun.file(join(generatedRoot, "atlas.hcl")).exists()).toBeTrue();
217
217
  const atlasConfig = await Bun.file(join(generatedRoot, "atlas.hcl")).text();
218
218
  expect(atlasConfig).toContain('revisions_schema = "public"');
@@ -224,7 +224,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
224
224
  const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
225
225
  expect(packageJson).toContain('"@anmho/authctl": "0.1.1"');
226
226
  expect(packageJson).toContain("@temporalio/worker");
227
- expect(packageJson).toContain('"dev": "bun run ./scripts/dev.ts bun run ./src/index.ts"');
227
+ expect(packageJson).toContain('"dev": "bun run ./scripts/dev.ts bun run ./src/index.ts --worker bun run ./src/worker.ts"');
228
228
  expect(packageJson).toContain('"gen": "bun run ./scripts/codegen.ts"');
229
229
  expect(packageJson).toContain('"service": "service"');
230
230
  expect(packageJson).toContain('"migrate": "service migrate"');
@@ -356,6 +356,11 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
356
356
  expect(packageJson).toContain('"auth": "service auth"');
357
357
  expect(packageJson).toContain('"wrangler"');
358
358
  expect(packageJson).toContain('"pg"');
359
+ expect(packageJson).toContain('"@trigger.dev/sdk"');
360
+ expect(packageJson).toContain('"trigger.dev"');
361
+ expect(packageJson).toContain('"trigger": "trigger"');
362
+ expect(packageJson).toContain('"trigger:dev": "trigger dev"');
363
+ expect(packageJson).toContain('"trigger:deploy": "trigger deploy"');
359
364
 
360
365
  const wranglerConfig = await Bun.file(join(generatedRoot, "wrangler.toml")).text();
361
366
  expect(wranglerConfig).toContain('name = "dns-api"');
@@ -364,6 +369,8 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
364
369
  expect(wranglerConfig).toContain('binding = "HYPERDRIVE"');
365
370
  expect(wranglerConfig).toContain('AUTH_ENABLED = "true"');
366
371
  expect(wranglerConfig).toContain('AUTH_AUDIENCE = "api://dns-api"');
372
+ expect(wranglerConfig).toContain('TRIGGER_TASK_ID = "dns-api-waitlist-follow-up"');
373
+ expect(wranglerConfig).toContain('TRIGGER_API_URL = "https://api.trigger.dev"');
367
374
  expect(wranglerConfig).not.toContain("[triggers]");
368
375
  expect(wranglerConfig).not.toContain("crons");
369
376
  const authSource = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
@@ -375,15 +382,20 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
375
382
  expect(entrypoint).toContain("/v1/admin/waitlist");
376
383
  expect(entrypoint).toContain('app.use("/v1/*", authMiddleware())');
377
384
  expect(entrypoint).toContain("createStorage(context.env)");
378
- expect(entrypoint).toContain("scheduled");
385
+ expect(entrypoint).toContain("dispatchWaitlistFollowUp");
386
+ expect(entrypoint).not.toContain("scheduled");
379
387
  const readme = await Bun.file(join(generatedRoot, "README.md")).text();
380
388
  expect(readme).toContain("Cloudflare Workers");
381
389
  expect(readme).toContain("Hyperdrive binding for Neon-backed Postgres persistence");
390
+ expect(readme).toContain("Trigger.dev task dispatch");
382
391
  expect(readme).not.toContain("Cloud Run");
383
392
  const serviceConfig = await Bun.file(join(generatedRoot, "service.jsonc")).text();
384
393
  expect(serviceConfig).toContain('"target": "workers"');
385
394
  expect(serviceConfig).toContain('"hostname": "api.dns-api.anmho.com"');
386
395
  expect(serviceConfig).toContain('"database_name": "dns_api"');
396
+ expect(serviceConfig).toContain('"trigger_dev"');
397
+ expect(serviceConfig).toContain('"access_token_env": "TRIGGER_ACCESS_TOKEN"');
398
+ expect(serviceConfig).toContain('"waitlist_task_id": "dns-api-waitlist-follow-up"');
387
399
  const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
388
400
  expect(makefile).toContain('no generated code for workers');
389
401
  expect(makefile).toContain("auth:");
@@ -392,6 +404,9 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
392
404
  expect(await Bun.file(join(generatedRoot, "scripts", "authctl.ts")).exists()).toBeFalse();
393
405
  expect(await Bun.file(join(generatedRoot, "src", "auth.ts")).exists()).toBeTrue();
394
406
  expect(await Bun.file(join(generatedRoot, "src", "storage.ts")).exists()).toBeTrue();
407
+ expect(await Bun.file(join(generatedRoot, "src", "trigger.ts")).exists()).toBeTrue();
408
+ expect(await Bun.file(join(generatedRoot, "trigger.config.ts")).exists()).toBeTrue();
409
+ expect(await Bun.file(join(generatedRoot, "trigger", "waitlist-follow-up.ts")).exists()).toBeTrue();
395
410
  expect(await Bun.file(join(generatedRoot, "scripts", "workers", "cli.ts")).exists()).toBeFalse();
396
411
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cli.ts")).exists()).toBeFalse();
397
412
  expect(await Bun.file(join(generatedRoot, "scripts", "dev.ts")).exists()).toBeTrue();
@@ -403,6 +418,7 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
403
418
  expect(await Bun.file(join(generatedRoot, "docker-compose.yml")).exists()).toBeTrue();
404
419
  expect(await Bun.file(join(generatedRoot, "src", "db", "repository.ts")).exists()).toBeFalse();
405
420
  expect(await Bun.file(join(generatedRoot, "src", "temporal", "worker.ts")).exists()).toBeFalse();
421
+ expect(await Bun.file(join(generatedRoot, "src", "worker.ts")).exists()).toBeFalse();
406
422
  expect(await Bun.file(join(generatedRoot, "scripts", "codegen.ts")).exists()).toBeFalse();
407
423
 
408
424
  const previewWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "preview.yml")).text();
package/src/scaffold.ts CHANGED
@@ -113,6 +113,7 @@ function shouldSkipForTarget(target: DeployTarget, templateKind: "shared" | "var
113
113
  return (
114
114
  relativePath.startsWith("src/db/") ||
115
115
  relativePath.startsWith("src/temporal/") ||
116
+ relativePath === "src/worker.ts" ||
116
117
  relativePath.startsWith("src/waitlist/") ||
117
118
  relativePath.startsWith("test/") ||
118
119
  relativePath.startsWith("migrations/") ||
@@ -230,6 +231,10 @@ function buildReplacements(config: ScaffoldConfig) {
230
231
  AUTH_ISSUER: authIssuer,
231
232
  AUTH_AUDIENCE: authAudience,
232
233
  AUTH_JWKS_URL: authJwksUrl,
234
+ TEMPORAL_ENABLED: "true",
235
+ TEMPORAL_ADDRESS: "localhost:7233",
236
+ TEMPORAL_NAMESPACE: "default",
237
+ TEMPORAL_TASK_QUEUE: config.serviceName,
233
238
  LOCAL_DATABASE_NAME: localDatabaseName,
234
239
  LOCAL_DATABASE_PORT: localDatabasePort,
235
240
  LOCAL_DATABASE_USER: "postgres",
@@ -318,6 +323,11 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
318
323
  "# This file is user-owned after scaffold and is gitignored.",
319
324
  "",
320
325
  "DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable",
326
+ "TEMPORAL_ENABLED={{TEMPORAL_ENABLED}}",
327
+ "TEMPORAL_ADDRESS={{TEMPORAL_ADDRESS}}",
328
+ "TEMPORAL_NAMESPACE={{TEMPORAL_NAMESPACE}}",
329
+ "TEMPORAL_TASK_QUEUE={{TEMPORAL_TASK_QUEUE}}",
330
+ "",
321
331
  "",
322
332
  "VAULT_SECRET_MOUNT=secret",
323
333
  "VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access",
@@ -54,6 +54,10 @@ export function defaultAuthResourceServerArgs() {
54
54
  export function runAuthCommand(args: string[]) {
55
55
  const [subject, action, ...rest] = args;
56
56
 
57
+ if (!subject || subject === "--help" || subject === "-h" || subject === "help") {
58
+ return formatAuthHelp();
59
+ }
60
+
57
61
  if (!subject || subject === "doctor") {
58
62
  const result = runAuthDoctor();
59
63
  if (!result.hasAuthctl) {
@@ -104,12 +108,40 @@ export function runAuthCommand(args: string[]) {
104
108
  }
105
109
 
106
110
  if (subject === "token") {
111
+ if (action === "--help" || action === "-h" || action === "help") {
112
+ return formatAuthTokenHelp();
113
+ }
107
114
  return mintAuthToken(parseTokenOptions([action, ...rest].filter(Boolean) as string[]));
108
115
  }
109
116
 
110
117
  throw new Error("Usage: service auth <doctor|resource-server|client|token> [args]");
111
118
  }
112
119
 
120
+ function formatAuthHelp() {
121
+ return [
122
+ "Usage: service auth <doctor|resource-server|client|token> [args]",
123
+ "",
124
+ "Commands:",
125
+ " doctor Check authctl availability",
126
+ " resource-server Manage this service auth resource server",
127
+ " client Create or manage service client credentials",
128
+ " token Mint a bearer token for protected API checks",
129
+ ].join("\n");
130
+ }
131
+
132
+ function formatAuthTokenHelp() {
133
+ return [
134
+ "Usage: service auth token [options]",
135
+ "",
136
+ "Options:",
137
+ " --scope <scope> Request an additional or replacement scope; repeatable",
138
+ " --resource <uri> Token resource; defaults to this service audience",
139
+ " --audience <value> Optional audience parameter",
140
+ " --json Print the full token response",
141
+ " --help, -h Show this message",
142
+ ].join("\n");
143
+ }
144
+
113
145
  export function ensureAuthResourceServer() {
114
146
  const command = ensureResourceServerCommandAvailable();
115
147
  authctl([command.subject, command.mutationAction, ...defaultAuthResourceServerArgs(), "--json"], { quiet: true });
@@ -79,19 +79,18 @@ export async function prepareGcpProject() {
79
79
 
80
80
  function publishTemporalSecrets() {
81
81
  const temporal = resolveTemporalRuntimeConfig();
82
- const apiKey = process.env.TEMPORAL_API_KEY?.trim();
83
- if (!apiKey || !temporal.apiKeySecretName) {
82
+ if (!temporal.apiKey || !temporal.apiKeySecretName) {
84
83
  return "No Temporal API key configured";
85
84
  }
86
85
 
87
- addSecretVersion(temporal.apiKeySecretName, apiKey);
86
+ addSecretVersion(temporal.apiKeySecretName, temporal.apiKey);
88
87
  ensureSecretAccessor(temporal.apiKeySecretName, `serviceAccount:${config.runtimeServiceAccount}`);
89
88
  return temporal.apiKeySecretName;
90
89
  }
91
90
 
92
91
  function shouldPublishTemporalSecrets() {
93
92
  const temporal = resolveTemporalRuntimeConfig();
94
- return Boolean(process.env.TEMPORAL_API_KEY?.trim() && temporal.apiKeySecretName);
93
+ return Boolean(temporal.enabled && temporal.apiKey && temporal.apiKeySecretName);
95
94
  }
96
95
 
97
96
  if (import.meta.main) {
@@ -29,7 +29,12 @@ import {
29
29
  } from "./lib";
30
30
 
31
31
  function matchesServiceResource(name: string) {
32
- return name === config.serviceName || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
32
+ return (
33
+ name === config.serviceName ||
34
+ name === `${config.serviceName}-worker` ||
35
+ name.startsWith(`${config.serviceName}-pr-`) ||
36
+ name.startsWith(`${config.serviceName}-dev-`)
37
+ );
33
38
  }
34
39
 
35
40
  function matchesSecretResource(name: string) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdir } from "node:fs/promises";
3
+ import { mkdir, readdir } from "node:fs/promises";
4
4
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
5
  import { stopLocalDev } from "../local-dev";
6
6
  import { bootstrap, prepareGcpProject } from "./bootstrap";
@@ -42,7 +42,6 @@ export async function main(argv = Bun.argv.slice(2)) {
42
42
  await runStep("Provisioning auth client", () => ensureAuthClient());
43
43
  const bootstrapResult = await bootstrap({ skipProjectSetup: true });
44
44
  const databaseUrl = bootstrapResult.databaseUrl;
45
- await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
46
45
  const origin = await deploy(["--ci"], { bootstrapResult });
47
46
  await runOptionalBunScript("seed", { DATABASE_URL: databaseUrl });
48
47
  return `Created ${origin}`;
@@ -51,6 +50,10 @@ export async function main(argv = Bun.argv.slice(2)) {
51
50
  }
52
51
 
53
52
  if (command === "deploy") {
53
+ if (hasHelpFlag(rest)) {
54
+ console.log(formatHelp());
55
+ return;
56
+ }
54
57
  await runMain("Deploy", () => deploy(rest));
55
58
  return;
56
59
  }
@@ -118,6 +121,10 @@ export async function main(argv = Bun.argv.slice(2)) {
118
121
  throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
119
122
  }
120
123
 
124
+ function hasHelpFlag(args: string[]) {
125
+ return args.includes("--help") || args.includes("-h") || args.includes("help");
126
+ }
127
+
121
128
  function formatHelp() {
122
129
  return [
123
130
  "Usage:",
@@ -272,16 +279,16 @@ async function runDoctor() {
272
279
  if (!(await Bun.file("./buf.yaml").exists())) {
273
280
  throw new Error("missing buf.yaml");
274
281
  }
275
- if (!(await Bun.file("./protos/waitlist/v1/waitlist.proto").exists())) {
276
- throw new Error("missing waitlist proto");
282
+ const protoFiles = await findFiles("./protos", ".proto");
283
+ if (protoFiles.length === 0) {
284
+ throw new Error("missing ConnectRPC proto");
277
285
  }
278
- return "waitlist proto present";
286
+ return `${protoFiles.length} proto file(s) present`;
279
287
  });
280
288
  await record(results, "Buf CLI", "warn", () => checkCommand("buf"));
281
289
  await record(results, "generated SDK artifacts", "warn", async () => {
282
- const bunGen = await Bun.file("./gen/protos/waitlist/v1/waitlist_pb.ts").exists();
283
- const goGen = await Bun.file("./gen/waitlist/v1/waitlist.pb.go").exists();
284
- if (!bunGen && !goGen) {
290
+ const artifacts = await findGeneratedSdkArtifacts();
291
+ if (artifacts.length === 0) {
285
292
  throw new Error("generated SDK artifacts are missing; run service sdk build");
286
293
  }
287
294
  return "local generated artifacts present";
@@ -371,16 +378,15 @@ async function runSdk(args: string[]) {
371
378
  }
372
379
 
373
380
  async function assertLocalSdkArtifacts() {
374
- const bunArtifacts = await Bun.file("./gen/protos/waitlist/v1/waitlist_pb.ts").exists();
375
- const goArtifacts = await Bun.file("./gen/waitlist/v1/waitlist.pb.go").exists();
376
- if (!bunArtifacts && !goArtifacts) {
381
+ const artifacts = await findGeneratedSdkArtifacts();
382
+ if (artifacts.length === 0) {
377
383
  throw new Error("Local SDK artifacts are missing. Run `service sdk build` first.");
378
384
  }
379
385
  }
380
386
 
381
387
  async function writeSdkMode(mode: "local" | "remote") {
382
388
  await mkdir(".service", { recursive: true });
383
- const localPath = config.runtime === "bun" ? "./gen/protos/waitlist/v1" : "./gen/waitlist/v1";
389
+ const localPath = await resolveLocalSdkPath();
384
390
  await Bun.write(
385
391
  ".service/sdk.json",
386
392
  `${JSON.stringify(
@@ -400,6 +406,40 @@ function bufModule() {
400
406
  return `buf.build/anmho/${config.serviceName}`;
401
407
  }
402
408
 
409
+ async function resolveLocalSdkPath() {
410
+ const artifacts = await findGeneratedSdkArtifacts();
411
+ if (artifacts.length === 0) {
412
+ return config.runtime === "bun" ? "./gen/protos" : "./gen";
413
+ }
414
+ const artifact = artifacts[0] || "./gen";
415
+ return artifact.split("/").slice(0, -1).join("/") || "./gen";
416
+ }
417
+
418
+ async function findGeneratedSdkArtifacts() {
419
+ const suffixes = config.runtime === "bun" ? ["_pb.ts", "_pb.js"] : [".pb.go"];
420
+ const files = await findFiles("./gen");
421
+ return files.filter((file) => suffixes.some((suffix) => file.endsWith(suffix)));
422
+ }
423
+
424
+ async function findFiles(root: string, suffix = ""): Promise<string[]> {
425
+ let entries;
426
+ try {
427
+ entries = await readdir(root, { withFileTypes: true });
428
+ } catch {
429
+ return [];
430
+ }
431
+ const files: string[] = [];
432
+ for (const entry of entries) {
433
+ const path = `${root}/${entry.name}`;
434
+ if (entry.isDirectory()) {
435
+ files.push(...(await findFiles(path, suffix)));
436
+ } else if (!suffix || path.endsWith(suffix)) {
437
+ files.push(path);
438
+ }
439
+ }
440
+ return files;
441
+ }
442
+
403
443
  if (import.meta.main) {
404
444
  await main();
405
445
  }
@@ -3,6 +3,7 @@ import { serviceConfig } from "../runtime";
3
3
  const cloudrun = serviceConfig.cloudrun;
4
4
  const dns = serviceConfig.dns;
5
5
  const neon = serviceConfig.neon;
6
+ const vault = serviceConfig.providers?.vault ?? {};
6
7
 
7
8
  export const config = {
8
9
  serviceName: serviceConfig.service_id,
@@ -39,6 +40,8 @@ export const config = {
39
40
  namespace: serviceConfig.temporal.namespace,
40
41
  taskQueue: serviceConfig.temporal.task_queue,
41
42
  apiKeySecretName: serviceConfig.temporal.api_key_secret_name,
43
+ vaultMount: vault.mount || "secret",
44
+ vaultPath: vault.temporal_path || "prod/providers/temporal",
42
45
  },
43
46
  neon: {
44
47
  projectId: neon.project_id,
@@ -6,6 +6,11 @@ export type DeployArgs = {
6
6
  name?: string;
7
7
  };
8
8
 
9
+ export type RuntimeMigrationCommand = {
10
+ command: string;
11
+ args: string[];
12
+ };
13
+
9
14
  export const CLOUD_RUN_LOCAL_BUILD_PLATFORM = "linux/amd64";
10
15
 
11
16
  export function localDockerBuildArgs(image: string) {
@@ -93,3 +98,15 @@ function parseBuildStrategy(value: string | undefined): DeployArgs["build"] {
93
98
  }
94
99
  throw new Error(`Unknown build strategy: ${value}`);
95
100
  }
101
+
102
+ export function migrationCommandForRuntime(runtime: string): RuntimeMigrationCommand {
103
+ if (runtime === "bun") {
104
+ return { command: "bun", args: ["run", "./scripts/migrate.ts"] };
105
+ }
106
+
107
+ if (runtime === "go") {
108
+ return { command: "atlas", args: ["migrate", "apply", "--env", "local"] };
109
+ }
110
+
111
+ throw new Error(`migrate is not available for ${runtime}`);
112
+ }
@@ -15,12 +15,16 @@ import {
15
15
  localDockerBuildArgs,
16
16
  parseDeployArgs,
17
17
  requireCommand,
18
+ resolveTemporalRuntimeConfig,
18
19
  resolveDeploymentTarget,
20
+ run,
19
21
  runMain,
20
22
  runStep,
21
23
  serviceOrigin,
22
24
  writeRenderedManifest,
25
+ writeRenderedWorkerManifest,
23
26
  } from "./lib";
27
+ import { migrationCommandForRuntime } from "./deploy-args";
24
28
 
25
29
  type DeployOptions = {
26
30
  bootstrapResult?: BootstrapResult;
@@ -38,6 +42,7 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
38
42
  ? bootstrapResult.target
39
43
  : resolveDeploymentTarget(options.environment, options.name);
40
44
  const neon = bootstrapResult?.neon ?? (await runStep("Resolving Neon defaults", () => resolveNeonConfig()));
45
+ let databaseUrl = bootstrapResult?.databaseUrl;
41
46
 
42
47
  if (options.destroy) {
43
48
  if (options.environment === "main") {
@@ -71,10 +76,17 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
71
76
  await runStep("Publishing environment database secret", async () => {
72
77
  await ensureDatabase(neon.projectId, branchId, neon.databaseName);
73
78
  const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
79
+ databaseUrl = connectionUri;
74
80
  addSecretVersion(target.databaseSecretName, connectionUri);
75
81
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
76
82
  });
77
83
  }
84
+ if (!databaseUrl) {
85
+ throw new Error(`Could not resolve database URL for ${target.serviceName}`);
86
+ }
87
+ const resolvedDatabaseUrl = databaseUrl;
88
+ await runStep("Applying database migrations", () => runMigration(resolvedDatabaseUrl));
89
+
78
90
  const image = imageUrl();
79
91
  if (options.build === "cloudbuild") {
80
92
  await runStep("Building container image in Cloud Build", () =>
@@ -95,6 +107,13 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
95
107
  gcloud(["run", "services", "replace", renderedManifestPath.pathname, "--project", config.project.id, "--region", config.region])
96
108
  );
97
109
 
110
+ if (resolveTemporalRuntimeConfig().enabled) {
111
+ const renderedWorkerManifestPath = await runStep("Rendering Cloud Run worker manifest", () => writeRenderedWorkerManifest(image, target));
112
+ await runStep(`Deploying Cloud Run worker ${target.serviceName}-worker`, () =>
113
+ gcloud(["run", "services", "replace", renderedWorkerManifestPath.pathname, "--project", config.project.id, "--region", config.region])
114
+ );
115
+ }
116
+
98
117
  await runStep("Granting public invoker access", () =>
99
118
  gcloudWithRetry([
100
119
  "run",
@@ -119,6 +138,12 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
119
138
  return serviceOrigin(target);
120
139
  }
121
140
 
141
+ function runMigration(databaseUrl: string) {
142
+ const task = migrationCommandForRuntime(config.runtime);
143
+ run(task.command, task.args, { env: { DATABASE_URL: databaseUrl } });
144
+ return "migrate finished";
145
+ }
146
+
122
147
  if (import.meta.main) {
123
148
  await runMain("Deploy", () => deploy(Bun.argv.slice(2)));
124
149
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, expect, test } from "bun:test";
2
- import { localDockerBuildArgs, parseDeployArgs } from "./deploy-args";
2
+ import { localDockerBuildArgs, migrationCommandForRuntime, parseDeployArgs } from "./deploy-args";
3
3
 
4
4
  const originalBuild = process.env.SERVICE_BUILD;
5
5
  const originalBuildStrategy = process.env.SERVICE_BUILD_STRATEGY;
@@ -38,3 +38,14 @@ test("local Docker builds target Cloud Run's runtime platform", () => {
38
38
  ".",
39
39
  ]);
40
40
  });
41
+
42
+ test("migrationCommandForRuntime uses generated migration tooling", () => {
43
+ expect(migrationCommandForRuntime("bun")).toEqual({
44
+ command: "bun",
45
+ args: ["run", "./scripts/migrate.ts"],
46
+ });
47
+ expect(migrationCommandForRuntime("go")).toEqual({
48
+ command: "atlas",
49
+ args: ["migrate", "apply", "--env", "local"],
50
+ });
51
+ });