create-svc 0.1.61 → 0.1.63
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.
- package/README.md +9 -6
- package/package.json +2 -1
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +17 -6
- package/src/naming.test.ts +6 -0
- package/src/naming.ts +1 -1
- package/src/scaffold.test.ts +19 -3
- package/src/scaffold.ts +10 -0
- package/src/service-runtime/authctl.ts +32 -0
- package/src/service-runtime/cloudrun/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
- package/src/service-runtime/cloudrun/deploy.ts +25 -0
- package/src/service-runtime/cloudrun/lib.test.ts +12 -1
- package/src/service-runtime/cloudrun/lib.ts +26 -3
- package/src/service-runtime/workers/cli.ts +88 -0
- package/src/service.test.ts +15 -2
- package/src/service.ts +31 -1
- package/templates/shared/.env.example +1 -1
- package/templates/shared/README.md +41 -9
- package/templates/shared/scripts/dev.ts +37 -5
- package/templates/shared/service.jsonc +8 -2
- package/templates/shared/service.yaml +4 -1
- package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
- package/templates/targets/workers/.github/workflows/preview-cleanup.yml +1 -1
- package/templates/targets/workers/.github/workflows/preview.yml +3 -0
- package/templates/targets/workers/README.md +28 -0
- package/templates/targets/workers/package.json +6 -1
- package/templates/targets/workers/src/index.ts +36 -25
- package/templates/targets/workers/src/trigger.ts +81 -0
- package/templates/targets/workers/test/app.test.ts +46 -1
- package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
- package/templates/targets/workers/trigger.config.ts +24 -0
- package/templates/targets/workers/tsconfig.json +1 -1
- package/templates/targets/workers/wrangler.toml +2 -0
- package/templates/variants/bun-connectrpc/package.json +1 -1
- package/templates/variants/bun-connectrpc/src/index.ts +2 -6
- package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
- package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
- package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
- package/templates/variants/bun-hono/package.json +1 -1
- package/templates/variants/bun-hono/src/index.ts +2 -6
- package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
- package/templates/variants/bun-hono/src/temporal.ts +56 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
- package/templates/variants/bun-hono/src/worker.ts +21 -0
- package/templates/variants/go-chi/Dockerfile +2 -0
- package/templates/variants/go-chi/Makefile +1 -1
- package/templates/variants/go-chi/cmd/server/main.go +5 -4
- package/templates/variants/go-chi/cmd/worker/main.go +56 -0
- package/templates/variants/go-chi/internal/app/service.go +35 -3
- package/templates/variants/go-chi/internal/config/config.go +34 -3
- package/templates/variants/go-chi/internal/config/config_test.go +63 -0
- package/templates/variants/go-chi/internal/temporal/client.go +55 -0
- package/templates/variants/go-connectrpc/Dockerfile +2 -0
- package/templates/variants/go-connectrpc/Makefile +1 -1
- package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
- package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
- package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
- package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.1.63",
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
1225
|
-
" service
|
|
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>",
|
package/src/naming.test.ts
CHANGED
|
@@ -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(
|
|
84
|
+
return String(15432 + (hash % 1000));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export function deriveDefaults(serviceName: string) {
|
package/src/scaffold.test.ts
CHANGED
|
@@ -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("
|
|
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 });
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
} from "./lib";
|
|
30
30
|
|
|
31
31
|
function matchesServiceResource(name: string) {
|
|
32
|
-
return
|
|
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
|
-
|
|
276
|
-
|
|
282
|
+
const protoFiles = await findFiles("./protos", ".proto");
|
|
283
|
+
if (protoFiles.length === 0) {
|
|
284
|
+
throw new Error("missing ConnectRPC proto");
|
|
277
285
|
}
|
|
278
|
-
return
|
|
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
|
|
283
|
-
|
|
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
|
|
375
|
-
|
|
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 =
|
|
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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -36,6 +36,8 @@ type CommandResult = {
|
|
|
36
36
|
exitCode: number;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
type CloudRunProcess = "api" | "worker";
|
|
40
|
+
|
|
39
41
|
const decoder = new TextDecoder();
|
|
40
42
|
const encoder = new TextEncoder();
|
|
41
43
|
const CLOUDFLARE_DNS_TTL_AUTO = 1;
|
|
@@ -506,12 +508,16 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
|
|
|
506
508
|
};
|
|
507
509
|
}
|
|
508
510
|
|
|
509
|
-
export async function renderManifest(image: string, target: DeploymentTarget) {
|
|
511
|
+
export async function renderManifest(image: string, target: DeploymentTarget, process: CloudRunProcess = "api") {
|
|
510
512
|
const template = await Bun.file(join(serviceRoot, "service.yaml")).text();
|
|
511
513
|
const temporal = resolveTemporalRuntimeConfig();
|
|
514
|
+
const serviceName = process === "worker" ? `${target.serviceName}-worker` : target.serviceName;
|
|
512
515
|
const values = {
|
|
513
|
-
SERVICE_NAME:
|
|
516
|
+
SERVICE_NAME: serviceName,
|
|
514
517
|
SERVICE_ID: config.serviceName,
|
|
518
|
+
SERVICE_ROLE: process,
|
|
519
|
+
SERVICE_INGRESS: process === "worker" ? "internal" : "all",
|
|
520
|
+
CONTAINER_COMMAND: renderContainerCommand(process),
|
|
515
521
|
RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
|
|
516
522
|
IMAGE_URL: image,
|
|
517
523
|
DATABASE_URL_SECRET: target.databaseSecretName,
|
|
@@ -552,7 +558,7 @@ export function resolveTemporalRuntimeConfig() {
|
|
|
552
558
|
const apiKeySecretName = process.env.TEMPORAL_API_KEY_SECRET?.trim() || (process.env.TEMPORAL_API_KEY?.trim() ? config.temporal.apiKeySecretName : "");
|
|
553
559
|
const enabled = enabledOverride
|
|
554
560
|
? ["1", "true", "yes", "on"].includes(enabledOverride.toLowerCase())
|
|
555
|
-
:
|
|
561
|
+
: config.temporal.enabled;
|
|
556
562
|
|
|
557
563
|
return {
|
|
558
564
|
enabled,
|
|
@@ -570,6 +576,23 @@ export async function writeRenderedManifest(image: string, target: DeploymentTar
|
|
|
570
576
|
return path;
|
|
571
577
|
}
|
|
572
578
|
|
|
579
|
+
export async function writeRenderedWorkerManifest(image: string, target: DeploymentTarget) {
|
|
580
|
+
const rendered = await renderManifest(image, target, "worker");
|
|
581
|
+
const path = new URL("../../.cloudrun.worker.rendered.yaml", import.meta.url);
|
|
582
|
+
await Bun.write(path, rendered);
|
|
583
|
+
return path;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function renderContainerCommand(process: CloudRunProcess) {
|
|
587
|
+
if (process === "api") {
|
|
588
|
+
return "";
|
|
589
|
+
}
|
|
590
|
+
if (config.runtime === "bun") {
|
|
591
|
+
return [" command:", " - bun", " args:", " - run", " - ./src/worker.ts"].join("\n");
|
|
592
|
+
}
|
|
593
|
+
return [" command:", " - /app/worker"].join("\n");
|
|
594
|
+
}
|
|
595
|
+
|
|
573
596
|
export function serviceUrl(serviceName: string) {
|
|
574
597
|
return gcloud(
|
|
575
598
|
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"]
|