create-svc 0.1.17 → 0.1.19

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 CHANGED
@@ -41,7 +41,7 @@ service deploy
41
41
  To install from npm:
42
42
 
43
43
  ```bash
44
- npm install -g create-svc
44
+ bun install -g create-svc
45
45
  ```
46
46
 
47
47
  For the strict one-command production path:
@@ -70,8 +70,7 @@ Without publishing to npm:
70
70
 
71
71
  ```bash
72
72
  bun install
73
- npm pack
74
- npm install -g ./create-svc-*.tgz
73
+ bun link
75
74
  service create my-service
76
75
  ```
77
76
 
@@ -130,6 +129,21 @@ service destroy
130
129
 
131
130
  Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
132
131
 
132
+ After `service create` has provisioned auth, the generated repo can mint a
133
+ client-credentials bearer token for smoke checks:
134
+
135
+ ```bash
136
+ TOKEN="$(service auth token)"
137
+ curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.<service_id>.anmho.com/v1/admin/waitlist?limit=1"
138
+ ```
139
+
140
+ For Go ConnectRPC services, use the same token with `grpcurl`:
141
+
142
+ ```bash
143
+ TOKEN="$(service auth token)"
144
+ grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto api.<service_id>.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries
145
+ ```
146
+
133
147
  The generated service is intended to be consumed by a web app, mobile client, or another service over HTTPS. In v1, production is expected to live at `https://api.<service_id>.anmho.com`, while preview and personal environments keep using deterministic platform URLs where appropriate.
134
148
 
135
149
  The generated microservice domain is a small waitlist/launch service example with public submit/status APIs and target-specific scheduled work.
@@ -142,7 +156,8 @@ bun test src scripts
142
156
  bun run index.ts create my-service
143
157
  ```
144
158
 
145
- Validate the generated app matrix against local Docker Compose Postgres:
159
+ Validate the generated service matrix against local Docker Compose Postgres and
160
+ Workers package checks:
146
161
 
147
162
  ```bash
148
163
  bun run validate:generated
@@ -150,7 +165,7 @@ bun run validate:generated -- --variant bun-hono
150
165
  bun run validate:generated -- --variant go-connectrpc --keep
151
166
  ```
152
167
 
153
- The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server, and smoke-tests health or typed ConnectRPC clients where applicable.
168
+ The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server for Cloud Run presets, smoke-tests health plus ConnectRPC clients where applicable, and verifies the Workers preset package compiles and tests.
154
169
 
155
170
  ## npm Trusted Publishing
156
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "dev": "bun run index.ts",
22
22
  "test": "bun test src scripts",
23
23
  "validate:generated": "bun run ./scripts/validate-generated.ts",
24
- "typecheck": "bunx tsc --noEmit"
24
+ "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",
@@ -16,6 +16,13 @@ describe("buildPostScaffoldCommands", () => {
16
16
  { command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
17
17
  ]);
18
18
  });
19
+
20
+ test("uses the workers service CLI for workers services", () => {
21
+ expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
22
+ { command: "bun", args: ["./scripts/workers/cli.ts", "create"] },
23
+ { command: "bun", args: ["./scripts/workers/cli.ts", "deploy"] },
24
+ ]);
25
+ });
19
26
  });
20
27
 
21
28
  describe("buildDeploymentVerificationCommands", () => {
@@ -23,13 +30,40 @@ describe("buildDeploymentVerificationCommands", () => {
23
30
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
24
31
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/healthz"] },
25
32
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/readyz"] },
33
+ {
34
+ command: "sh",
35
+ args: [
36
+ "-c",
37
+ 'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
38
+ ],
39
+ },
26
40
  ]);
27
41
  });
28
42
 
29
- test("uses grpcurl for Go ConnectRPC services", () => {
43
+ test("uses auth token and grpcurl for Go ConnectRPC services", () => {
30
44
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "connectrpc", runtime: "go" })).toContainEqual({
31
- command: "grpcurl",
32
- args: ["api.launch.anmho.com:443", "list"],
45
+ command: "sh",
46
+ args: [
47
+ "-c",
48
+ 'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && grpcurl -H "Authorization: Bearer $TOKEN" -d \'{"limit":1}\' -proto protos/waitlist/v1/waitlist.proto api.launch.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries',
49
+ ],
50
+ });
51
+ });
52
+
53
+ test("uses the workers service CLI for protected workers verification", () => {
54
+ expect(
55
+ buildDeploymentVerificationCommands({
56
+ target: "workers",
57
+ apiHostname: "api.launch.anmho.com",
58
+ framework: "hono",
59
+ runtime: "bun",
60
+ })
61
+ ).toContainEqual({
62
+ command: "sh",
63
+ args: [
64
+ "-c",
65
+ 'TOKEN="$(bun ./scripts/workers/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
66
+ ],
33
67
  });
34
68
  });
35
69
  });
@@ -20,6 +20,8 @@ type PostScaffoldCommand = {
20
20
 
21
21
  const decoder = new TextDecoder();
22
22
  const encoder = new TextEncoder();
23
+ const DEPLOYMENT_VERIFY_ATTEMPTS = 36;
24
+ const DEPLOYMENT_VERIFY_DELAY_MS = 10_000;
23
25
 
24
26
  export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
25
27
  if (config.autoDeploy) {
@@ -28,7 +30,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
28
30
  run(command.command, command.args, { cwd });
29
31
  }
30
32
  for (const command of buildDeploymentVerificationCommands(config)) {
31
- run(command.command, command.args, { cwd, quiet: true });
33
+ runWithRetries(command, { cwd, quiet: true }, DEPLOYMENT_VERIFY_ATTEMPTS, DEPLOYMENT_VERIFY_DELAY_MS);
32
34
  }
33
35
  return { message: "Dependencies installed, service created, service deployed, and production health verified" };
34
36
  }
@@ -36,31 +38,103 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
36
38
  return { message: "Backend package generated" };
37
39
  }
38
40
 
41
+ function runWithRetries(command: PostScaffoldCommand, options: CommandOptions, attempts: number, delayMs: number) {
42
+ let lastError: unknown;
43
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
44
+ try {
45
+ return run(command.command, command.args, options);
46
+ } catch (error) {
47
+ lastError = error;
48
+ if (attempt === attempts) {
49
+ break;
50
+ }
51
+ Bun.sleepSync(delayMs);
52
+ }
53
+ }
54
+ throw lastError;
55
+ }
56
+
39
57
  export function buildDeploymentVerificationCommands(
40
- config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">
58
+ config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime"> & Partial<Pick<ScaffoldConfig, "target">>
41
59
  ): PostScaffoldCommand[] {
42
60
  const origin = `https://${config.apiHostname}`;
61
+ const tokenCommand = `TOKEN="$(bun ${serviceCliPath(config)} auth token)"`;
43
62
  return [
44
63
  { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/healthz`] },
45
64
  { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/readyz`] },
46
- ...(config.framework === "connectrpc"
47
- ? [
48
- config.runtime === "go"
49
- ? { command: "grpcurl", args: [`${config.apiHostname}:443`, "list"] }
50
- : { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/debug/connectrpc`] },
51
- ]
52
- : []),
65
+ protectedVerificationCommand(config, origin, tokenCommand),
53
66
  ];
54
67
  }
55
68
 
56
- export function buildPostScaffoldCommands(config: Pick<ScaffoldConfig, "framework">): PostScaffoldCommand[] {
69
+ function protectedVerificationCommand(
70
+ config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">,
71
+ origin: string,
72
+ tokenCommand: string
73
+ ): PostScaffoldCommand {
74
+ if (config.framework === "connectrpc" && config.runtime === "go") {
75
+ return {
76
+ command: "sh",
77
+ args: [
78
+ "-c",
79
+ [
80
+ `${tokenCommand} &&`,
81
+ "grpcurl",
82
+ '-H "Authorization: Bearer $TOKEN"',
83
+ "-d '{\"limit\":1}'",
84
+ "-proto protos/waitlist/v1/waitlist.proto",
85
+ `${config.apiHostname}:443`,
86
+ "waitlist.v1.WaitlistService/ListWaitlistEntries",
87
+ ].join(" "),
88
+ ],
89
+ };
90
+ }
91
+
92
+ if (config.framework === "connectrpc") {
93
+ return {
94
+ command: "sh",
95
+ args: [
96
+ "-c",
97
+ [
98
+ `${tokenCommand} &&`,
99
+ "curl --fail --show-error --silent",
100
+ '-H "Authorization: Bearer $TOKEN"',
101
+ '-H "Content-Type: application/json"',
102
+ "-d '{\"limit\":1}'",
103
+ `"${origin}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
104
+ ].join(" "),
105
+ ],
106
+ };
107
+ }
108
+
109
+ return {
110
+ command: "sh",
111
+ args: [
112
+ "-c",
113
+ [
114
+ `${tokenCommand} &&`,
115
+ "curl --fail --show-error --silent",
116
+ '-H "Authorization: Bearer $TOKEN"',
117
+ `"${origin}/v1/admin/waitlist?limit=1"`,
118
+ ].join(" "),
119
+ ],
120
+ };
121
+ }
122
+
123
+ export function buildPostScaffoldCommands(
124
+ config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
125
+ ): PostScaffoldCommand[] {
126
+ const serviceCli = serviceCliPath(config);
57
127
  return [
58
- ...(config.framework === "connectrpc" ? [{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "sdk", "build"] }] : []),
59
- { command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
60
- { command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
128
+ ...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "bun", args: [serviceCli, "sdk", "build"] }] : []),
129
+ { command: "bun", args: [serviceCli, "create"] },
130
+ { command: "bun", args: [serviceCli, "deploy"] },
61
131
  ];
62
132
  }
63
133
 
134
+ function serviceCliPath(config: Partial<Pick<ScaffoldConfig, "target">>) {
135
+ return config.target === "workers" ? "./scripts/workers/cli.ts" : "./scripts/cloudrun/cli.ts";
136
+ }
137
+
64
138
  function installProjectDependencies(cwd: string) {
65
139
  requireCommand("bun");
66
140
  run("bun", ["install"], { cwd });
@@ -59,6 +59,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
59
59
  expect(serviceConfig).toContain('service_id: "dns-api"');
60
60
  expect(serviceConfig).toContain('target: "cloudrun"');
61
61
  expect(serviceConfig).toContain('module: "buf.build/anmho/dns-api"');
62
+ expect(serviceConfig).toContain('cloudflare_vault_path: "prod/providers/cloudflare"');
62
63
  expect(serviceConfig).toContain('issuer: "https://auth.anmho.com/api/auth"');
63
64
  expect(serviceConfig).toContain('audience: "api://dns-api"');
64
65
  expect(serviceConfig).toContain('vault_path_prefix: "prod/apps/dns-api/server/oauth-clients"');
@@ -79,6 +80,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
79
80
  expect(configScript).toContain('baseBranchName: "main"');
80
81
  expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
81
82
  expect(configScript).toContain('hostname: "api.dns-api.anmho.com"');
83
+ expect(configScript).toContain('cloudflareVaultPath: "prod/providers/cloudflare"');
82
84
  expect(configScript).not.toContain("github:");
83
85
  expect(configScript).not.toContain("attachmentBucket");
84
86
 
@@ -87,6 +89,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
87
89
  expect(deployScript).toContain('projectMode === "use_existing"');
88
90
  expect(deployScript).toContain("serviceDomain");
89
91
  expect(deployScript).toContain("ensureProductionDomainMapping");
92
+ expect(deployScript).toContain("ensureCloudflareDnsRecord");
93
+ expect(deployScript).toContain("CLOUDFLARE_API_TOKEN");
90
94
  expect(deployScript).toContain('"domain-mappings",');
91
95
  expect(deployScript).toContain('"--region",');
92
96
  expect(deployScript).toContain("assertProductionDomainAvailable");
@@ -152,6 +156,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
152
156
  expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
153
157
  expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
154
158
  expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
159
+ expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
155
160
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
156
161
 
157
162
  const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
@@ -218,6 +223,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
218
223
  expect(serviceCli).toContain("Provision auth, database, migrations, and first deploy");
219
224
  expect(serviceCli).toContain("assertServiceNameAvailable(config.serviceName)");
220
225
  expect(serviceCli).toContain("ensureAuthResourceServer");
226
+ expect(serviceCli).toContain("ensureAuthClient");
227
+ expect(serviceCli).toContain("auth token");
221
228
  expect(serviceCli).toContain('["resources", "push", "--path", "./grafana"]');
222
229
  const cloudrunLib = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
223
230
  expect(cloudrunLib).toContain("resolveTemporalRuntimeConfig");
@@ -229,6 +236,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
229
236
  expect(authctlScript).toContain("resource-servers");
230
237
  expect(authctlScript).toContain("clients");
231
238
  expect(authctlScript).toContain("defaultClientTargetArgs");
239
+ expect(authctlScript).toContain("ensureAuthClient");
240
+ expect(authctlScript).toContain("mintAuthToken");
241
+ expect(authctlScript).toContain("clientVaultPath");
232
242
  expect(authctlScript).toContain("deleteAuthResourceServer");
233
243
  expect(authctlScript).toContain("readAuthctlAccessVaultField");
234
244
  expect(authctlScript).toContain("prod/apps/auth/authctl/cloudflare-access");
@@ -363,6 +373,8 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
363
373
  expect(workerCli).toContain("hyperdrive");
364
374
  expect(workerCli).toContain('["resources", "push", "--path", "./grafana"]');
365
375
  expect(workerCli).toContain("ensureAuthResourceServer");
376
+ expect(workerCli).toContain("ensureAuthClient");
377
+ expect(workerCli).toContain("auth token");
366
378
  expect(workerCli).toContain("Workers database schema applied");
367
379
  expect(workerCli).toContain("create table if not exists waitlist_entries");
368
380
  expect(workerCli).toContain("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
package/src/scaffold.ts CHANGED
@@ -218,6 +218,7 @@ function buildReplacements(config: ScaffoldConfig) {
218
218
  COMMAND_DEPLOY: "service deploy",
219
219
  COMMAND_AUTH_RESOURCE: "service auth resource-server",
220
220
  COMMAND_AUTH_CLIENT: "service auth client create",
221
+ COMMAND_AUTH_TOKEN: "service auth token",
221
222
  COMMAND_DEPLOY_PERSONAL: "service deploy --environment personal --name <name>",
222
223
  COMMAND_DEPLOY_DESTROY: "service destroy --environment personal --name <name>",
223
224
  COMMAND_CLEANUP: "service destroy",
@@ -236,9 +237,42 @@ function buildReplacements(config: ScaffoldConfig) {
236
237
  "- override with `ENABLE_RPC_INTROSPECTION=true|false`",
237
238
  ].join("\n")
238
239
  : "",
240
+ PRODUCTION_PROTECTED_CHECKS: buildProtectedChecks(config),
239
241
  };
240
242
  }
241
243
 
244
+ function buildProtectedChecks(config: ScaffoldConfig) {
245
+ const tokenCommand = 'TOKEN="$(service auth token)"';
246
+ if (config.framework === "connectrpc" && config.runtime === "go") {
247
+ return [
248
+ "After deploy, verify protected reads with:",
249
+ "",
250
+ "```bash",
251
+ tokenCommand,
252
+ `grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto ${config.apiHostname}:443 waitlist.v1.WaitlistService/ListWaitlistEntries`,
253
+ "```",
254
+ ].join("\n");
255
+ }
256
+ if (config.framework === "connectrpc") {
257
+ return [
258
+ "After deploy, verify protected reads with:",
259
+ "",
260
+ "```bash",
261
+ tokenCommand,
262
+ `curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"limit":1}' "https://${config.apiHostname}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
263
+ "```",
264
+ ].join("\n");
265
+ }
266
+ return [
267
+ "After deploy, verify protected reads with:",
268
+ "",
269
+ "```bash",
270
+ tokenCommand,
271
+ `curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://${config.apiHostname}/v1/admin/waitlist?limit=1"`,
272
+ "```",
273
+ ].join("\n");
274
+ }
275
+
242
276
  async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
243
277
  const envPath = join(targetDir, ".env.local");
244
278
  if (await Bun.file(envPath).exists()) {
@@ -259,6 +293,8 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
259
293
  "VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
260
294
  "VAULT_NEON_API_KEY_PATH=prod/providers/neon",
261
295
  "VAULT_NEON_API_KEY_FIELD=api_key",
296
+ "VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare",
297
+ "VAULT_CLOUDFLARE_API_TOKEN_FIELD=api_token",
262
298
  "",
263
299
  ].join("\n"),
264
300
  replacements
@@ -179,6 +179,7 @@ Use `service auth` for follow-up auth operations:
179
179
  {{COMMAND_BOOTSTRAP}} # includes resource-server registration
180
180
  {{COMMAND_AUTH_RESOURCE}}
181
181
  {{COMMAND_AUTH_CLIENT}} --resource-server <target-service> --scope <scope>
182
+ {{COMMAND_AUTH_TOKEN}}
182
183
  ```
183
184
 
184
185
  `authctl clients create` prints a one-time client secret plus the recommended
@@ -194,6 +195,10 @@ target resource server as `resource=api://<resource_server_id>`. The generated
194
195
  runtime expects a JWT with that audience; omitting `resource` can return an
195
196
  opaque access token that the service will reject.
196
197
 
198
+ `{{COMMAND_AUTH_TOKEN}}` reads the generated service-client credentials from
199
+ Vault, creates and stores them if missing, and prints a bearer token suitable
200
+ for local smoke checks.
201
+
197
202
  Webhook signature hooks are provider-specific and optional in v1. Add provider
198
203
  secrets only when you add a provider adapter. A generic adapter can honor:
199
204
 
@@ -231,6 +236,8 @@ The main environment is intended to be public at:
231
236
  https://{{API_HOSTNAME}}
232
237
  ```
233
238
 
239
+ {{PRODUCTION_PROTECTED_CHECKS}}
240
+
234
241
  Preview and personal environments keep using deterministic Cloud Run hostnames in v1 so the generated backend stays easy to use inside standalone repos or monorepos.
235
242
 
236
243
  ## Generated backend domain
@@ -253,6 +260,7 @@ Hono variants expose:
253
260
  - `POST /v1/waitlist`
254
261
  - `GET /v1/waitlist?email=...`
255
262
  - `GET /v1/waitlist/{entryId}`
263
+ - `GET /v1/admin/waitlist`
256
264
  - `POST /v1/triggers/waitlist`
257
265
  - `POST /webhooks/:provider`
258
266
 
@@ -27,6 +27,17 @@ type ResourceServerMutationCommand = ResourceServerCommand & {
27
27
  mutationAction: "upsert" | "create";
28
28
  };
29
29
 
30
+ type ClientCredentials = {
31
+ client_id: string;
32
+ client_secret: string;
33
+ };
34
+
35
+ type TokenResponse = {
36
+ access_token?: string;
37
+ token_type?: string;
38
+ expires_in?: number;
39
+ };
40
+
30
41
  export function defaultAuthResourceServerArgs() {
31
42
  const auth = serviceConfig.auth;
32
43
  return [
@@ -92,7 +103,11 @@ export function runAuthCommand(args: string[]) {
92
103
  return runClientCommand(action, rest);
93
104
  }
94
105
 
95
- throw new Error("Usage: service auth <doctor|resource-server|client> [args]");
106
+ if (subject === "token") {
107
+ return mintAuthToken(parseTokenOptions([action, ...rest].filter(Boolean) as string[]));
108
+ }
109
+
110
+ throw new Error("Usage: service auth <doctor|resource-server|client|token> [args]");
96
111
  }
97
112
 
98
113
  export function ensureAuthResourceServer() {
@@ -101,6 +116,34 @@ export function ensureAuthResourceServer() {
101
116
  return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
102
117
  }
103
118
 
119
+ export function ensureAuthClient() {
120
+ const existing = readStoredClientCredentials();
121
+ if (existing) {
122
+ return `Auth client ready: ${existing.client_id}`;
123
+ }
124
+
125
+ const result = authctl(
126
+ [
127
+ "clients",
128
+ "create",
129
+ "--client-app",
130
+ serviceConfig.auth.client.app_id,
131
+ "--client-identity",
132
+ serviceConfig.auth.client.identity,
133
+ ...defaultClientTargetArgs([]),
134
+ "--stage",
135
+ serviceConfig.stage_default,
136
+ "--yes",
137
+ "--json",
138
+ ],
139
+ { quiet: true }
140
+ );
141
+
142
+ const created = parseClientSecretResponse(result.stdout);
143
+ storeClientCredentials(created);
144
+ return `Auth client ready: ${created.client_id}`;
145
+ }
146
+
104
147
  export function deleteAuthResourceServer() {
105
148
  const command = resolveResourceServerCommand();
106
149
  if (!command?.actions.includes("delete")) {
@@ -164,8 +207,12 @@ export function runAuthDoctor(): AuthDoctorResult {
164
207
  }
165
208
 
166
209
  function runClientCommand(action = "", rest: string[]) {
210
+ if (!action || action === "ensure") {
211
+ return ensureAuthClient();
212
+ }
213
+
167
214
  if (action === "create") {
168
- authctl([
215
+ const result = authctl([
169
216
  "clients",
170
217
  "create",
171
218
  "--client-app",
@@ -179,6 +226,8 @@ function runClientCommand(action = "", rest: string[]) {
179
226
  "--json",
180
227
  ...rest,
181
228
  ]);
229
+ const created = parseClientSecretResponse(result.stdout);
230
+ storeClientCredentials(created);
182
231
  return "Auth client created";
183
232
  }
184
233
 
@@ -199,6 +248,112 @@ function defaultClientTargetArgs(rest: string[]) {
199
248
  ];
200
249
  }
201
250
 
251
+ function parseTokenOptions(args: string[]) {
252
+ const options: { json: boolean; scope?: string[]; resource?: string; audience?: string } = { json: false };
253
+ for (let index = 0; index < args.length; index += 1) {
254
+ const token = args[index];
255
+ if (!token) continue;
256
+ const next = args[index + 1];
257
+ const readValue = () => {
258
+ if (!next || next.startsWith("-")) {
259
+ throw new Error(`Missing value for ${token}`);
260
+ }
261
+ index += 1;
262
+ return next;
263
+ };
264
+ if (token === "--json") {
265
+ options.json = true;
266
+ continue;
267
+ }
268
+ if (token === "--scope") {
269
+ options.scope = [...(options.scope ?? []), readValue()];
270
+ continue;
271
+ }
272
+ if (token.startsWith("--scope=")) {
273
+ options.scope = [...(options.scope ?? []), token.slice("--scope=".length)];
274
+ continue;
275
+ }
276
+ if (token === "--resource") {
277
+ options.resource = readValue();
278
+ continue;
279
+ }
280
+ if (token.startsWith("--resource=")) {
281
+ options.resource = token.slice("--resource=".length);
282
+ continue;
283
+ }
284
+ if (token === "--audience") {
285
+ options.audience = readValue();
286
+ continue;
287
+ }
288
+ if (token.startsWith("--audience=")) {
289
+ options.audience = token.slice("--audience=".length);
290
+ continue;
291
+ }
292
+ throw new Error(`Unknown service auth token argument: ${token}`);
293
+ }
294
+ return options;
295
+ }
296
+
297
+ function mintAuthToken(options: { json: boolean; scope?: string[]; resource?: string; audience?: string }) {
298
+ const credentials = readStoredClientCredentials() ?? createAndStoreClientCredentials();
299
+ const scopes = options.scope?.length ? options.scope : serviceConfig.auth.resource_server.default_scopes;
300
+ const resource = options.resource ?? serviceConfig.auth.resource_server.audience;
301
+ const token = requestClientCredentialsToken(credentials, {
302
+ scope: scopes.join(" "),
303
+ resource,
304
+ audience: options.audience,
305
+ });
306
+
307
+ if (options.json) {
308
+ return JSON.stringify(token, null, 2);
309
+ }
310
+
311
+ if (!token.access_token) {
312
+ throw new Error("token response did not include access_token");
313
+ }
314
+ return token.access_token;
315
+ }
316
+
317
+ function createAndStoreClientCredentials() {
318
+ ensureAuthClient();
319
+ const stored = readStoredClientCredentials();
320
+ if (!stored) {
321
+ throw new Error(`Auth client credentials were not written to Vault path ${clientVaultPath()}`);
322
+ }
323
+ return stored;
324
+ }
325
+
326
+ function requestClientCredentialsToken(credentials: ClientCredentials, options: { scope: string; resource: string; audience?: string }) {
327
+ const body = new URLSearchParams({
328
+ grant_type: "client_credentials",
329
+ scope: options.scope,
330
+ resource: options.resource,
331
+ });
332
+ if (options.audience) {
333
+ body.set("audience", options.audience);
334
+ }
335
+
336
+ const response = fetchSync(serviceConfig.auth.token_endpoint, {
337
+ method: "POST",
338
+ headers: {
339
+ authorization: `Basic ${basicAuth(credentials.client_id, credentials.client_secret)}`,
340
+ "content-type": "application/x-www-form-urlencoded",
341
+ accept: "application/json",
342
+ },
343
+ body: body.toString(),
344
+ });
345
+
346
+ if (response.status < 200 || response.status >= 300) {
347
+ throw new Error(`token request failed: ${response.status} ${response.body}`);
348
+ }
349
+
350
+ const token = JSON.parse(response.body) as TokenResponse;
351
+ if (token.token_type?.toLowerCase() !== "bearer" || !token.access_token) {
352
+ throw new Error("token response did not include a bearer access_token");
353
+ }
354
+ return token;
355
+ }
356
+
202
357
  function hasFlag(args: string[], name: string) {
203
358
  return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
204
359
  }
@@ -335,3 +490,112 @@ function readAuthctlAccessVaultField(env: Record<string, string | undefined>, fi
335
490
 
336
491
  return decoder.decode(result.stdout).trim();
337
492
  }
493
+
494
+ function parseClientSecretResponse(stdout: string): ClientCredentials {
495
+ if (!stdout) {
496
+ throw new Error("authctl did not return client credentials");
497
+ }
498
+ const parsed = JSON.parse(stdout) as { client_id?: string; client_secret?: string };
499
+ if (!parsed.client_id || !parsed.client_secret) {
500
+ throw new Error("authctl client create did not return one-time client credentials");
501
+ }
502
+ return {
503
+ client_id: parsed.client_id,
504
+ client_secret: parsed.client_secret,
505
+ };
506
+ }
507
+
508
+ function readStoredClientCredentials(): ClientCredentials | undefined {
509
+ const clientId = readVaultField(clientVaultPath(), "client_id");
510
+ const clientSecret = readVaultField(clientVaultPath(), "client_secret");
511
+ if (!clientId || !clientSecret) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ client_id: clientId,
516
+ client_secret: clientSecret,
517
+ };
518
+ }
519
+
520
+ function storeClientCredentials(credentials: ClientCredentials) {
521
+ const vault = requireVaultCommand();
522
+ const result = Bun.spawnSync(
523
+ [
524
+ vault,
525
+ "kv",
526
+ "put",
527
+ `-mount=${vaultMount()}`,
528
+ clientVaultPath(),
529
+ `client_id=${credentials.client_id}`,
530
+ `client_secret=${credentials.client_secret}`,
531
+ ],
532
+ {
533
+ cwd: process.cwd(),
534
+ env: process.env,
535
+ stdout: "pipe",
536
+ stderr: "pipe",
537
+ }
538
+ );
539
+ if (!result.success) {
540
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
541
+ throw new Error(`failed to store auth client credentials in Vault at ${clientVaultPath()}\n${stderr}`);
542
+ }
543
+ }
544
+
545
+ function readVaultField(path: string, field: string) {
546
+ const vault = Bun.which("vault");
547
+ if (!vault) {
548
+ return "";
549
+ }
550
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${vaultMount()}`, `-field=${field}`, path], {
551
+ cwd: process.cwd(),
552
+ env: process.env,
553
+ stdout: "pipe",
554
+ stderr: "pipe",
555
+ });
556
+ if (!result.success || !result.stdout) {
557
+ return "";
558
+ }
559
+ return decoder.decode(result.stdout).trim();
560
+ }
561
+
562
+ function requireVaultCommand() {
563
+ const vault = Bun.which("vault");
564
+ if (!vault) {
565
+ throw new Error("vault is required to store generated auth client credentials");
566
+ }
567
+ return vault;
568
+ }
569
+
570
+ function vaultMount() {
571
+ return serviceConfig.providers.vault.mount || "secret";
572
+ }
573
+
574
+ function clientVaultPath() {
575
+ return `${serviceConfig.auth.client.vault_path_prefix}/${serviceConfig.auth.resource_server.id}`;
576
+ }
577
+
578
+ function basicAuth(clientId: string, clientSecret: string) {
579
+ return Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
580
+ }
581
+
582
+ function fetchSync(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
583
+ const script = [
584
+ "const url = process.argv[1];",
585
+ "const init = JSON.parse(process.argv[2]);",
586
+ "const response = await fetch(url, init);",
587
+ "const body = await response.text();",
588
+ "console.log(JSON.stringify({ status: response.status, body }));",
589
+ ].join("\n");
590
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
591
+ cwd: process.cwd(),
592
+ env: process.env,
593
+ stdout: "pipe",
594
+ stderr: "pipe",
595
+ });
596
+ if (!result.success) {
597
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
598
+ throw new Error(`token request process failed\n${stderr}`);
599
+ }
600
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
601
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { mkdir } from "node:fs/promises";
4
- import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
4
+ import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
5
  import { bootstrap, prepareGcpProject } from "./bootstrap";
6
6
  import { cleanup } from "./cleanup";
7
7
  import { deploy } from "./deploy";
@@ -37,6 +37,7 @@ async function main(argv = Bun.argv.slice(2)) {
37
37
  assertProductionDomainAvailable(config.serviceName);
38
38
  await prepareGcpProject();
39
39
  await runStep("Registering auth resource server", () => ensureAuthResourceServer());
40
+ await runStep("Provisioning auth client", () => ensureAuthClient());
40
41
  await bootstrap({ skipProjectSetup: true });
41
42
  const target = resolveDeploymentTarget("main");
42
43
  const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
@@ -79,6 +80,10 @@ async function main(argv = Bun.argv.slice(2)) {
79
80
  }
80
81
 
81
82
  if (command === "auth") {
83
+ if (rest[0] === "token") {
84
+ console.log(runAuthCommand(rest));
85
+ return;
86
+ }
82
87
  await runMain("Auth", () => runAuthCommand(rest));
83
88
  return;
84
89
  }
@@ -108,6 +113,7 @@ function formatHelp() {
108
113
  " seed Run the seed script when configured",
109
114
  " doctor Check local tools and cloud access",
110
115
  " auth Manage auth resource server and clients",
116
+ " auth token Mint a bearer token for protected API checks",
111
117
  " sdk Build or publish generated SDK artifacts",
112
118
  " dns Repair or inspect DNS mappings",
113
119
  " dashboards Publish Grafana resources",
@@ -22,6 +22,9 @@ export const config = {
22
22
  domain: {
23
23
  hostname: "{{API_HOSTNAME}}",
24
24
  baseDomain: "{{API_BASE_DOMAIN}}",
25
+ cloudflareApiBaseUrl: "https://api.cloudflare.com/client/v4",
26
+ cloudflareVaultPath: "prod/providers/cloudflare",
27
+ cloudflareVaultField: "api_token",
25
28
  },
26
29
  auth: {
27
30
  issuer: "https://auth.anmho.com/api/auth",
@@ -42,6 +42,7 @@ type CommandResult = {
42
42
 
43
43
  const decoder = new TextDecoder();
44
44
  const encoder = new TextEncoder();
45
+ const CLOUDFLARE_DNS_TTL_AUTO = 1;
45
46
 
46
47
  export class CommandError extends Error {
47
48
  command: string;
@@ -485,12 +486,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
485
486
  if (existing) {
486
487
  const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
487
488
  if (!mappedService || mappedService === serviceName) {
489
+ ensureCloudflareDnsRecord(existing);
488
490
  return;
489
491
  }
490
492
  throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
491
493
  }
492
494
 
493
- gcloud([
495
+ const result = gcloud([
494
496
  "beta",
495
497
  "run",
496
498
  "domain-mappings",
@@ -504,6 +506,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
504
506
  "--region",
505
507
  config.region,
506
508
  ]);
509
+ const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
510
+ ensureCloudflareDnsRecord(created);
507
511
  }
508
512
 
509
513
  export function describeProductionDomainMapping():
@@ -561,6 +565,7 @@ export function assertServiceNameAvailable(serviceName: string) {
561
565
  }
562
566
 
563
567
  export function deleteProductionDomainMapping() {
568
+ deleteCloudflareDnsRecord();
564
569
  gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
565
570
  allowFailure: true,
566
571
  });
@@ -573,6 +578,165 @@ export function listCloudRunServices() {
573
578
  .filter(Boolean);
574
579
  }
575
580
 
581
+ function parseDomainMappingOutput(stdout: string) {
582
+ if (!stdout.trim().startsWith("{")) {
583
+ return undefined;
584
+ }
585
+ try {
586
+ return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
587
+ } catch {
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ function ensureCloudflareDnsRecord(
593
+ mapping:
594
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
595
+ | undefined
596
+ ) {
597
+ const desired = desiredCloudflareRecord(mapping);
598
+ const zoneId = cloudflareZoneId();
599
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
600
+ const conflicting = records.find((record) => record.type !== desired.type);
601
+ if (conflicting) {
602
+ throw new Error(
603
+ `Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
604
+ );
605
+ }
606
+ const existing = records.find((record) => record.type === desired.type);
607
+ if (!existing) {
608
+ cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
609
+ return;
610
+ }
611
+ if (existing.content === desired.content && existing.proxied === desired.proxied) {
612
+ return;
613
+ }
614
+ cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
615
+ }
616
+
617
+ function deleteCloudflareDnsRecord() {
618
+ const token = resolveCloudflareApiToken({ required: false });
619
+ if (!token) {
620
+ return;
621
+ }
622
+ const zoneId = cloudflareZoneId(token);
623
+ const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
624
+ for (const record of records) {
625
+ cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
626
+ }
627
+ }
628
+
629
+ function desiredCloudflareRecord(
630
+ mapping:
631
+ | { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
632
+ | undefined
633
+ ) {
634
+ const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
635
+ const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
636
+ return {
637
+ type: "CNAME",
638
+ name: config.domain.hostname,
639
+ content,
640
+ ttl: CLOUDFLARE_DNS_TTL_AUTO,
641
+ proxied: false,
642
+ };
643
+ }
644
+
645
+ function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
646
+ const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
647
+ const zone = response.result?.[0] as { id?: string } | undefined;
648
+ if (!zone?.id) {
649
+ throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
650
+ }
651
+ return zone.id;
652
+ }
653
+
654
+ function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
655
+ const response = cloudflareFetch(
656
+ "GET",
657
+ `/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
658
+ undefined,
659
+ token
660
+ );
661
+ return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
662
+ }
663
+
664
+ function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
665
+ const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
666
+ method,
667
+ headers: {
668
+ authorization: `Bearer ${token}`,
669
+ "content-type": "application/json",
670
+ accept: "application/json",
671
+ },
672
+ body: body === undefined ? undefined : JSON.stringify(body),
673
+ });
674
+ if (response.status < 200 || response.status >= 300) {
675
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
676
+ }
677
+ const parsed = response.body ? JSON.parse(response.body) : {};
678
+ if (parsed.success === false) {
679
+ throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
680
+ }
681
+ return parsed;
682
+ }
683
+
684
+ function resolveCloudflareApiToken(options: { required: boolean }) {
685
+ const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
686
+ if (direct) {
687
+ return direct;
688
+ }
689
+
690
+ const vault = Bun.which("vault");
691
+ if (vault) {
692
+ const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
693
+ const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
694
+ const result = Bun.spawnSync(
695
+ [vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
696
+ {
697
+ cwd: process.cwd(),
698
+ env: process.env,
699
+ stdout: "pipe",
700
+ stderr: "pipe",
701
+ }
702
+ );
703
+ if (result.success && result.stdout) {
704
+ return decoder.decode(result.stdout).trim();
705
+ }
706
+ }
707
+
708
+ if (!options.required) {
709
+ return "";
710
+ }
711
+ throw new Error(
712
+ [
713
+ "CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
714
+ `Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
715
+ ].join(" ")
716
+ );
717
+ }
718
+
719
+ function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
720
+ const script = [
721
+ "const url = process.argv[1];",
722
+ "const init = JSON.parse(process.argv[2]);",
723
+ "const response = await fetch(url, init);",
724
+ "const body = await response.text();",
725
+ "console.log(JSON.stringify({ status: response.status, body }));",
726
+ ].join("\n");
727
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
728
+ cwd: process.cwd(),
729
+ env: process.env,
730
+ stdout: "pipe",
731
+ stderr: "pipe",
732
+ });
733
+ if (!result.success) {
734
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
735
+ throw new Error(`Cloudflare request process failed\n${stderr}`);
736
+ }
737
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
738
+ }
739
+
576
740
  export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
577
741
  const result = gcloud(
578
742
  ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
@@ -7,6 +7,9 @@ export default {
7
7
  dns: {
8
8
  hostname: "{{API_HOSTNAME}}",
9
9
  base_domain: "{{API_BASE_DOMAIN}}",
10
+ cloudflare_api_base_url: "https://api.cloudflare.com/client/v4",
11
+ cloudflare_vault_path: "prod/providers/cloudflare",
12
+ cloudflare_vault_field: "api_token",
10
13
  },
11
14
  ownership: {
12
15
  managed_by: "create-service",
@@ -38,6 +41,7 @@ export default {
38
41
  vault: {
39
42
  mount: "secret",
40
43
  neon_path: "prod/providers/neon",
44
+ cloudflare_path: "prod/providers/cloudflare",
41
45
  grafana_path: "prod/providers/grafana",
42
46
  clerk_m2m_path: "prod/providers/clerk-m2m",
43
47
  temporal_path: "prod/providers/temporal",
@@ -12,7 +12,7 @@ gen:
12
12
  @echo "no generated code for workers"
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -43,6 +43,7 @@ small waitlist/trigger schema on first use.
43
43
  - `POST /v1/waitlist`
44
44
  - `GET /v1/waitlist?email=<email>`
45
45
  - `GET /v1/waitlist/:entryId`
46
+ - `GET /v1/admin/waitlist`
46
47
  - `POST /v1/triggers/waitlist`
47
48
  - `POST /webhooks/:provider`
48
49
  - `GET /webhooks/:provider/health`
@@ -63,6 +64,8 @@ https://{{API_HOSTNAME}}
63
64
  Use `service doctor` after create to verify Wrangler auth, route config, Cron,
64
65
  Hyperdrive, dashboard tooling, auth tooling, and deployed health.
65
66
 
67
+ {{PRODUCTION_PROTECTED_CHECKS}}
68
+
66
69
  If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
67
70
  `NEON_API_KEY` to create/resolve the generated Neon database and connection URI,
68
71
  applies the waitlist schema, then runs `wrangler hyperdrive create` and writes
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/workers/cli.ts",
11
11
  "migrate": "bun run ./scripts/workers/cli.ts migrate",
12
12
  "seed": "bun run ./scripts/workers/cli.ts seed",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/workers/cli.ts create",
16
16
  "deploy": "bun run ./scripts/workers/cli.ts deploy",
@@ -3,7 +3,7 @@
3
3
  import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
4
4
  import { createApiClient } from "@neondatabase/api-client";
5
5
  import { Client } from "pg";
6
- import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
6
+ import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
7
7
 
8
8
  const config = {
9
9
  serviceName: "{{SERVICE_NAME}}",
@@ -25,6 +25,7 @@ async function main(argv = Bun.argv.slice(2)) {
25
25
  if (command === "create") {
26
26
  return runMain("Create", async () => {
27
27
  ensureAuthResourceServer();
28
+ ensureAuthClient();
28
29
  const databaseUrl = await resolveDatabaseUrl();
29
30
  await applySchema(databaseUrl);
30
31
  await ensureHyperdrive(databaseUrl);
@@ -68,6 +69,10 @@ async function main(argv = Bun.argv.slice(2)) {
68
69
  }
69
70
 
70
71
  if (command === "auth") {
72
+ if (rest[0] === "token") {
73
+ console.log(runAuthCommand(rest));
74
+ return;
75
+ }
71
76
  return runMain("Auth", () => runAuthCommand(rest));
72
77
  }
73
78
 
@@ -102,6 +107,7 @@ function formatHelp() {
102
107
  " seed Report seed status",
103
108
  " doctor Check local tools and cloud access",
104
109
  " auth Manage auth resource server and clients",
110
+ " auth token Mint a bearer token for protected API checks",
105
111
  " dns Show Workers custom-domain configuration",
106
112
  " dashboards Publish Grafana resources",
107
113
  " destroy Remove service-managed Worker resources",
@@ -12,7 +12,7 @@ gen:
12
12
  bun run ./scripts/codegen.ts
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
11
  "migrate": "bun run ./scripts/migrate.ts",
12
12
  "gen": "bun run ./scripts/codegen.ts",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/cloudrun/cli.ts create",
16
16
  "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
@@ -12,7 +12,7 @@ gen:
12
12
  bun run ./scripts/codegen.ts
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
11
  "migrate": "bun run ./scripts/migrate.ts",
12
12
  "gen": "bun run ./scripts/codegen.ts",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/cloudrun/cli.ts create",
16
16
  "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",