create-svc 0.1.1 → 0.1.3

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 (73) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -1
  3. package/src/cli.test.ts +10 -0
  4. package/src/cli.ts +331 -107
  5. package/src/gcp.test.ts +71 -0
  6. package/src/gcp.ts +97 -0
  7. package/src/naming.test.ts +37 -0
  8. package/src/naming.ts +103 -0
  9. package/src/neon.test.ts +48 -0
  10. package/src/neon.ts +76 -0
  11. package/src/post-scaffold.ts +77 -0
  12. package/src/scaffold.test.ts +66 -31
  13. package/src/scaffold.ts +60 -55
  14. package/templates/root/.github/workflows/deploy.yml +1 -1
  15. package/templates/root/README.md +3 -3
  16. package/templates/shared/.github/workflows/ci.yml +22 -0
  17. package/templates/shared/.github/workflows/deploy.yml +30 -0
  18. package/templates/shared/.github/workflows/personal.yml +41 -0
  19. package/templates/shared/.github/workflows/preview-cleanup.yml +25 -0
  20. package/templates/shared/.github/workflows/preview.yml +29 -0
  21. package/templates/shared/README.md +37 -0
  22. package/templates/shared/scripts/cloudrun/bootstrap.ts +76 -0
  23. package/templates/shared/scripts/cloudrun/config.ts +57 -0
  24. package/templates/shared/scripts/cloudrun/deploy.ts +82 -0
  25. package/templates/shared/scripts/cloudrun/lib.ts +380 -0
  26. package/templates/shared/scripts/cloudrun/neon.ts +104 -0
  27. package/templates/shared/service.yaml +28 -0
  28. package/templates/variants/bun-connectrpc/Dockerfile +13 -0
  29. package/templates/variants/bun-connectrpc/package.json +20 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -0
  31. package/templates/variants/bun-connectrpc/src/index.ts +32 -0
  32. package/templates/variants/bun-connectrpc/test/app.test.ts +17 -0
  33. package/templates/variants/bun-connectrpc/tsconfig.json +10 -0
  34. package/templates/variants/bun-hono/Dockerfile +13 -0
  35. package/templates/variants/bun-hono/package.json +21 -0
  36. package/templates/variants/bun-hono/scripts/codegen.ts +1 -0
  37. package/templates/variants/bun-hono/src/index.ts +24 -0
  38. package/templates/variants/bun-hono/test/app.test.ts +12 -0
  39. package/templates/variants/bun-hono/tsconfig.json +10 -0
  40. package/templates/variants/go-chi/Dockerfile +23 -0
  41. package/templates/variants/go-chi/buf.gen.yaml +10 -0
  42. package/templates/variants/go-chi/buf.yaml +9 -0
  43. package/templates/variants/go-chi/cmd/server/main.go +52 -0
  44. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +623 -0
  45. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  46. package/templates/variants/go-chi/go.mod +10 -0
  47. package/templates/variants/go-chi/internal/app/service.go +109 -0
  48. package/templates/variants/go-chi/internal/app/token_source.go +50 -0
  49. package/templates/variants/go-chi/internal/cloudflare/client.go +160 -0
  50. package/templates/variants/go-chi/internal/config/config.go +23 -0
  51. package/templates/variants/go-chi/internal/connectapi/handler.go +79 -0
  52. package/templates/variants/go-chi/internal/httpapi/routes.go +93 -0
  53. package/templates/variants/go-chi/internal/vault/client.go +148 -0
  54. package/templates/variants/go-chi/package.json +16 -0
  55. package/templates/variants/go-chi/protos/dns/v1/dns.proto +58 -0
  56. package/templates/variants/go-chi/test/go.test.ts +19 -0
  57. package/templates/variants/go-connectrpc/Dockerfile +23 -0
  58. package/templates/variants/go-connectrpc/buf.gen.yaml +10 -0
  59. package/templates/variants/go-connectrpc/buf.yaml +9 -0
  60. package/templates/variants/go-connectrpc/cmd/server/main.go +51 -0
  61. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +623 -0
  62. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  63. package/templates/variants/go-connectrpc/go.mod +10 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +109 -0
  65. package/templates/variants/go-connectrpc/internal/app/token_source.go +50 -0
  66. package/templates/variants/go-connectrpc/internal/cloudflare/client.go +160 -0
  67. package/templates/variants/go-connectrpc/internal/config/config.go +23 -0
  68. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +79 -0
  69. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +93 -0
  70. package/templates/variants/go-connectrpc/internal/vault/client.go +148 -0
  71. package/templates/variants/go-connectrpc/package.json +16 -0
  72. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +58 -0
  73. package/templates/variants/go-connectrpc/test/go.test.ts +19 -0
package/README.md CHANGED
@@ -22,7 +22,7 @@ bun dev
22
22
  bun gen
23
23
  bun lint
24
24
  bun test
25
- bun deploy
25
+ bun run deploy
26
26
  ```
27
27
 
28
28
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Bun-authored CLI to scaffold Go Cloud Run services with Chi, ConnectRPC, Vault, and Cloudflare examples.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -44,6 +44,9 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@clack/prompts": "^1.2.0",
47
+ "@google-cloud/billing": "^5.1.1",
48
+ "@google-cloud/resource-manager": "^6.2.1",
49
+ "@neondatabase/api-client": "^2.7.1",
47
50
  "picocolors": "^1.1.1"
48
51
  }
49
52
  }
@@ -0,0 +1,10 @@
1
+ import { expect, test } from "bun:test";
2
+ import { normalizeValidationResult } from "./cli";
3
+
4
+ test("normalizeValidationResult converts success to undefined", () => {
5
+ expect(normalizeValidationResult(true)).toBeUndefined();
6
+ });
7
+
8
+ test("normalizeValidationResult preserves validation errors", () => {
9
+ expect(normalizeValidationResult("Service name is required")).toBe("Service name is required");
10
+ });
package/src/cli.ts CHANGED
@@ -1,29 +1,60 @@
1
- import { cancel, confirm, intro, isCancel, log, note, outro, spinner, text } from "@clack/prompts";
1
+ import {
2
+ cancel,
3
+ confirm,
4
+ intro,
5
+ isCancel,
6
+ log,
7
+ note,
8
+ outro,
9
+ select,
10
+ spinner,
11
+ text,
12
+ } from "@clack/prompts";
2
13
  import pc from "picocolors";
3
14
  import { basename, dirname, resolve } from "node:path";
4
15
  import { fileURLToPath } from "node:url";
16
+ import { runPostScaffoldFlow } from "./post-scaffold";
17
+ import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
18
+ import { discoverNeonDefaults } from "./neon";
19
+ import {
20
+ BILLING_ACCOUNT_DEFAULT,
21
+ FRAMEWORKS_BY_RUNTIME,
22
+ QUOTA_PROJECT_DEFAULT,
23
+ buildCreateProjectLabel,
24
+ buildGcpProjectOptions,
25
+ deriveDefaults,
26
+ slugify,
27
+ type Framework,
28
+ type GcpProjectMode,
29
+ type Runtime,
30
+ } from "./naming";
5
31
  import { scaffoldProject, type ScaffoldConfig } from "./scaffold";
6
32
 
7
33
  type ParsedArgs = {
8
34
  directory?: string;
9
- modulePath?: string;
10
- projectId?: string;
11
- region?: string;
35
+ runtime?: Runtime;
36
+ framework?: Framework;
37
+ gcpProjectMode?: GcpProjectMode;
38
+ gcpProject?: string;
12
39
  githubRepo?: string;
13
- vaultAddr?: string;
14
- vaultSecretPath?: string;
15
- vaultSecretKey?: string;
16
- cloudflareZoneId?: string;
17
- bufModule?: string;
40
+ region?: string;
41
+ billingAccount?: string;
42
+ quotaProjectId?: string;
43
+ autoDeploy?: boolean;
18
44
  yes: boolean;
19
45
  help: boolean;
20
46
  };
21
47
 
48
+ type DiscoveryState = {
49
+ projects: GcpProject[];
50
+ billingAccounts: BillingAccount[];
51
+ neonProjectId?: string;
52
+ neonBaseBranchId?: string;
53
+ neonBaseBranchName?: string;
54
+ warnings: string[];
55
+ };
56
+
22
57
  const DEFAULT_REGION = "us-west1";
23
- const DEFAULT_VAULT_ADDR = "https://vault.anmho.com";
24
- const DEFAULT_VAULT_SECRET_PATH = "provider/cloudflare-api-token";
25
- const DEFAULT_VAULT_SECRET_KEY = "value";
26
- const DEFAULT_CLOUDFLARE_ZONE_ID = "893c2371cc222826de6e00583f4902ea";
27
58
 
28
59
  export async function run(argv: string[]) {
29
60
  const args = parseArgs(argv);
@@ -32,7 +63,7 @@ export async function run(argv: string[]) {
32
63
  return;
33
64
  }
34
65
 
35
- intro(`${pc.bold("create-service")} ${pc.dim("Cloud Run scaffold")}`);
66
+ intro(`${pc.bold("create-svc")} ${pc.dim("Cloud Run scaffold")}`);
36
67
 
37
68
  const config = await resolveConfig(args);
38
69
  const targetDir = resolve(process.cwd(), config.directory);
@@ -40,8 +71,10 @@ export async function run(argv: string[]) {
40
71
  note(
41
72
  [
42
73
  `${pc.bold("Output")}: ${targetDir}`,
43
- `${pc.bold("Module")}: ${config.modulePath}`,
44
- `${pc.bold("Deploy")}: public Cloud Run service with Vault-backed Cloudflare DNS CRUD`,
74
+ `${pc.bold("Runtime")}: ${config.runtime} + ${config.framework}`,
75
+ `${pc.bold("Project")}: ${config.gcpProjectMode === "create_new" ? "create" : "use"} ${config.gcpProjectName} (${config.gcpProject})`,
76
+ `${pc.bold("GitHub")}: ${config.githubRepo}`,
77
+ `${pc.bold("Neon")}: ${config.neonProjectId || "(set later)"} / ${config.neonBaseBranchName || "(set later)"}`,
45
78
  ].join("\n"),
46
79
  "Scaffold"
47
80
  );
@@ -51,12 +84,26 @@ export async function run(argv: string[]) {
51
84
  await scaffoldProject(config);
52
85
  buildSpinner.stop("Project files generated");
53
86
 
87
+ const shouldRunPostScaffoldFlow = Boolean(process.stdout.isTTY && process.stdin.isTTY && (config.createGithubRepo || config.autoDeploy));
88
+ if (shouldRunPostScaffoldFlow) {
89
+ const automationSpinner = spinner();
90
+ automationSpinner.start("Running post-scaffold automation");
91
+ try {
92
+ const result = await runPostScaffoldFlow(config, targetDir);
93
+ automationSpinner.stop(result.message);
94
+ } catch (error) {
95
+ automationSpinner.stop("Post-scaffold automation skipped");
96
+ log.warn(error instanceof Error ? error.message : String(error));
97
+ }
98
+ }
99
+
54
100
  outro(
55
101
  [
56
102
  `Next: ${pc.cyan(`cd ${config.directory}`)}`,
57
103
  `Local dev: ${pc.cyan("bun dev")}`,
58
- `Generate stubs: ${pc.cyan("bun gen")}`,
59
- `First deploy: set ${pc.cyan("BOOTSTRAP_VAULT_ROLE_ID")} and ${pc.cyan("BOOTSTRAP_VAULT_SECRET_ID")}, then run ${pc.cyan("bun deploy")}`,
104
+ `Bootstrap: ${pc.cyan("bun run bootstrap")}`,
105
+ `Deploy: ${pc.cyan("bun run deploy")}`,
106
+ `Personal env: ${pc.cyan(`bun run deploy -- --environment personal --name ${config.serviceName}`)}`,
60
107
  ].join("\n")
61
108
  );
62
109
  }
@@ -97,93 +144,98 @@ function parseArgs(argv: string[]): ParsedArgs {
97
144
  continue;
98
145
  }
99
146
 
100
- if (token === "--module") {
101
- parsed.modulePath = readValue();
147
+ if (token === "--runtime") {
148
+ parsed.runtime = readValue() as Runtime;
102
149
  continue;
103
150
  }
104
151
 
105
- if (token.startsWith("--module=")) {
106
- parsed.modulePath = token.slice("--module=".length);
152
+ if (token.startsWith("--runtime=")) {
153
+ parsed.runtime = token.slice("--runtime=".length) as Runtime;
107
154
  continue;
108
155
  }
109
156
 
110
- if (token === "--project-id") {
111
- parsed.projectId = readValue();
157
+ if (token === "--framework") {
158
+ parsed.framework = readValue() as Framework;
112
159
  continue;
113
160
  }
114
161
 
115
- if (token.startsWith("--project-id=")) {
116
- parsed.projectId = token.slice("--project-id=".length);
162
+ if (token.startsWith("--framework=")) {
163
+ parsed.framework = token.slice("--framework=".length) as Framework;
117
164
  continue;
118
165
  }
119
166
 
120
- if (token === "--region") {
121
- parsed.region = readValue();
167
+ if (token === "--project-mode") {
168
+ parsed.gcpProjectMode = readValue() as GcpProjectMode;
122
169
  continue;
123
170
  }
124
171
 
125
- if (token.startsWith("--region=")) {
126
- parsed.region = token.slice("--region=".length);
172
+ if (token.startsWith("--project-mode=")) {
173
+ parsed.gcpProjectMode = token.slice("--project-mode=".length) as GcpProjectMode;
127
174
  continue;
128
175
  }
129
176
 
130
- if (token === "--github-repo") {
131
- parsed.githubRepo = readValue();
177
+ if (token === "--project-id" || token === "--gcp-project") {
178
+ parsed.gcpProject = readValue();
132
179
  continue;
133
180
  }
134
181
 
135
- if (token.startsWith("--github-repo=")) {
136
- parsed.githubRepo = token.slice("--github-repo=".length);
182
+ if (token.startsWith("--project-id=")) {
183
+ parsed.gcpProject = token.slice("--project-id=".length);
137
184
  continue;
138
185
  }
139
186
 
140
- if (token === "--vault-addr") {
141
- parsed.vaultAddr = readValue();
187
+ if (token.startsWith("--gcp-project=")) {
188
+ parsed.gcpProject = token.slice("--gcp-project=".length);
142
189
  continue;
143
190
  }
144
191
 
145
- if (token.startsWith("--vault-addr=")) {
146
- parsed.vaultAddr = token.slice("--vault-addr=".length);
192
+ if (token === "--github-repo") {
193
+ parsed.githubRepo = readValue();
147
194
  continue;
148
195
  }
149
196
 
150
- if (token === "--vault-secret-path") {
151
- parsed.vaultSecretPath = readValue();
197
+ if (token.startsWith("--github-repo=")) {
198
+ parsed.githubRepo = token.slice("--github-repo=".length);
152
199
  continue;
153
200
  }
154
201
 
155
- if (token.startsWith("--vault-secret-path=")) {
156
- parsed.vaultSecretPath = token.slice("--vault-secret-path=".length);
202
+ if (token === "--region") {
203
+ parsed.region = readValue();
157
204
  continue;
158
205
  }
159
206
 
160
- if (token === "--vault-secret-key") {
161
- parsed.vaultSecretKey = readValue();
207
+ if (token.startsWith("--region=")) {
208
+ parsed.region = token.slice("--region=".length);
162
209
  continue;
163
210
  }
164
211
 
165
- if (token.startsWith("--vault-secret-key=")) {
166
- parsed.vaultSecretKey = token.slice("--vault-secret-key=".length);
212
+ if (token === "--billing-account") {
213
+ parsed.billingAccount = readValue();
167
214
  continue;
168
215
  }
169
216
 
170
- if (token === "--cloudflare-zone-id") {
171
- parsed.cloudflareZoneId = readValue();
217
+ if (token.startsWith("--billing-account=")) {
218
+ parsed.billingAccount = token.slice("--billing-account=".length);
172
219
  continue;
173
220
  }
174
221
 
175
- if (token.startsWith("--cloudflare-zone-id=")) {
176
- parsed.cloudflareZoneId = token.slice("--cloudflare-zone-id=".length);
222
+ if (token === "--quota-project") {
223
+ parsed.quotaProjectId = readValue();
177
224
  continue;
178
225
  }
179
226
 
180
- if (token === "--buf-module") {
181
- parsed.bufModule = readValue();
227
+ if (token.startsWith("--quota-project=")) {
228
+ parsed.quotaProjectId = token.slice("--quota-project=".length);
182
229
  continue;
183
230
  }
184
231
 
185
- if (token.startsWith("--buf-module=")) {
186
- parsed.bufModule = token.slice("--buf-module=".length);
232
+ if (token === "--auto-deploy") {
233
+ parsed.autoDeploy = true;
234
+ continue;
235
+ }
236
+
237
+ if (token === "--no-auto-deploy") {
238
+ parsed.autoDeploy = false;
187
239
  continue;
188
240
  }
189
241
 
@@ -193,36 +245,22 @@ function parseArgs(argv: string[]): ParsedArgs {
193
245
  return parsed;
194
246
  }
195
247
 
196
- async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
197
- const inferredName = slugify(basename(args.directory ?? "dns-api"));
248
+ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
249
+ const inferredName = slugify(basename(args.directory ?? "my-service"));
198
250
  const serviceName = args.yes
199
251
  ? inferredName
200
252
  : await promptText("Service name", inferredName, (value) => slugify(value).length > 0 || "Service name is required");
201
253
 
202
- const directory = args.directory ?? serviceName;
203
- const githubRepo = args.githubRepo ?? `anmho/${serviceName}`;
204
- const modulePath = args.modulePath ?? `github.com/${githubRepo}`;
205
- const projectId = args.projectId ?? (args.yes ? "my-gcp-project" : "");
254
+ const defaults = deriveDefaults(serviceName);
255
+ const discovery = await discoverCloudInputs(serviceName);
256
+ const runtime = await resolveRuntime(args);
257
+ const framework = await resolveFramework(args, runtime);
258
+ const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
259
+ const githubRepo = args.githubRepo ?? defaults.githubRepo;
206
260
  const region = args.region ?? DEFAULT_REGION;
207
- const vaultAddr = args.vaultAddr ?? DEFAULT_VAULT_ADDR;
208
- const vaultSecretPath = args.vaultSecretPath ?? DEFAULT_VAULT_SECRET_PATH;
209
- const vaultSecretKey = args.vaultSecretKey ?? DEFAULT_VAULT_SECRET_KEY;
210
- const cloudflareZoneId = args.cloudflareZoneId ?? DEFAULT_CLOUDFLARE_ZONE_ID;
211
- const bufModule = args.bufModule ?? `buf.build/${githubRepo}`;
212
-
213
- const confirmedProjectId = projectId || (await promptText("GCP project ID", "my-gcp-project", (value) => value.trim().length > 0 || "Project ID is required"));
214
- const confirmedModulePath = args.yes
215
- ? modulePath
216
- : await promptText("Go module path", modulePath, (value) => value.trim().length > 0 || "Module path is required");
217
- const confirmedGithubRepo = args.yes
218
- ? githubRepo
219
- : await promptText("GitHub repo", githubRepo, (value) => value.includes("/") || "Use owner/repo format");
220
- const confirmedRegion = args.yes
221
- ? region
222
- : await promptText("Cloud Run region", region, (value) => value.trim().length > 0 || "Region is required");
223
- const confirmedBufModule = args.yes
224
- ? bufModule
225
- : await promptText("Buf module", bufModule, (value) => value.trim().length > 0 || "Buf module is required");
261
+ const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
262
+ const autoDeploy = resolveAutoDeploy(args.autoDeploy);
263
+ const directory = args.directory ?? serviceName;
226
264
 
227
265
  if (!args.yes) {
228
266
  const okay = await confirm({
@@ -235,22 +273,208 @@ async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
235
273
  }
236
274
  }
237
275
 
276
+ for (const warning of discovery.warnings) {
277
+ log.warn(warning);
278
+ }
279
+
238
280
  return {
239
281
  directory,
240
282
  serviceName,
241
- modulePath: confirmedModulePath,
242
- projectId: confirmedProjectId,
243
- region: confirmedRegion,
244
- githubRepo: confirmedGithubRepo,
245
- vaultAddr,
246
- vaultSecretPath,
247
- vaultSecretKey,
248
- cloudflareZoneId,
249
- bufModule: confirmedBufModule,
283
+ runtime,
284
+ framework,
285
+ region,
286
+ gcpProjectMode: gcpSelection.mode,
287
+ gcpProject: gcpSelection.projectId,
288
+ gcpProjectName: gcpSelection.projectName,
289
+ billingAccount,
290
+ quotaProjectId: args.quotaProjectId ?? QUOTA_PROJECT_DEFAULT,
291
+ githubRepo,
292
+ githubVisibility: "public",
293
+ createGithubRepo: true,
294
+ autoDeploy,
295
+ neonProjectId: discovery.neonProjectId ?? "",
296
+ neonBaseBranchId: discovery.neonBaseBranchId ?? "",
297
+ neonBaseBranchName: discovery.neonBaseBranchName ?? "main",
298
+ neonDatabaseName: defaults.neonDatabaseName,
250
299
  generatorRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
251
300
  };
252
301
  }
253
302
 
303
+ async function resolveRuntime(args: ParsedArgs): Promise<Runtime> {
304
+ if (args.runtime) {
305
+ return args.runtime;
306
+ }
307
+
308
+ if (args.yes) {
309
+ return "go";
310
+ }
311
+
312
+ const value = await select({
313
+ message: "Runtime",
314
+ initialValue: "go",
315
+ options: [
316
+ { value: "go", label: "Go", hint: "Default" },
317
+ { value: "bun", label: "Bun" },
318
+ ],
319
+ });
320
+
321
+ if (isCancel(value)) {
322
+ cancel("Aborted");
323
+ process.exit(1);
324
+ }
325
+
326
+ return value;
327
+ }
328
+
329
+ async function resolveFramework(args: ParsedArgs, runtime: Runtime): Promise<Framework> {
330
+ const allowed = FRAMEWORKS_BY_RUNTIME[runtime];
331
+ if (args.framework) {
332
+ if (allowed.includes(args.framework)) {
333
+ return args.framework;
334
+ }
335
+ throw new Error(`Framework ${args.framework} is not valid for runtime ${runtime}`);
336
+ }
337
+
338
+ if (args.yes) {
339
+ return allowed[0];
340
+ }
341
+
342
+ const value = await select({
343
+ message: "Framework",
344
+ initialValue: allowed[0],
345
+ options: allowed.map((framework, index) => ({
346
+ value: framework,
347
+ label: framework,
348
+ hint: index === 0 ? "Default" : undefined,
349
+ })),
350
+ });
351
+
352
+ if (isCancel(value)) {
353
+ cancel("Aborted");
354
+ process.exit(1);
355
+ }
356
+
357
+ return value;
358
+ }
359
+
360
+ async function resolveGcpSelection(
361
+ args: ParsedArgs,
362
+ defaults: ReturnType<typeof deriveDefaults>,
363
+ discovery: DiscoveryState
364
+ ) {
365
+ const options = buildGcpProjectOptions(defaults.serviceName, defaults.projectId, defaults.projectName, discovery.projects);
366
+
367
+ if (args.gcpProjectMode && args.gcpProject) {
368
+ return {
369
+ mode: args.gcpProjectMode,
370
+ projectId: args.gcpProject,
371
+ projectName: args.gcpProjectMode === "create_new" ? defaults.projectName : args.gcpProject,
372
+ };
373
+ }
374
+
375
+ if (args.gcpProjectMode === "create_new") {
376
+ return {
377
+ mode: "create_new" as const,
378
+ projectId: args.gcpProject ?? defaults.projectId,
379
+ projectName: defaults.projectName,
380
+ };
381
+ }
382
+
383
+ if (args.gcpProjectMode === "use_existing") {
384
+ const existing = discovery.projects.find((project) => project.projectId === args.gcpProject);
385
+ return {
386
+ mode: "use_existing" as const,
387
+ projectId: args.gcpProject ?? discovery.projects[0]?.projectId ?? defaults.projectId,
388
+ projectName: existing?.name ?? args.gcpProject ?? defaults.projectName,
389
+ };
390
+ }
391
+
392
+ if (args.yes) {
393
+ return {
394
+ mode: "create_new" as const,
395
+ projectId: defaults.projectId,
396
+ projectName: defaults.projectName,
397
+ };
398
+ }
399
+
400
+ const value = await select({
401
+ message: "GCP project",
402
+ initialValue: buildCreateProjectLabel(defaults.serviceName, defaults.projectId),
403
+ options: options.map((option) => ({
404
+ value: option.label,
405
+ label: option.label,
406
+ hint: option.mode === "create_new" ? "Default" : undefined,
407
+ })),
408
+ });
409
+
410
+ if (isCancel(value)) {
411
+ cancel("Aborted");
412
+ process.exit(1);
413
+ }
414
+
415
+ const selected = options.find((option) => option.label === value);
416
+ if (!selected) {
417
+ throw new Error(`Unknown GCP project selection: ${value}`);
418
+ }
419
+
420
+ return {
421
+ mode: selected.mode,
422
+ projectId: selected.projectId,
423
+ projectName: selected.projectName,
424
+ };
425
+ }
426
+
427
+ async function discoverCloudInputs(serviceName: string): Promise<DiscoveryState> {
428
+ const result: DiscoveryState = {
429
+ projects: [],
430
+ billingAccounts: [],
431
+ warnings: [],
432
+ };
433
+
434
+ try {
435
+ result.projects = await listAccessibleProjects();
436
+ } catch (error) {
437
+ result.warnings.push(`Skipping GCP project discovery: ${formatError(error)}`);
438
+ }
439
+
440
+ try {
441
+ result.billingAccounts = await listOpenBillingAccounts();
442
+ } catch (error) {
443
+ result.warnings.push(`Skipping billing account discovery: ${formatError(error)}`);
444
+ }
445
+
446
+ try {
447
+ const neonDefaults = await discoverNeonDefaults(serviceName);
448
+ result.neonProjectId = neonDefaults.projectId;
449
+ result.neonBaseBranchId = neonDefaults.baseBranchId;
450
+ result.neonBaseBranchName = neonDefaults.baseBranchName;
451
+ } catch (error) {
452
+ result.warnings.push(`Skipping Neon discovery: ${formatError(error)}`);
453
+ }
454
+
455
+ return result;
456
+ }
457
+
458
+ function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
459
+ if (input) {
460
+ return input;
461
+ }
462
+
463
+ const preferred = accounts.find((account) => account.name === BILLING_ACCOUNT_DEFAULT);
464
+ if (preferred) {
465
+ return preferred.name;
466
+ }
467
+
468
+ return accounts[0]?.name ?? BILLING_ACCOUNT_DEFAULT;
469
+ }
470
+
471
+ function resolveAutoDeploy(value: boolean | undefined) {
472
+ if (value !== undefined) {
473
+ return value;
474
+ }
475
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
476
+ }
477
+
254
478
  async function promptText(
255
479
  message: string,
256
480
  initialValue: string,
@@ -259,7 +483,7 @@ async function promptText(
259
483
  const value = await text({
260
484
  message,
261
485
  initialValue,
262
- validate: (input) => validate(input.trim()),
486
+ validate: (input) => normalizeValidationResult(validate(input.trim())),
263
487
  });
264
488
 
265
489
  if (isCancel(value)) {
@@ -270,13 +494,12 @@ async function promptText(
270
494
  return value.trim();
271
495
  }
272
496
 
273
- function slugify(value: string): string {
274
- return value
275
- .trim()
276
- .toLowerCase()
277
- .replace(/[^a-z0-9]+/g, "-")
278
- .replace(/^-+|-+$/g, "")
279
- .slice(0, 63);
497
+ function formatError(error: unknown) {
498
+ return error instanceof Error ? error.message : String(error);
499
+ }
500
+
501
+ export function normalizeValidationResult(result: true | string): string | undefined {
502
+ return result === true ? undefined : result;
280
503
  }
281
504
 
282
505
  function printHelp() {
@@ -285,16 +508,17 @@ Usage:
285
508
  bun run index.ts [directory] [options]
286
509
 
287
510
  Options:
288
- --module <path> Go module path
289
- --project-id <id> GCP project ID
290
- --region <region> Cloud Run region
291
- --github-repo <owner/repo> GitHub repository
292
- --vault-addr <url> Vault address
293
- --vault-secret-path <path> Vault KV secret path
294
- --vault-secret-key <key> Vault KV secret key
295
- --cloudflare-zone-id <id> Cloudflare zone ID
296
- --buf-module <module> Buf module name
297
- --yes, -y Accept defaults without prompts
298
- --help, -h Show this message
511
+ --runtime <go|bun> Runtime scaffold to generate
512
+ --framework <name> Framework for the selected runtime
513
+ --project-mode <mode> create_new or use_existing
514
+ --project-id <id> GCP project id
515
+ --github-repo <owner/repo> GitHub repository
516
+ --billing-account <name> Billing account resource name
517
+ --quota-project <id> Billing quota project for gcloud calls
518
+ --region <region> Cloud Run region
519
+ --auto-deploy Run bootstrap and first deploy after scaffold
520
+ --no-auto-deploy Scaffold only
521
+ --yes, -y Accept defaults without prompts
522
+ --help, -h Show this message
299
523
  `);
300
524
  }