create-svc 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -3,11 +3,31 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { config } from "./config";
5
5
 
6
+ type NeonProject = {
7
+ id: string;
8
+ name: string;
9
+ };
10
+
6
11
  type NeonBranch = {
7
12
  id: string;
8
13
  name: string;
9
14
  };
10
15
 
16
+ type NeonDatabase = {
17
+ name: string;
18
+ ownerName: string;
19
+ };
20
+
21
+ type ResolvedNeonConfig = {
22
+ projectId: string;
23
+ baseBranchId: string;
24
+ baseBranchName: string;
25
+ databaseName: string;
26
+ roleName: string;
27
+ previewBranchPrefix: string;
28
+ personalBranchPrefix: string;
29
+ };
30
+
11
31
  async function resolveNeonApiKey() {
12
32
  const direct = process.env.NEON_API_KEY?.trim();
13
33
  if (direct) {
@@ -17,8 +37,8 @@ async function resolveNeonApiKey() {
17
37
  const addr = process.env.VAULT_ADDR?.trim() ?? "";
18
38
  const token = await resolveVaultToken();
19
39
  const mount = process.env.VAULT_SECRET_MOUNT?.trim() ?? "secret";
20
- const path = process.env.VAULT_NEON_API_KEY_PATH?.trim() ?? "provider/neon-api-key";
21
- const field = process.env.VAULT_NEON_API_KEY_FIELD?.trim() ?? "value";
40
+ const path = process.env.VAULT_NEON_API_KEY_PATH?.trim() ?? "prod/providers/neon";
41
+ const field = process.env.VAULT_NEON_API_KEY_FIELD?.trim() ?? "api_key";
22
42
 
23
43
  if (!addr || !token) {
24
44
  throw new Error("NEON_API_KEY is required for Neon provisioning, or set VAULT_ADDR with VAULT_TOKEN, VAULT_TOKEN_FILE, or ~/.vault-token");
@@ -57,7 +77,7 @@ async function resolveVaultToken() {
57
77
  return direct;
58
78
  }
59
79
 
60
- const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(homedir(), ".vault-token");
80
+ const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(process.env.HOME?.trim() || homedir(), ".vault-token");
61
81
 
62
82
  try {
63
83
  return (await Bun.file(tokenFile).text()).trim();
@@ -71,15 +91,80 @@ async function neonClient() {
71
91
  return createApiClient({ apiKey });
72
92
  }
73
93
 
94
+ export async function listProjects() {
95
+ const payload = await (await neonClient()).listProjects({ limit: 100 });
96
+ const projects = ((payload.data as { projects?: Array<{ id?: string; name?: string }> } | undefined)?.projects ?? []);
97
+ return projects
98
+ .map((project: { id?: string; name?: string }) => ({
99
+ id: project.id ?? "",
100
+ name: project.name ?? project.id ?? "",
101
+ }))
102
+ .filter((project: NeonProject): project is NeonProject => Boolean(project.id))
103
+ .sort((left: NeonProject, right: NeonProject) => left.name.localeCompare(right.name));
104
+ }
105
+
74
106
  export async function listBranches(projectId: string) {
75
107
  const payload = await (await neonClient()).listProjectBranches({ projectId });
76
- return (payload.branches ?? [])
77
- .map((branch) => ({
108
+ const branches = ((payload.data as { branches?: Array<{ id?: string; name?: string }> } | undefined)?.branches ?? []);
109
+ return branches
110
+ .map((branch: { id?: string; name?: string }) => ({
78
111
  id: branch.id ?? "",
79
112
  name: branch.name ?? branch.id ?? "",
80
113
  }))
81
- .filter((branch): branch is NeonBranch => Boolean(branch.id))
82
- .sort((left, right) => left.name.localeCompare(right.name));
114
+ .filter((branch: NeonBranch): branch is NeonBranch => Boolean(branch.id))
115
+ .sort((left: NeonBranch, right: NeonBranch) => left.name.localeCompare(right.name));
116
+ }
117
+
118
+ export async function listDatabases(projectId: string, branchId: string) {
119
+ const payload = await (await neonClient()).listProjectBranchDatabases(projectId, branchId);
120
+ const databases = ((payload.data as { databases?: Array<{ name?: string; owner_name?: string }> } | undefined)?.databases ?? []);
121
+ return databases
122
+ .map((database: { name?: string; owner_name?: string }) => ({
123
+ name: database.name ?? "",
124
+ ownerName: database.owner_name ?? "",
125
+ }))
126
+ .filter((database: NeonDatabase): database is NeonDatabase => Boolean(database.name))
127
+ .sort((left: NeonDatabase, right: NeonDatabase) => left.name.localeCompare(right.name));
128
+ }
129
+
130
+ export async function resolveNeonConfig(): Promise<ResolvedNeonConfig> {
131
+ const configuredProjectId = config.neon.projectId.trim();
132
+ const configuredBaseBranchId = config.neon.baseBranchId.trim();
133
+ const configuredBaseBranchName = config.neon.baseBranchName.trim() || "main";
134
+
135
+ if (configuredProjectId && configuredBaseBranchId) {
136
+ return {
137
+ projectId: configuredProjectId,
138
+ baseBranchId: configuredBaseBranchId,
139
+ baseBranchName: configuredBaseBranchName,
140
+ databaseName: config.neon.databaseName,
141
+ roleName: config.neon.roleName,
142
+ previewBranchPrefix: config.neon.previewBranchPrefix,
143
+ personalBranchPrefix: config.neon.personalBranchPrefix,
144
+ };
145
+ }
146
+
147
+ const projects = await listProjects();
148
+ const project = projects[0];
149
+ if (!project) {
150
+ throw new Error(`No Neon projects are available for ${config.serviceName}`);
151
+ }
152
+
153
+ const branches = await listBranches(project.id);
154
+ const branch = branches.find((candidate) => candidate.name === configuredBaseBranchName) ?? branches[0];
155
+ if (!branch) {
156
+ throw new Error(`No Neon branches are available in project ${project.id}`);
157
+ }
158
+
159
+ return {
160
+ projectId: project.id,
161
+ baseBranchId: branch.id,
162
+ baseBranchName: branch.name,
163
+ databaseName: config.neon.databaseName,
164
+ roleName: config.neon.roleName,
165
+ previewBranchPrefix: config.neon.previewBranchPrefix,
166
+ personalBranchPrefix: config.neon.personalBranchPrefix,
167
+ };
83
168
  }
84
169
 
85
170
  export async function ensureDatabase(projectId: string, branchId: string, databaseName: string) {
@@ -98,11 +183,13 @@ export async function ensureDatabase(projectId: string, branchId: string, databa
98
183
  await client.createProjectBranchDatabase(projectId, branchId, {
99
184
  database: {
100
185
  name: databaseName,
186
+ owner_name: config.neon.roleName,
101
187
  },
102
188
  });
103
189
  }
104
190
 
105
191
  export async function deleteDatabase(projectId: string, branchId: string, databaseName: string) {
192
+ await assertDatabaseOwned(projectId, branchId, databaseName);
106
193
  try {
107
194
  await (await neonClient()).deleteProjectBranchDatabase(projectId, branchId, databaseName);
108
195
  } catch (error) {
@@ -127,12 +214,12 @@ export async function ensureBranch(projectId: string, branchName: string, parent
127
214
  },
128
215
  endpoints: [
129
216
  {
130
- type: "read_write",
217
+ type: "read_write" as never,
131
218
  },
132
219
  ],
133
220
  });
134
221
 
135
- const branch = payload.branch;
222
+ const branch = (payload.data as { branch?: { id?: string; name?: string } } | undefined)?.branch;
136
223
  if (!branch?.id) {
137
224
  throw new Error(`Neon did not return a branch for ${branchName}`);
138
225
  }
@@ -144,6 +231,11 @@ export async function ensureBranch(projectId: string, branchName: string, parent
144
231
  }
145
232
 
146
233
  export async function deleteBranch(projectId: string, branchId: string) {
234
+ const branch = (await listBranches(projectId)).find((candidate) => candidate.id === branchId);
235
+ if (!branch) {
236
+ return;
237
+ }
238
+ assertDisposableBranchName(branch.name);
147
239
  try {
148
240
  await (await neonClient()).deleteProjectBranch(projectId, branchId);
149
241
  } catch (error) {
@@ -155,15 +247,37 @@ export async function deleteBranch(projectId: string, branchId: string) {
155
247
  }
156
248
  }
157
249
 
250
+ async function assertDatabaseOwned(projectId: string, branchId: string, databaseName: string) {
251
+ if (databaseName !== config.neon.databaseName) {
252
+ throw new Error(`Refusing to delete Neon database ${databaseName}; expected ${config.neon.databaseName}`);
253
+ }
254
+
255
+ const database = (await listDatabases(projectId, branchId)).find((candidate) => candidate.name === databaseName);
256
+ if (!database) {
257
+ return;
258
+ }
259
+
260
+ if (database.ownerName && database.ownerName !== config.neon.roleName) {
261
+ throw new Error(`Refusing to delete Neon database ${databaseName}; owner is ${database.ownerName}, expected ${config.neon.roleName}`);
262
+ }
263
+ }
264
+
265
+ function assertDisposableBranchName(branchName: string) {
266
+ if (branchName.startsWith(`${config.neon.previewBranchPrefix}-`) || branchName.startsWith(`${config.neon.personalBranchPrefix}-`)) {
267
+ return;
268
+ }
269
+ throw new Error(`Refusing to delete Neon branch ${branchName}; it is not owned by ${config.serviceName}`);
270
+ }
271
+
158
272
  export async function getConnectionUri(projectId: string, branchId: string, databaseName: string, roleName: string) {
159
273
  const payload = await (await neonClient()).getConnectionUri({
160
274
  projectId,
161
- branchId,
162
- databaseName,
163
- roleName,
275
+ branch_id: branchId,
276
+ database_name: databaseName,
277
+ role_name: roleName,
164
278
  });
165
279
 
166
- const uri = payload.uri;
280
+ const uri = (payload.data as { uri?: string } | undefined)?.uri;
167
281
  if (!uri) {
168
282
  throw new Error(`Neon did not return a connection URI for ${databaseName} in ${config.serviceName}`);
169
283
  }
@@ -0,0 +1,22 @@
1
+ import { readLocalEnv } from "./local-env";
2
+ import { ensureLocalPostgres } from "./local-docker";
3
+
4
+ const command = Bun.argv.slice(2);
5
+
6
+ if (command.length === 0) {
7
+ throw new Error("Usage: bun run ./scripts/dev.ts <command...>");
8
+ }
9
+
10
+ await ensureLocalPostgres();
11
+
12
+ const child = Bun.spawn(command, {
13
+ stdin: "inherit",
14
+ stdout: "inherit",
15
+ stderr: "inherit",
16
+ env: {
17
+ ...Bun.env,
18
+ ...(await readLocalEnv()),
19
+ },
20
+ });
21
+
22
+ process.exit(await child.exited);
@@ -0,0 +1,3 @@
1
+ import { ensureLocalPostgres } from "./local-docker";
2
+
3
+ await ensureLocalPostgres();
@@ -0,0 +1,63 @@
1
+ export async function ensureLocalPostgres() {
2
+ await ensureDockerRunning();
3
+ await run(["docker", "compose", "up", "-d"], { label: "start local postgres" });
4
+ }
5
+
6
+ async function ensureDockerRunning() {
7
+ if (await dockerInfo()) {
8
+ return;
9
+ }
10
+
11
+ await openDocker();
12
+ const deadline = Date.now() + 120_000;
13
+
14
+ while (Date.now() < deadline) {
15
+ if (await dockerInfo()) {
16
+ return;
17
+ }
18
+ await Bun.sleep(2_000);
19
+ }
20
+
21
+ throw new Error("Docker did not become ready within 120 seconds. Open Docker Desktop and retry.");
22
+ }
23
+
24
+ async function dockerInfo() {
25
+ const result = await Bun.spawn(["docker", "info"], {
26
+ stdout: "ignore",
27
+ stderr: "ignore",
28
+ }).exited;
29
+ return result === 0;
30
+ }
31
+
32
+ async function openDocker() {
33
+ if (process.platform === "darwin") {
34
+ await run(["open", "-a", "Docker"], { label: "open Docker Desktop" });
35
+ return;
36
+ }
37
+
38
+ if (process.platform === "win32") {
39
+ await run(["powershell.exe", "-NoProfile", "-Command", "Start-Process 'Docker Desktop'"], {
40
+ label: "open Docker Desktop",
41
+ optional: true,
42
+ });
43
+ return;
44
+ }
45
+
46
+ await run(["systemctl", "--user", "start", "docker-desktop"], {
47
+ label: "open Docker Desktop",
48
+ optional: true,
49
+ });
50
+ }
51
+
52
+ async function run(command: string[], options: { label: string; optional?: boolean }) {
53
+ const result = await Bun.spawn(command, {
54
+ stdin: "ignore",
55
+ stdout: "inherit",
56
+ stderr: "inherit",
57
+ env: Bun.env,
58
+ }).exited;
59
+
60
+ if (result !== 0 && !options.optional) {
61
+ throw new Error(`${options.label} failed with exit code ${result}`);
62
+ }
63
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ export async function readLocalEnv() {
5
+ if (!existsSync(".env.local")) {
6
+ return {};
7
+ }
8
+
9
+ const values: Record<string, string> = {};
10
+ const text = await readFile(".env.local", "utf8");
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#")) {
14
+ continue;
15
+ }
16
+
17
+ const separator = trimmed.indexOf("=");
18
+ if (separator === -1) {
19
+ continue;
20
+ }
21
+
22
+ const key = trimmed.slice(0, separator).trim();
23
+ const rawValue = trimmed.slice(separator + 1).trim();
24
+ values[key] = rawValue.replace(/^['"]|['"]$/g, "");
25
+ }
26
+ return values;
27
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { SQL } from "bun";
4
+
5
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
6
+ if (!databaseUrl) {
7
+ throw new Error("DATABASE_URL is required");
8
+ }
9
+
10
+ const stage = normalizeStage(Bun.env.SERVICE_STAGE || Bun.env.APP_ENV || Bun.env.NODE_ENV || "local");
11
+ if (stage === "prod" && Bun.env.SEED_PROD !== "true") {
12
+ console.log("Skipping production seed data. Set SEED_PROD=true to apply production seeds.");
13
+ process.exit(0);
14
+ }
15
+
16
+ const sql = new SQL(databaseUrl);
17
+
18
+ try {
19
+ const entries = seedEntries(stage);
20
+ for (const entry of entries) {
21
+ await sql`
22
+ insert into waitlist_entries (id, email, name, company, source, status)
23
+ values (${entry.id}, ${entry.email}, ${entry.name}, ${entry.company}, ${entry.source}, ${entry.status})
24
+ on conflict (email) do update set
25
+ name = excluded.name,
26
+ company = excluded.company,
27
+ source = excluded.source,
28
+ updated_at = now()
29
+ `;
30
+
31
+ await sql`
32
+ insert into waitlist_triggers (id, type, entry_id, status, payload_json)
33
+ values (${`${entry.id}-trigger`}, ${"seed"}, ${entry.id}, ${"queued"}, ${JSON.stringify({ stage, email: entry.email })})
34
+ on conflict (id) do nothing
35
+ `;
36
+ }
37
+
38
+ console.log(`Seeded ${entries.length} waitlist entr${entries.length === 1 ? "y" : "ies"} for ${stage}.`);
39
+ } finally {
40
+ await sql.close();
41
+ }
42
+
43
+ function normalizeStage(value: string) {
44
+ const normalized = value.trim().toLowerCase();
45
+ if (normalized === "production" || normalized === "main") {
46
+ return "prod";
47
+ }
48
+ if (normalized === "development") {
49
+ return "local";
50
+ }
51
+ return normalized || "local";
52
+ }
53
+
54
+ function seedEntries(stage: string) {
55
+ return [
56
+ {
57
+ id: `seed-${stage}-founder`,
58
+ email: `founder+${stage}@example.com`,
59
+ name: "Founder Example",
60
+ company: "Example Co",
61
+ source: `seed:${stage}`,
62
+ status: "joined",
63
+ },
64
+ {
65
+ id: `seed-${stage}-operator`,
66
+ email: `operator+${stage}@example.com`,
67
+ name: "Operator Example",
68
+ company: "Example Co",
69
+ source: `seed:${stage}`,
70
+ status: "joined",
71
+ },
72
+ ];
73
+ }
@@ -0,0 +1,32 @@
1
+ import { SQL } from "bun";
2
+ import { readLocalEnv } from "./local-env";
3
+
4
+ const env = {
5
+ ...Bun.env,
6
+ ...(await readLocalEnv()),
7
+ };
8
+ const databaseUrl = env.DATABASE_URL?.trim();
9
+
10
+ if (!databaseUrl) {
11
+ throw new Error("DATABASE_URL is required");
12
+ }
13
+
14
+ const client = new SQL(databaseUrl);
15
+ const deadline = Date.now() + 45_000;
16
+ let lastError: unknown;
17
+
18
+ while (Date.now() < deadline) {
19
+ try {
20
+ await client.unsafe("select 1");
21
+ process.exit(0);
22
+ } catch (error) {
23
+ lastError = error;
24
+ await Bun.sleep(1_000);
25
+ }
26
+ }
27
+
28
+ throw new Error(`Timed out waiting for Postgres: ${formatError(lastError)}`);
29
+
30
+ function formatError(error: unknown) {
31
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
32
+ }
@@ -0,0 +1,59 @@
1
+ export default {
2
+ service_id: "{{SERVICE_ID}}",
3
+ target: "{{TARGET}}",
4
+ runtime: "{{RUNTIME}}",
5
+ framework: "{{FRAMEWORK}}",
6
+ stage_default: "prod",
7
+ dns: {
8
+ hostname: "{{API_HOSTNAME}}",
9
+ base_domain: "{{API_BASE_DOMAIN}}",
10
+ },
11
+ ownership: {
12
+ managed_by: "create-service",
13
+ service_id: "{{SERVICE_ID}}",
14
+ },
15
+ auth: {
16
+ issuer: "{{AUTH_ISSUER}}",
17
+ token_endpoint: "https://auth.anmho.com/api/auth/oauth2/token",
18
+ jwks_url: "https://auth.anmho.com/api/auth/jwks",
19
+ resource_server: {
20
+ id: "{{SERVICE_ID}}",
21
+ audience: "api://{{SERVICE_ID}}",
22
+ default_scopes: ["{{SERVICE_ID}}:read", "{{SERVICE_ID}}:write"],
23
+ },
24
+ client: {
25
+ app_id: "{{SERVICE_ID}}",
26
+ identity: "server",
27
+ vault_path_prefix: "prod/apps/{{SERVICE_ID}}/server/oauth-clients",
28
+ },
29
+ },
30
+ temporal: {
31
+ enabled: false,
32
+ address: "localhost:7233",
33
+ namespace: "default",
34
+ task_queue: "{{SERVICE_ID}}",
35
+ api_key_secret_name: "{{SERVICE_ID}}-temporal-api-key",
36
+ },
37
+ providers: {
38
+ vault: {
39
+ mount: "secret",
40
+ neon_path: "prod/providers/neon",
41
+ grafana_path: "prod/providers/grafana",
42
+ clerk_m2m_path: "prod/providers/clerk-m2m",
43
+ temporal_path: "prod/providers/temporal",
44
+ },
45
+ },
46
+ buf: {
47
+ module: "buf.build/anmho/{{SERVICE_ID}}",
48
+ },
49
+ cloudrun: {
50
+ project_id: "{{PROJECT_ID}}",
51
+ region: "{{REGION}}",
52
+ service_account: "{{RUNTIME_SERVICE_ACCOUNT}}",
53
+ },
54
+ workers: {
55
+ script_name: "{{SERVICE_ID}}",
56
+ hyperdrive_binding: "HYPERDRIVE",
57
+ cron: "*/15 * * * *",
58
+ },
59
+ } as const;
@@ -2,10 +2,17 @@ apiVersion: serving.knative.dev/v1
2
2
  kind: Service
3
3
  metadata:
4
4
  name: ${SERVICE_NAME}
5
+ labels:
6
+ managed_by: create-service
7
+ service_id: ${SERVICE_ID}
5
8
  annotations:
6
9
  run.googleapis.com/ingress: all
7
10
  spec:
8
11
  template:
12
+ metadata:
13
+ labels:
14
+ managed_by: create-service
15
+ service_id: ${SERVICE_ID}
9
16
  spec:
10
17
  serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
11
18
  containers:
@@ -17,12 +24,28 @@ spec:
17
24
  value: ${SERVICE_RUNTIME}
18
25
  - name: APP_FRAMEWORK
19
26
  value: ${SERVICE_FRAMEWORK}
27
+ - name: TEMPORAL_ENABLED
28
+ value: "${TEMPORAL_ENABLED}"
29
+ - name: TEMPORAL_ADDRESS
30
+ value: "${TEMPORAL_ADDRESS}"
31
+ - name: TEMPORAL_NAMESPACE
32
+ value: "${TEMPORAL_NAMESPACE}"
33
+ - name: TEMPORAL_TASK_QUEUE
34
+ value: "${TEMPORAL_TASK_QUEUE}"
35
+ ${TEMPORAL_API_KEY_ENV}
20
36
  - name: DATABASE_URL
21
37
  valueFrom:
22
38
  secretKeyRef:
23
39
  name: ${DATABASE_URL_SECRET}
24
40
  key: latest
41
+ - name: AUTH_ENABLED
42
+ value: "true"
43
+ - name: AUTH_ISSUER
44
+ value: ${AUTH_ISSUER}
45
+ - name: AUTH_AUDIENCE
46
+ value: ${AUTH_AUDIENCE}
47
+ - name: AUTH_JWKS_URL
48
+ value: ${AUTH_JWKS_URL}
25
49
  traffic:
26
50
  - latestRevision: true
27
51
  percent: 100
28
-
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: oven-sh/setup-bun@v2
15
+ - run: bun install
16
+ - run: bun lint
17
+ - run: bun test
18
+ - if: ${{ vars.GCX_ENABLED == 'true' && github.ref == 'refs/heads/main' }}
19
+ run: bun run dashboards
@@ -0,0 +1,19 @@
1
+ name: Deploy
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: oven-sh/setup-bun@v2
14
+ - run: bun install
15
+ - run: bun run deploy
16
+ env:
17
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
18
+ - if: ${{ vars.GCX_ENABLED == 'true' }}
19
+ run: bun run dashboards
@@ -0,0 +1,33 @@
1
+ .PHONY: dev migrate gen lint test create deploy dashboards auth destroy
2
+
3
+ SERVICE := npx --no-install service
4
+
5
+ dev:
6
+ bun run dev
7
+
8
+ migrate:
9
+ $(SERVICE) migrate
10
+
11
+ gen:
12
+ @echo "no generated code for workers"
13
+
14
+ lint:
15
+ bunx tsc --noEmit
16
+
17
+ test:
18
+ bun test
19
+
20
+ create:
21
+ $(SERVICE) create
22
+
23
+ deploy:
24
+ $(SERVICE) deploy $(ARGS)
25
+
26
+ dashboards:
27
+ $(SERVICE) dashboards
28
+
29
+ auth:
30
+ $(SERVICE) auth $(ARGS)
31
+
32
+ destroy:
33
+ $(SERVICE) destroy $(ARGS)