create-svc 0.1.10 → 0.1.12

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 (171) hide show
  1. package/README.md +51 -47
  2. package/index.ts +2 -2
  3. package/package.json +10 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +196 -33
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +232 -41
  14. package/src/scaffold.ts +81 -36
  15. package/src/service.test.ts +30 -0
  16. package/src/service.ts +65 -0
  17. package/src/vault.test.ts +61 -1
  18. package/src/vault.ts +77 -15
  19. package/templates/shared/.github/workflows/ci.yml +2 -1
  20. package/templates/shared/.github/workflows/deploy.yml +2 -0
  21. package/templates/shared/README.md +124 -47
  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 +14 -5
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  27. package/templates/shared/scripts/cloudrun/cli.ts +329 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  29. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  30. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  31. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  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 -44
  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 +402 -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/Makefile +14 -8
  53. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  54. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  55. package/templates/variants/bun-connectrpc/package.json +12 -5
  56. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  57. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  58. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  59. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  60. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  61. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  62. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  63. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  64. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  65. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  66. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  67. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  68. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  69. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  70. package/templates/variants/bun-hono/Makefile +14 -8
  71. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  72. package/templates/variants/bun-hono/package.json +12 -5
  73. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  74. package/templates/variants/bun-hono/src/auth.ts +181 -0
  75. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  76. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  77. package/templates/variants/bun-hono/src/index.ts +65 -180
  78. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  79. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  80. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  81. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  82. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  83. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  84. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  85. package/templates/variants/go-chi/Makefile +27 -11
  86. package/templates/variants/go-chi/atlas.hcl +8 -0
  87. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  88. package/templates/variants/go-chi/go.mod +1 -3
  89. package/templates/variants/go-chi/internal/app/service.go +202 -685
  90. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  91. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  92. package/templates/variants/go-chi/internal/config/config.go +27 -11
  93. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  94. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  95. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  96. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  97. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  98. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  99. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  100. package/templates/variants/go-chi/package.json +7 -1
  101. package/templates/variants/go-connectrpc/Makefile +26 -9
  102. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  103. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  104. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  105. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  106. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  107. package/templates/variants/go-connectrpc/go.mod +1 -1
  108. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  109. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  110. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  111. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  112. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  113. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  114. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  115. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  116. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  117. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  118. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  119. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  120. package/templates/variants/go-connectrpc/package.json +7 -1
  121. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  122. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  123. package/templates/root/.github/workflows/ci.yml +0 -26
  124. package/templates/root/.github/workflows/deploy.yml +0 -22
  125. package/templates/root/Dockerfile +0 -23
  126. package/templates/root/README.md +0 -69
  127. package/templates/root/buf.gen.yaml +0 -10
  128. package/templates/root/buf.yaml +0 -9
  129. package/templates/root/cmd/server/main.go +0 -44
  130. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  131. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  132. package/templates/root/go.mod +0 -10
  133. package/templates/root/internal/app/service.go +0 -152
  134. package/templates/root/internal/app/token_source.go +0 -50
  135. package/templates/root/internal/cloudflare/client.go +0 -160
  136. package/templates/root/internal/config/config.go +0 -55
  137. package/templates/root/internal/connectapi/handler.go +0 -79
  138. package/templates/root/internal/httpapi/routes.go +0 -93
  139. package/templates/root/internal/vault/client.go +0 -148
  140. package/templates/root/package.json +0 -12
  141. package/templates/root/protos/dns/v1/dns.proto +0 -58
  142. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  143. package/templates/root/scripts/cloudrun/config.ts +0 -50
  144. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  145. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  146. package/templates/root/service.yaml +0 -50
  147. package/templates/root/test/go.test.ts +0 -19
  148. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  149. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  150. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  151. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  152. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  153. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  154. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  155. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  156. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  157. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  158. package/templates/variants/bun-hono/src/storage.ts +0 -72
  159. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  160. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  161. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  162. package/templates/variants/go-chi/buf.yaml +0 -9
  163. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  164. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  165. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  166. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  167. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  168. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  169. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  170. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
  171. /package/bin/{create-svc.mjs → service.mjs} +0 -0
package/src/service.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { run as runScaffoldCli } from "./cli";
4
+
5
+ const SCAFFOLD_COMMANDS = new Set(["create", "new", "init"]);
6
+
7
+ export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
8
+ const serviceRoot = findGeneratedServiceRoot(cwd);
9
+ if (serviceRoot) {
10
+ delegateToGeneratedService(serviceRoot, argv);
11
+ return;
12
+ }
13
+
14
+ await runScaffoldCli(normalizeScaffoldArgs(argv));
15
+ }
16
+
17
+ export function normalizeScaffoldArgs(argv: string[]) {
18
+ const [command, ...rest] = argv;
19
+ if (command && SCAFFOLD_COMMANDS.has(command)) {
20
+ return rest;
21
+ }
22
+ if (command === "help") {
23
+ return ["--help", ...rest];
24
+ }
25
+ return argv;
26
+ }
27
+
28
+ export function findGeneratedServiceRoot(start: string): string | undefined {
29
+ let current = start;
30
+ while (true) {
31
+ if (isGeneratedServiceRoot(current)) {
32
+ return current;
33
+ }
34
+
35
+ const parent = dirname(current);
36
+ if (parent === current) {
37
+ return undefined;
38
+ }
39
+ current = parent;
40
+ }
41
+ }
42
+
43
+ function isGeneratedServiceRoot(path: string) {
44
+ return (
45
+ existsSync(join(path, "service.config.ts")) &&
46
+ (existsSync(join(path, "scripts", "cloudrun", "cli.ts")) || existsSync(join(path, "scripts", "workers", "cli.ts")))
47
+ );
48
+ }
49
+
50
+ function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
51
+ const cliPath = existsSync(join(serviceRoot, "scripts", "cloudrun", "cli.ts"))
52
+ ? "./scripts/cloudrun/cli.ts"
53
+ : "./scripts/workers/cli.ts";
54
+ const result = Bun.spawnSync(["bun", "run", cliPath, ...argv], {
55
+ cwd: serviceRoot,
56
+ env: process.env,
57
+ stdin: "inherit",
58
+ stdout: "inherit",
59
+ stderr: "inherit",
60
+ });
61
+
62
+ if (!result.success) {
63
+ process.exit(result.exitCode || 1);
64
+ }
65
+ }
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
 
@@ -97,3 +97,63 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
97
97
  })
98
98
  ).resolves.toBe("vault-token");
99
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
@@ -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() {
@@ -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
@@ -1,23 +1,24 @@
1
1
  # {{SERVICE_NAME}}
2
2
 
3
- Generated by `create-svc`.
3
+ Generated by `service create`.
4
4
 
5
5
  This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
6
6
 
7
7
  - one generated `service.yaml` manifest
8
8
  - a lightweight `{{EXAMPLE_LABEL}}` example surface
9
9
  - local Docker Compose Postgres for first-run development
10
- - a local `svc-cloudrun` CLI for bootstrap, deploy, and cleanup
11
- - GCP project bootstrap with billing and quota-project-aware `gcloud` calls
12
- - Neon-backed remote database provisioning during bootstrap and deploy
13
- - GCS-backed image attachments
14
- - typed HTTP webhook ingestion
10
+ - the `service` CLI for create, deploy, doctor, dashboards, and destroy
11
+ - GCP project create with billing and quota-project-aware `gcloud` calls
12
+ - Neon-backed remote database provisioning during create and deploy
13
+ - Better Auth client-credentials resource-server registration through `authctl`
14
+ - stage-aware waitlist data and trigger ingestion
15
+ - typed HTTP webhook ingestion where the selected template supports it
15
16
  - a production API origin at `https://{{API_HOSTNAME}}`
16
17
 
17
18
  The default happy path is standalone. Terraform is optional: advanced users can
18
19
  precreate shared foundations and point this package at them, but a generated app
19
20
  does not need Terraform state, Terraform plans, a control plane, or a platform
20
- console to bootstrap and deploy.
21
+ console to create and deploy.
21
22
 
22
23
  ## Commands
23
24
 
@@ -29,6 +30,8 @@ console to bootstrap and deploy.
29
30
  {{COMMAND_TEST}}
30
31
  {{COMMAND_BOOTSTRAP}}
31
32
  {{COMMAND_DEPLOY}}
33
+ {{COMMAND_AUTH_RESOURCE}}
34
+ {{COMMAND_AUTH_CLIENT}}
32
35
  {{COMMAND_DEPLOY_PERSONAL}}
33
36
  {{COMMAND_DEPLOY_DESTROY}}
34
37
  {{COMMAND_CLEANUP}}
@@ -42,7 +45,6 @@ The scaffold writes a ready-to-use `.env.local` and includes a local Postgres se
42
45
  First local run:
43
46
 
44
47
  ```bash
45
- docker compose up -d
46
48
  {{COMMAND_MIGRATE}}
47
49
  {{COMMAND_DEV}}
48
50
  ```
@@ -50,21 +52,21 @@ docker compose up -d
50
52
  Local runtime uses:
51
53
 
52
54
  - `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
53
- - `ATTACHMENT_BUCKET`
54
- - `ATTACHMENT_PUBLIC_BASE_URL`
55
+ - `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres
56
+ - `TEMPORAL_ENABLED=false` by default; set Temporal env vars locally only when you want to run against a real Temporal server
55
57
 
56
- The local attachment settings are generated so startup works out of the box. Attachment upload endpoints still rely on GCS credentials when you exercise signed-upload flows locally.
58
+ No cloud credentials are required for local HTTP development after Docker and Postgres are running.
57
59
 
58
60
  ## Remote provisioning
59
61
 
60
62
  The generated Cloud Run config lives in [scripts/cloudrun/config.ts](scripts/cloudrun/config.ts).
61
63
 
62
- Bootstrap, deploy, and cleanup use:
64
+ Create, deploy, and destroy use:
63
65
 
64
66
  - known-good CLIs first, especially `gcloud`
65
67
  - `gcloud`
66
68
  - `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
67
- - the package-local CLI via `npx --no-install svc-cloudrun ...`
69
+ - the repo-aware `service` CLI from this package
68
70
 
69
71
  Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
70
72
 
@@ -82,6 +84,12 @@ gcloud auth application-default login
82
84
 
83
85
  The generated backend scripts still use `gcloud` as the primary control plane even when ADC is present.
84
86
 
87
+ Go variants use Atlas for migrations:
88
+
89
+ ```bash
90
+ atlas version
91
+ ```
92
+
85
93
  For the Neon admin credential, prefer a normal Vault login flow:
86
94
 
87
95
  ```bash
@@ -96,36 +104,110 @@ The scaffold will use, in order:
96
104
 
97
105
  That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
98
106
 
107
+ For production auth registration, `authctl` also needs the auth service's
108
+ Cloudflare Access service token:
109
+
110
+ ```bash
111
+ export AUTH_INTERNAL_BASE_URL="$(vault kv get -mount=secret -field=AUTH_INTERNAL_BASE_URL prod/apps/auth/authctl/cloudflare-access)"
112
+ export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID prod/apps/auth/authctl/cloudflare-access)"
113
+ export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET prod/apps/auth/authctl/cloudflare-access)"
114
+ ```
115
+
116
+ Before first production create, verify the installed `authctl` exposes the
117
+ resource-server control-plane command:
118
+
119
+ ```bash
120
+ {{COMMAND_AUTH_RESOURCE}} --help
121
+ ```
122
+
123
+ If this fails with `authctl is installed but does not expose resource-server
124
+ commands`, make sure the generated package installed `@anmho/authctl@0.1.1` or
125
+ newer before running `{{COMMAND_BOOTSTRAP}}`.
126
+
99
127
  Optional remote-only Vault overrides for Neon admin key lookup:
100
128
 
101
129
  - `VAULT_SECRET_MOUNT` default `secret`
102
130
  - `VAULT_NEON_API_KEY_PATH` default `prod/providers/neon`
103
131
  - `VAULT_NEON_API_KEY_FIELD` default `api_key`
104
132
 
105
- The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to bootstrap and deploy.
133
+ The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to create and deploy.
134
+
135
+ ## Temporal
136
+
137
+ Cloud Run variants include an in-process Temporal worker in the service process.
138
+ Production Temporal is enabled when you set `TEMPORAL_ENABLED=true`, or when
139
+ `TEMPORAL_ADDRESS`, `TEMPORAL_API_KEY`, or `TEMPORAL_API_KEY_SECRET` is present
140
+ during deploy rendering.
141
+
142
+ For Temporal Cloud, provide:
106
143
 
107
- For attachments, the generated Cloud Run manifest injects:
144
+ ```bash
145
+ TEMPORAL_ENABLED=true
146
+ TEMPORAL_ADDRESS=<namespace>.<account>.tmprl.cloud:7233
147
+ TEMPORAL_NAMESPACE=<namespace>.<account>
148
+ TEMPORAL_API_KEY=<one-time local value for service create>
149
+ ```
150
+
151
+ `service create` writes `TEMPORAL_API_KEY` to Secret Manager as
152
+ `{{SERVICE_ID}}-temporal-api-key` and grants the runtime service account access.
153
+ Later deploys can set `TEMPORAL_API_KEY_SECRET={{SERVICE_ID}}-temporal-api-key`
154
+ without exposing the key locally.
155
+
156
+ ## Service auth
157
+
158
+ Generated services are resource servers for the Better Auth client-credentials
159
+ server at `https://auth.anmho.com`.
160
+
161
+ `service create` registers this service as an auth resource server through
162
+ `authctl` before the first production deploy. The generated resource server is:
163
+
164
+ - id: `{{SERVICE_ID}}`
165
+ - audience: `api://{{SERVICE_ID}}`
166
+ - default scopes: `{{SERVICE_ID}}:read`, `{{SERVICE_ID}}:write`
167
+
168
+ Production runtime has `AUTH_ENABLED=true` and verifies JWT bearer tokens from
169
+ the Better Auth JWKS endpoint on `/v1/*` and ConnectRPC service paths. Local
170
+ development defaults to `AUTH_ENABLED=false` in `.env.local`.
108
171
 
109
- - `ATTACHMENT_BUCKET`
110
- - `ATTACHMENT_PUBLIC_BASE_URL`
172
+ Use `service auth` for follow-up auth operations:
111
173
 
112
- Webhook signature hooks are provider-specific and optional in v1. The generic adapter will honor:
174
+ ```bash
175
+ {{COMMAND_BOOTSTRAP}} # includes resource-server registration
176
+ {{COMMAND_AUTH_RESOURCE}}
177
+ {{COMMAND_AUTH_CLIENT}} --resource-server <target-service> --scope <scope>
178
+ ```
179
+
180
+ `authctl clients create` prints a one-time client secret plus the recommended
181
+ Vault command. By convention, generated services store outgoing service-client
182
+ credentials under:
183
+
184
+ ```text
185
+ prod/apps/{{SERVICE_ID}}/server/oauth-clients/<resource_server_id>
186
+ ```
187
+
188
+ When requesting a client-credentials token for a generated service, include the
189
+ target resource server as `resource=api://<resource_server_id>`. The generated
190
+ runtime expects a JWT with that audience; omitting `resource` can return an
191
+ opaque access token that the service will reject.
192
+
193
+ Webhook signature hooks are provider-specific and optional in v1. Add provider
194
+ secrets only when you add a provider adapter. A generic adapter can honor:
113
195
 
114
196
  - `WEBHOOK_<PROVIDER>_SECRET`
115
197
 
116
- ## One-command production bootstrap
198
+ ## One-command production create
117
199
 
118
- The one-command production bootstrap path is designed for a fresh standalone service.
200
+ The one-command production create path is designed for a fresh standalone service.
119
201
 
120
- When generated through `create-svc` with `--bootstrap`, the intended flow is:
202
+ The intended one-command flow is:
121
203
 
122
204
  ```bash
123
- bun create svc {{SERVICE_NAME}} --bootstrap --yes
205
+ service create {{SERVICE_NAME}} --yes
124
206
  ```
125
207
 
126
- That command scaffolds this package, runs bootstrap, deploys the production
127
- Cloud Run service, and fails loudly with resumable instructions if a required
128
- cloud or provider credential is missing. The generated package can also be run
208
+ That command scaffolds this package, runs `service create`, deploys the production
209
+ Cloud Run service through `service deploy`, and fails loudly with resumable
210
+ instructions if a required cloud credential is missing. The generated package can also be run
129
211
  manually:
130
212
 
131
213
  ```bash
@@ -133,9 +215,9 @@ manually:
133
215
  {{COMMAND_DEPLOY}}
134
216
  ```
135
217
 
136
- Bootstrap reads provider credentials from environment variables or from Vault
137
- when `VAULT_ADDR` and a Vault token are available. Runtime secrets are delivered
138
- to Cloud Run through app-project Secret Manager.
218
+ Create reads Neon credentials from environment variables or from Vault when
219
+ `VAULT_ADDR` and a Vault token are available. Runtime database credentials are
220
+ delivered to Cloud Run through app-project Secret Manager.
139
221
 
140
222
  ## Production API
141
223
 
@@ -153,28 +235,23 @@ The generated microservice profile is a waitlist/launch service example. It is
153
235
  kept deliberately small so the integration plumbing is easy to remove or adapt:
154
236
 
155
237
  - public launch/waitlist submission
156
- - protected admin APIs
157
- - logo or social-image upload plumbing
158
- - provider webhook ingestion
159
- - Pro entitlement checks for paid capabilities
160
-
161
- The current backend plumbing includes:
238
+ - status lookup
239
+ - trigger ingestion for scheduled, webhook, and manual follow-up work
240
+ - provider webhook ingress where the selected template supports it
162
241
 
163
- - `users`
164
- - `conversations`
165
- - `conversation_participants`
166
- - `messages`
167
- - `attachments`
168
- - `webhook_events`
242
+ The current Hono backend plumbing includes:
169
243
 
170
- HTTP variants expose REST endpoints for chat CRUD, attachment upload/finalize, and `/webhooks/:provider`.
244
+ - `waitlist_entries`
245
+ - `waitlist_triggers`
171
246
 
172
- ConnectRPC variants expose typed unary chat and attachment RPCs, while keeping webhook ingress on plain HTTP.
247
+ Hono variants expose:
173
248
 
174
- Transcript reads are newest-first and cursor-paginated:
249
+ - `POST /v1/waitlist`
250
+ - `GET /v1/waitlist?email=...`
251
+ - `GET /v1/waitlist/{entryId}`
252
+ - `POST /v1/triggers/waitlist`
253
+ - `POST /webhooks/:provider`
175
254
 
176
- - REST: `GET /v1/conversations/{conversationId}/messages?cursor=...&limit=...`
177
- - ConnectRPC: `ListMessages(conversation_id, cursor, limit)`
178
- - default page size `50`, max page size `100`
179
- - message payloads include lightweight attachment metadata only
255
+ ConnectRPC variants expose typed unary waitlist RPCs and will usually be the
256
+ first place to adapt domain-specific contracts after scaffold.
180
257
  {{LOCAL_INTROSPECTION_NOTE}}
@@ -0,0 +1,54 @@
1
+ apiVersion: 1
2
+ groups:
3
+ - orgId: 1
4
+ name: "{{SERVICE_NAME}} baseline"
5
+ folder: "create-service"
6
+ interval: 1m
7
+ rules:
8
+ - uid: "{{SERVICE_ID}}-high-error-rate"
9
+ title: "{{SERVICE_NAME}} high error rate"
10
+ condition: C
11
+ data:
12
+ - refId: A
13
+ relativeTimeRange:
14
+ from: 600
15
+ to: 0
16
+ datasourceUid: "${datasource}"
17
+ model:
18
+ expr: "sum(rate(http_requests_total{service=\"{{SERVICE_NAME}}\",status=~\"5..\"}[5m]))"
19
+ intervalMs: 1000
20
+ maxDataPoints: 43200
21
+ refId: A
22
+ - refId: C
23
+ relativeTimeRange:
24
+ from: 600
25
+ to: 0
26
+ datasourceUid: __expr__
27
+ model:
28
+ conditions:
29
+ - evaluator:
30
+ params: [0.05]
31
+ type: gt
32
+ operator:
33
+ type: and
34
+ query:
35
+ params: [A]
36
+ reducer:
37
+ type: last
38
+ type: query
39
+ datasource:
40
+ type: __expr__
41
+ uid: __expr__
42
+ expression: A
43
+ intervalMs: 1000
44
+ maxDataPoints: 43200
45
+ refId: C
46
+ type: threshold
47
+ noDataState: NoData
48
+ execErrState: Error
49
+ for: 5m
50
+ annotations:
51
+ summary: "{{SERVICE_NAME}} 5xx rate is elevated"
52
+ labels:
53
+ service_id: "{{SERVICE_ID}}"
54
+ target: "{{TARGET}}"
@@ -0,0 +1,63 @@
1
+ {
2
+ "uid": "{{SERVICE_ID}}-waitlist",
3
+ "title": "{{SERVICE_NAME}} Waitlist Service",
4
+ "tags": ["create-service", "{{TARGET}}", "{{RUNTIME}}", "{{FRAMEWORK}}"],
5
+ "timezone": "browser",
6
+ "schemaVersion": 39,
7
+ "version": 1,
8
+ "refresh": "30s",
9
+ "panels": [
10
+ {
11
+ "id": 1,
12
+ "type": "timeseries",
13
+ "title": "Request rate",
14
+ "datasource": { "type": "prometheus", "uid": "${datasource}" },
15
+ "targets": [
16
+ {
17
+ "refId": "A",
18
+ "expr": "sum(rate(http_requests_total{service=\"{{SERVICE_NAME}}\"}[5m]))"
19
+ }
20
+ ],
21
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }
22
+ },
23
+ {
24
+ "id": 2,
25
+ "type": "timeseries",
26
+ "title": "Error rate",
27
+ "datasource": { "type": "prometheus", "uid": "${datasource}" },
28
+ "targets": [
29
+ {
30
+ "refId": "A",
31
+ "expr": "sum(rate(http_requests_total{service=\"{{SERVICE_NAME}}\",status=~\"5..\"}[5m]))"
32
+ }
33
+ ],
34
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }
35
+ },
36
+ {
37
+ "id": 3,
38
+ "type": "timeseries",
39
+ "title": "p95 latency",
40
+ "datasource": { "type": "prometheus", "uid": "${datasource}" },
41
+ "targets": [
42
+ {
43
+ "refId": "A",
44
+ "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{service=\"{{SERVICE_NAME}}\"}[5m])) by (le))"
45
+ }
46
+ ],
47
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }
48
+ },
49
+ {
50
+ "id": 4,
51
+ "type": "stat",
52
+ "title": "Queued triggers",
53
+ "datasource": { "type": "prometheus", "uid": "${datasource}" },
54
+ "targets": [
55
+ {
56
+ "refId": "A",
57
+ "expr": "sum(waitlist_triggers_queued{service=\"{{SERVICE_NAME}}\"})"
58
+ }
59
+ ],
60
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }
61
+ }
62
+ ]
63
+ }