create-svc 0.1.10 → 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.
- package/README.md +46 -43
- package/bin/create-service.mjs +2 -0
- package/package.json +12 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +195 -30
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +231 -40
- package/src/scaffold.ts +84 -29
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -44
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
|
@@ -4,6 +4,7 @@ import { config } from "./config";
|
|
|
4
4
|
type CommandOptions = {
|
|
5
5
|
allowFailure?: boolean;
|
|
6
6
|
input?: string;
|
|
7
|
+
env?: Record<string, string | undefined>;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
type DeployArgs = {
|
|
@@ -15,6 +16,7 @@ type DeployArgs = {
|
|
|
15
16
|
|
|
16
17
|
type CleanupArgs = {
|
|
17
18
|
destroyProject: boolean;
|
|
19
|
+
force: boolean;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type DeploymentTarget = {
|
|
@@ -24,6 +26,13 @@ export type DeploymentTarget = {
|
|
|
24
26
|
databaseSecretName: string;
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
type GcpResourceWithLabels = {
|
|
30
|
+
metadata?: {
|
|
31
|
+
labels?: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
labels?: Record<string, string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
27
36
|
type CommandResult = {
|
|
28
37
|
success: boolean;
|
|
29
38
|
stdout: string;
|
|
@@ -67,7 +76,7 @@ export function requireGcloudAuth() {
|
|
|
67
76
|
throw new Error(
|
|
68
77
|
[
|
|
69
78
|
"gcloud is installed but no active Google Cloud account is available.",
|
|
70
|
-
"Run `gcloud auth login` on this machine before using
|
|
79
|
+
"Run `gcloud auth login` on this machine before using service create, deploy, doctor, dns, or destroy.",
|
|
71
80
|
"If you also rely on Application Default Credentials for other tooling, run `gcloud auth application-default login` as well.",
|
|
72
81
|
].join(" ")
|
|
73
82
|
);
|
|
@@ -77,7 +86,7 @@ export function requireGcloudAuth() {
|
|
|
77
86
|
export function run(command: string, args: string[], options: CommandOptions = {}): CommandResult {
|
|
78
87
|
const result = Bun.spawnSync([command, ...args], {
|
|
79
88
|
cwd: process.cwd(),
|
|
80
|
-
env: process.env,
|
|
89
|
+
env: { ...process.env, ...options.env },
|
|
81
90
|
stdin: options.input === undefined ? undefined : encoder.encode(options.input),
|
|
82
91
|
stdout: "pipe",
|
|
83
92
|
stderr: "pipe",
|
|
@@ -119,7 +128,7 @@ export async function runStep<T>(label: string, task: () => Promise<T> | T) {
|
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
|
|
122
|
-
export async function runMain(name: string, task: () => Promise<string | void>) {
|
|
131
|
+
export async function runMain(name: string, task: () => Promise<string | void> | string | void) {
|
|
123
132
|
intro(name);
|
|
124
133
|
|
|
125
134
|
try {
|
|
@@ -152,6 +161,9 @@ export function ensureProject() {
|
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
export function attachBilling() {
|
|
164
|
+
if (config.project.mode === "use_existing") {
|
|
165
|
+
return "Using existing project billing";
|
|
166
|
+
}
|
|
155
167
|
gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
|
|
156
168
|
}
|
|
157
169
|
|
|
@@ -192,7 +204,17 @@ export function ensureSecret(secretName: string) {
|
|
|
192
204
|
return;
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
gcloud([
|
|
207
|
+
gcloud([
|
|
208
|
+
"secrets",
|
|
209
|
+
"create",
|
|
210
|
+
secretName,
|
|
211
|
+
"--project",
|
|
212
|
+
config.project.id,
|
|
213
|
+
"--replication-policy",
|
|
214
|
+
"automatic",
|
|
215
|
+
"--labels",
|
|
216
|
+
ownershipLabelsArg(),
|
|
217
|
+
]);
|
|
196
218
|
}
|
|
197
219
|
|
|
198
220
|
export function addSecretVersion(secretName: string, value: string) {
|
|
@@ -200,6 +222,10 @@ export function addSecretVersion(secretName: string, value: string) {
|
|
|
200
222
|
gcloud(["secrets", "versions", "add", secretName, "--project", config.project.id, "--data-file=-"], { input: value });
|
|
201
223
|
}
|
|
202
224
|
|
|
225
|
+
export function accessSecretVersion(secretName: string) {
|
|
226
|
+
return gcloud(["secrets", "versions", "access", "latest", "--secret", secretName, "--project", config.project.id]).stdout;
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
export function ensureSecretAccessor(secretName: string, member: string) {
|
|
204
230
|
gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
|
|
205
231
|
}
|
|
@@ -216,6 +242,11 @@ export function deleteSecret(secretName: string) {
|
|
|
216
242
|
gcloud(["secrets", "delete", secretName, "--project", config.project.id, "--quiet"], { allowFailure: true });
|
|
217
243
|
}
|
|
218
244
|
|
|
245
|
+
export function describeSecret(secretName: string): GcpResourceWithLabels | undefined {
|
|
246
|
+
const result = gcloud(["secrets", "describe", secretName, "--project", config.project.id, "--format=json"], { allowFailure: true });
|
|
247
|
+
return parseOptionalJson(result.stdout, result.success);
|
|
248
|
+
}
|
|
249
|
+
|
|
219
250
|
export function ensureArtifactRepository() {
|
|
220
251
|
if (
|
|
221
252
|
gcloud(
|
|
@@ -240,24 +271,6 @@ export function ensureArtifactRepository() {
|
|
|
240
271
|
]);
|
|
241
272
|
}
|
|
242
273
|
|
|
243
|
-
export function ensureStorageBucket() {
|
|
244
|
-
if (gcloud(["storage", "buckets", "describe", `gs://${config.storage.attachmentBucket}`, "--project", config.project.id], { allowFailure: true }).success) {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
gcloud([
|
|
249
|
-
"storage",
|
|
250
|
-
"buckets",
|
|
251
|
-
"create",
|
|
252
|
-
`gs://${config.storage.attachmentBucket}`,
|
|
253
|
-
"--project",
|
|
254
|
-
config.project.id,
|
|
255
|
-
"--location",
|
|
256
|
-
config.region,
|
|
257
|
-
"--uniform-bucket-level-access",
|
|
258
|
-
]);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
274
|
export function projectNumber() {
|
|
262
275
|
return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
|
|
263
276
|
}
|
|
@@ -330,6 +343,7 @@ export function parseDeployArgs(argv: string[]): DeployArgs {
|
|
|
330
343
|
export function parseCleanupArgs(argv: string[]): CleanupArgs {
|
|
331
344
|
const parsed: CleanupArgs = {
|
|
332
345
|
destroyProject: false,
|
|
346
|
+
force: false,
|
|
333
347
|
};
|
|
334
348
|
|
|
335
349
|
for (const token of argv) {
|
|
@@ -337,6 +351,10 @@ export function parseCleanupArgs(argv: string[]): CleanupArgs {
|
|
|
337
351
|
parsed.destroyProject = true;
|
|
338
352
|
continue;
|
|
339
353
|
}
|
|
354
|
+
if (token === "--force") {
|
|
355
|
+
parsed.force = true;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
340
358
|
}
|
|
341
359
|
|
|
342
360
|
return parsed;
|
|
@@ -374,42 +392,63 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
|
|
|
374
392
|
};
|
|
375
393
|
}
|
|
376
394
|
|
|
377
|
-
export function runtimeSecretNames(target: DeploymentTarget) {
|
|
378
|
-
return {
|
|
379
|
-
CLERK_SECRET_KEY: `${target.serviceName}-clerk-secret-key`,
|
|
380
|
-
CLERK_WEBHOOK_SECRET: `${target.serviceName}-clerk-webhook-secret`,
|
|
381
|
-
STRIPE_SECRET_KEY: `${target.serviceName}-stripe-secret-key`,
|
|
382
|
-
STRIPE_WEBHOOK_SECRET: `${target.serviceName}-stripe-webhook-secret`,
|
|
383
|
-
REVENUECAT_API_KEY: `${target.serviceName}-revenuecat-api-key`,
|
|
384
|
-
REVENUECAT_WEBHOOK_SECRET: `${target.serviceName}-revenuecat-webhook-secret`,
|
|
385
|
-
RESEND_API_KEY: `${target.serviceName}-resend-api-key`,
|
|
386
|
-
POSTHOG_API_KEY: `${target.serviceName}-posthog-api-key`,
|
|
387
|
-
} as const;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
395
|
export async function renderManifest(image: string, target: DeploymentTarget) {
|
|
391
396
|
const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
|
|
397
|
+
const temporal = resolveTemporalRuntimeConfig();
|
|
392
398
|
const values = {
|
|
393
399
|
SERVICE_NAME: target.serviceName,
|
|
400
|
+
SERVICE_ID: config.serviceName,
|
|
394
401
|
RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
|
|
395
402
|
IMAGE_URL: image,
|
|
396
403
|
DATABASE_URL_SECRET: target.databaseSecretName,
|
|
397
|
-
...runtimeSecretNames(target),
|
|
398
404
|
SERVICE_RUNTIME: config.runtime,
|
|
399
405
|
SERVICE_FRAMEWORK: config.framework,
|
|
400
|
-
|
|
401
|
-
|
|
406
|
+
TEMPORAL_ENABLED: String(temporal.enabled),
|
|
407
|
+
TEMPORAL_ADDRESS: temporal.address,
|
|
408
|
+
TEMPORAL_NAMESPACE: temporal.namespace,
|
|
409
|
+
TEMPORAL_TASK_QUEUE: temporal.taskQueue,
|
|
410
|
+
TEMPORAL_API_KEY_ENV: temporal.apiKeySecretName
|
|
411
|
+
? [
|
|
412
|
+
" - name: TEMPORAL_API_KEY",
|
|
413
|
+
" valueFrom:",
|
|
414
|
+
" secretKeyRef:",
|
|
415
|
+
` name: ${temporal.apiKeySecretName}`,
|
|
416
|
+
" key: latest",
|
|
417
|
+
].join("\n")
|
|
418
|
+
: "",
|
|
419
|
+
AUTH_ISSUER: config.auth.issuer,
|
|
420
|
+
AUTH_AUDIENCE: config.auth.audience,
|
|
421
|
+
AUTH_JWKS_URL: config.auth.jwksUrl,
|
|
402
422
|
};
|
|
403
423
|
|
|
404
424
|
return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key: string) => {
|
|
405
425
|
const value = values[key as keyof typeof values];
|
|
406
|
-
if (
|
|
426
|
+
if (value === undefined) {
|
|
407
427
|
throw new Error(`missing manifest value for ${key}`);
|
|
408
428
|
}
|
|
409
429
|
return value;
|
|
410
430
|
});
|
|
411
431
|
}
|
|
412
432
|
|
|
433
|
+
export function resolveTemporalRuntimeConfig() {
|
|
434
|
+
const enabledOverride = process.env.TEMPORAL_ENABLED?.trim();
|
|
435
|
+
const address = process.env.TEMPORAL_ADDRESS?.trim() || config.temporal.address;
|
|
436
|
+
const namespace = process.env.TEMPORAL_NAMESPACE?.trim() || config.temporal.namespace;
|
|
437
|
+
const taskQueue = process.env.TEMPORAL_TASK_QUEUE?.trim() || config.temporal.taskQueue;
|
|
438
|
+
const apiKeySecretName = process.env.TEMPORAL_API_KEY_SECRET?.trim() || (process.env.TEMPORAL_API_KEY?.trim() ? config.temporal.apiKeySecretName : "");
|
|
439
|
+
const enabled = enabledOverride
|
|
440
|
+
? ["1", "true", "yes", "on"].includes(enabledOverride.toLowerCase())
|
|
441
|
+
: Boolean(process.env.TEMPORAL_ADDRESS?.trim() || process.env.TEMPORAL_API_KEY?.trim() || process.env.TEMPORAL_API_KEY_SECRET?.trim());
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
enabled,
|
|
445
|
+
address,
|
|
446
|
+
namespace,
|
|
447
|
+
taskQueue,
|
|
448
|
+
apiKeySecretName,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
413
452
|
export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
|
|
414
453
|
const rendered = await renderManifest(image, target);
|
|
415
454
|
const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
|
|
@@ -441,8 +480,13 @@ export function serviceOrigin(target: DeploymentTarget) {
|
|
|
441
480
|
}
|
|
442
481
|
|
|
443
482
|
export function ensureProductionDomainMapping(serviceName: string) {
|
|
444
|
-
|
|
445
|
-
|
|
483
|
+
const existing = describeProductionDomainMapping();
|
|
484
|
+
if (existing) {
|
|
485
|
+
const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
|
|
486
|
+
if (!mappedService || mappedService === serviceName) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
|
|
446
490
|
}
|
|
447
491
|
|
|
448
492
|
gcloud([
|
|
@@ -461,6 +505,60 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
461
505
|
]);
|
|
462
506
|
}
|
|
463
507
|
|
|
508
|
+
export function describeProductionDomainMapping():
|
|
509
|
+
| { spec?: { routeName?: string }; status?: { resourceRecords?: Array<{ rrdata?: string }> } }
|
|
510
|
+
| undefined {
|
|
511
|
+
const result = gcloud(
|
|
512
|
+
[
|
|
513
|
+
"beta",
|
|
514
|
+
"run",
|
|
515
|
+
"domain-mappings",
|
|
516
|
+
"describe",
|
|
517
|
+
"--domain",
|
|
518
|
+
config.domain.hostname,
|
|
519
|
+
"--project",
|
|
520
|
+
config.project.id,
|
|
521
|
+
"--region",
|
|
522
|
+
config.region,
|
|
523
|
+
"--format=json",
|
|
524
|
+
],
|
|
525
|
+
{ allowFailure: true }
|
|
526
|
+
);
|
|
527
|
+
if (!result.success || !result.stdout) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
return JSON.parse(result.stdout);
|
|
533
|
+
} catch {
|
|
534
|
+
throw new Error(`Unable to parse Cloud Run domain mapping for ${config.domain.hostname}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function assertProductionDomainAvailable(serviceName: string) {
|
|
539
|
+
const existing = describeProductionDomainMapping();
|
|
540
|
+
if (!existing) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const mappedService = existing.spec?.routeName;
|
|
545
|
+
if (mappedService && mappedService !== serviceName) {
|
|
546
|
+
throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; choose a different service_id before provisioning resources`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
throw new Error(`${config.domain.hostname} already has a domain mapping; use service deploy to redeploy or service dns to repair it`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function assertServiceNameAvailable(serviceName: string) {
|
|
553
|
+
const result = gcloud(
|
|
554
|
+
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(metadata.name)"],
|
|
555
|
+
{ allowFailure: true }
|
|
556
|
+
);
|
|
557
|
+
if (result.success) {
|
|
558
|
+
throw new Error(`${serviceName} already exists in Cloud Run; use service deploy to redeploy or service destroy to remove owned resources`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
464
562
|
export function deleteProductionDomainMapping() {
|
|
465
563
|
gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
|
|
466
564
|
allowFailure: true,
|
|
@@ -474,6 +572,14 @@ export function listCloudRunServices() {
|
|
|
474
572
|
.filter(Boolean);
|
|
475
573
|
}
|
|
476
574
|
|
|
575
|
+
export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
|
|
576
|
+
const result = gcloud(
|
|
577
|
+
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
|
|
578
|
+
{ allowFailure: true }
|
|
579
|
+
);
|
|
580
|
+
return parseOptionalJson(result.stdout, result.success);
|
|
581
|
+
}
|
|
582
|
+
|
|
477
583
|
export function deleteService(serviceName: string) {
|
|
478
584
|
gcloud(["run", "services", "delete", serviceName, "--project", config.project.id, "--region", config.region, "--quiet"], {
|
|
479
585
|
allowFailure: true,
|
|
@@ -491,3 +597,30 @@ function slugify(value: string) {
|
|
|
491
597
|
.replace(/[^a-z0-9]+/g, "-")
|
|
492
598
|
.replace(/^-+|-+$/g, "");
|
|
493
599
|
}
|
|
600
|
+
|
|
601
|
+
export function assertOwnedResource(name: string, resource: GcpResourceWithLabels | undefined) {
|
|
602
|
+
if (!resource) {
|
|
603
|
+
throw new Error(`${name} does not exist`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const labels = resource.metadata?.labels ?? resource.labels ?? {};
|
|
607
|
+
if (labels.managed_by !== "create-service" || labels.service_id !== config.serviceName) {
|
|
608
|
+
throw new Error(`${name} is missing ownership labels for service_id=${config.serviceName}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function ownershipLabelsArg() {
|
|
613
|
+
return `managed_by=create-service,service_id=${config.serviceName}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function parseOptionalJson<T>(stdout: string, success: boolean): T | undefined {
|
|
617
|
+
if (!success || !stdout) {
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
return JSON.parse(stdout) as T;
|
|
623
|
+
} catch {
|
|
624
|
+
throw new Error("Unable to parse gcloud JSON response");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
@@ -13,6 +13,11 @@ type NeonBranch = {
|
|
|
13
13
|
name: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
type NeonDatabase = {
|
|
17
|
+
name: string;
|
|
18
|
+
ownerName: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
16
21
|
type ResolvedNeonConfig = {
|
|
17
22
|
projectId: string;
|
|
18
23
|
baseBranchId: string;
|
|
@@ -110,6 +115,18 @@ export async function listBranches(projectId: string) {
|
|
|
110
115
|
.sort((left: NeonBranch, right: NeonBranch) => left.name.localeCompare(right.name));
|
|
111
116
|
}
|
|
112
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
|
+
|
|
113
130
|
export async function resolveNeonConfig(): Promise<ResolvedNeonConfig> {
|
|
114
131
|
const configuredProjectId = config.neon.projectId.trim();
|
|
115
132
|
const configuredBaseBranchId = config.neon.baseBranchId.trim();
|
|
@@ -172,6 +189,7 @@ export async function ensureDatabase(projectId: string, branchId: string, databa
|
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
export async function deleteDatabase(projectId: string, branchId: string, databaseName: string) {
|
|
192
|
+
await assertDatabaseOwned(projectId, branchId, databaseName);
|
|
175
193
|
try {
|
|
176
194
|
await (await neonClient()).deleteProjectBranchDatabase(projectId, branchId, databaseName);
|
|
177
195
|
} catch (error) {
|
|
@@ -213,6 +231,11 @@ export async function ensureBranch(projectId: string, branchName: string, parent
|
|
|
213
231
|
}
|
|
214
232
|
|
|
215
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);
|
|
216
239
|
try {
|
|
217
240
|
await (await neonClient()).deleteProjectBranch(projectId, branchId);
|
|
218
241
|
} catch (error) {
|
|
@@ -224,6 +247,28 @@ export async function deleteBranch(projectId: string, branchId: string) {
|
|
|
224
247
|
}
|
|
225
248
|
}
|
|
226
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
|
+
|
|
227
272
|
export async function getConnectionUri(projectId: string, branchId: string, databaseName: string, roleName: string) {
|
|
228
273
|
const payload = await (await neonClient()).getConnectionUri({
|
|
229
274
|
projectId,
|
|
@@ -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,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
|
+
}
|