create-svc 0.1.2 → 0.1.4
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/package.json +6 -3
- package/src/cli.ts +328 -108
- package/src/gcp.test.ts +71 -0
- package/src/gcp.ts +97 -0
- package/src/naming.test.ts +37 -0
- package/src/naming.ts +103 -0
- package/src/neon.test.ts +48 -0
- package/src/neon.ts +76 -0
- package/src/post-scaffold.ts +77 -0
- package/src/scaffold.test.ts +66 -31
- package/src/scaffold.ts +60 -55
- package/templates/shared/.github/workflows/ci.yml +22 -0
- package/templates/shared/.github/workflows/deploy.yml +30 -0
- package/templates/shared/.github/workflows/personal.yml +41 -0
- package/templates/shared/.github/workflows/preview-cleanup.yml +25 -0
- package/templates/shared/.github/workflows/preview.yml +29 -0
- package/templates/shared/README.md +37 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +76 -0
- package/templates/shared/scripts/cloudrun/config.ts +57 -0
- package/templates/shared/scripts/cloudrun/deploy.ts +82 -0
- package/templates/shared/scripts/cloudrun/lib.ts +380 -0
- package/templates/shared/scripts/cloudrun/neon.ts +104 -0
- package/templates/shared/service.yaml +28 -0
- package/templates/variants/bun-connectrpc/Dockerfile +13 -0
- package/templates/variants/bun-connectrpc/package.json +20 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -0
- package/templates/variants/bun-connectrpc/src/index.ts +32 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +17 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +10 -0
- package/templates/variants/bun-hono/Dockerfile +13 -0
- package/templates/variants/bun-hono/package.json +21 -0
- package/templates/variants/bun-hono/scripts/codegen.ts +1 -0
- package/templates/variants/bun-hono/src/index.ts +24 -0
- package/templates/variants/bun-hono/test/app.test.ts +12 -0
- package/templates/variants/bun-hono/tsconfig.json +10 -0
- package/templates/variants/go-chi/Dockerfile +23 -0
- package/templates/variants/go-chi/buf.gen.yaml +10 -0
- package/templates/variants/go-chi/buf.yaml +9 -0
- package/templates/variants/go-chi/cmd/server/main.go +52 -0
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +623 -0
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
- package/templates/variants/go-chi/go.mod +10 -0
- package/templates/variants/go-chi/internal/app/service.go +109 -0
- package/templates/variants/go-chi/internal/app/token_source.go +50 -0
- package/templates/variants/go-chi/internal/cloudflare/client.go +160 -0
- package/templates/variants/go-chi/internal/config/config.go +23 -0
- package/templates/variants/go-chi/internal/connectapi/handler.go +79 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +93 -0
- package/templates/variants/go-chi/internal/vault/client.go +148 -0
- package/templates/variants/go-chi/package.json +16 -0
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +58 -0
- package/templates/variants/go-chi/test/go.test.ts +19 -0
- package/templates/variants/go-connectrpc/Dockerfile +23 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +10 -0
- package/templates/variants/go-connectrpc/buf.yaml +9 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +51 -0
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +623 -0
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
- package/templates/variants/go-connectrpc/go.mod +10 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +109 -0
- package/templates/variants/go-connectrpc/internal/app/token_source.go +50 -0
- package/templates/variants/go-connectrpc/internal/cloudflare/client.go +160 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +23 -0
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +79 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +93 -0
- package/templates/variants/go-connectrpc/internal/vault/client.go +148 -0
- package/templates/variants/go-connectrpc/package.json +16 -0
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +58 -0
- package/templates/variants/go-connectrpc/test/go.test.ts +19 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-svc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"bin": {
|
|
9
|
-
"create-svc": "
|
|
10
|
-
"create-service": "
|
|
9
|
+
"create-svc": "bin/create-svc.mjs",
|
|
10
|
+
"create-service": "bin/create-svc.mjs"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
|
@@ -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
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,29 +1,60 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
35
|
+
runtime?: Runtime;
|
|
36
|
+
framework?: Framework;
|
|
37
|
+
gcpProjectMode?: GcpProjectMode;
|
|
38
|
+
gcpProject?: string;
|
|
12
39
|
githubRepo?: string;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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-
|
|
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("
|
|
44
|
-
`${pc.bold("
|
|
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
|
-
`
|
|
59
|
-
`
|
|
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 === "--
|
|
101
|
-
parsed.
|
|
147
|
+
if (token === "--runtime") {
|
|
148
|
+
parsed.runtime = readValue() as Runtime;
|
|
102
149
|
continue;
|
|
103
150
|
}
|
|
104
151
|
|
|
105
|
-
if (token.startsWith("--
|
|
106
|
-
parsed.
|
|
152
|
+
if (token.startsWith("--runtime=")) {
|
|
153
|
+
parsed.runtime = token.slice("--runtime=".length) as Runtime;
|
|
107
154
|
continue;
|
|
108
155
|
}
|
|
109
156
|
|
|
110
|
-
if (token === "--
|
|
111
|
-
parsed.
|
|
157
|
+
if (token === "--framework") {
|
|
158
|
+
parsed.framework = readValue() as Framework;
|
|
112
159
|
continue;
|
|
113
160
|
}
|
|
114
161
|
|
|
115
|
-
if (token.startsWith("--
|
|
116
|
-
parsed.
|
|
162
|
+
if (token.startsWith("--framework=")) {
|
|
163
|
+
parsed.framework = token.slice("--framework=".length) as Framework;
|
|
117
164
|
continue;
|
|
118
165
|
}
|
|
119
166
|
|
|
120
|
-
if (token === "--
|
|
121
|
-
parsed.
|
|
167
|
+
if (token === "--project-mode") {
|
|
168
|
+
parsed.gcpProjectMode = readValue() as GcpProjectMode;
|
|
122
169
|
continue;
|
|
123
170
|
}
|
|
124
171
|
|
|
125
|
-
if (token.startsWith("--
|
|
126
|
-
parsed.
|
|
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 === "--
|
|
131
|
-
parsed.
|
|
177
|
+
if (token === "--project-id" || token === "--gcp-project") {
|
|
178
|
+
parsed.gcpProject = readValue();
|
|
132
179
|
continue;
|
|
133
180
|
}
|
|
134
181
|
|
|
135
|
-
if (token.startsWith("--
|
|
136
|
-
parsed.
|
|
182
|
+
if (token.startsWith("--project-id=")) {
|
|
183
|
+
parsed.gcpProject = token.slice("--project-id=".length);
|
|
137
184
|
continue;
|
|
138
185
|
}
|
|
139
186
|
|
|
140
|
-
if (token
|
|
141
|
-
parsed.
|
|
187
|
+
if (token.startsWith("--gcp-project=")) {
|
|
188
|
+
parsed.gcpProject = token.slice("--gcp-project=".length);
|
|
142
189
|
continue;
|
|
143
190
|
}
|
|
144
191
|
|
|
145
|
-
if (token
|
|
146
|
-
parsed.
|
|
192
|
+
if (token === "--github-repo") {
|
|
193
|
+
parsed.githubRepo = readValue();
|
|
147
194
|
continue;
|
|
148
195
|
}
|
|
149
196
|
|
|
150
|
-
if (token
|
|
151
|
-
parsed.
|
|
197
|
+
if (token.startsWith("--github-repo=")) {
|
|
198
|
+
parsed.githubRepo = token.slice("--github-repo=".length);
|
|
152
199
|
continue;
|
|
153
200
|
}
|
|
154
201
|
|
|
155
|
-
if (token
|
|
156
|
-
parsed.
|
|
202
|
+
if (token === "--region") {
|
|
203
|
+
parsed.region = readValue();
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (token.startsWith("--region=")) {
|
|
208
|
+
parsed.region = token.slice("--region=".length);
|
|
157
209
|
continue;
|
|
158
210
|
}
|
|
159
211
|
|
|
160
|
-
if (token === "--
|
|
161
|
-
parsed.
|
|
212
|
+
if (token === "--billing-account") {
|
|
213
|
+
parsed.billingAccount = readValue();
|
|
162
214
|
continue;
|
|
163
215
|
}
|
|
164
216
|
|
|
165
|
-
if (token.startsWith("--
|
|
166
|
-
parsed.
|
|
217
|
+
if (token.startsWith("--billing-account=")) {
|
|
218
|
+
parsed.billingAccount = token.slice("--billing-account=".length);
|
|
167
219
|
continue;
|
|
168
220
|
}
|
|
169
221
|
|
|
170
|
-
if (token === "--
|
|
171
|
-
parsed.
|
|
222
|
+
if (token === "--quota-project") {
|
|
223
|
+
parsed.quotaProjectId = readValue();
|
|
172
224
|
continue;
|
|
173
225
|
}
|
|
174
226
|
|
|
175
|
-
if (token.startsWith("--
|
|
176
|
-
parsed.
|
|
227
|
+
if (token.startsWith("--quota-project=")) {
|
|
228
|
+
parsed.quotaProjectId = token.slice("--quota-project=".length);
|
|
177
229
|
continue;
|
|
178
230
|
}
|
|
179
231
|
|
|
180
|
-
if (token === "--
|
|
181
|
-
parsed.
|
|
232
|
+
if (token === "--auto-deploy") {
|
|
233
|
+
parsed.autoDeploy = true;
|
|
182
234
|
continue;
|
|
183
235
|
}
|
|
184
236
|
|
|
185
|
-
if (token
|
|
186
|
-
parsed.
|
|
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 ?? "
|
|
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
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
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
|
|
208
|
-
const
|
|
209
|
-
const
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
region
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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,
|
|
@@ -270,17 +494,12 @@ async function promptText(
|
|
|
270
494
|
return value.trim();
|
|
271
495
|
}
|
|
272
496
|
|
|
273
|
-
|
|
274
|
-
return
|
|
497
|
+
function formatError(error: unknown) {
|
|
498
|
+
return error instanceof Error ? error.message : String(error);
|
|
275
499
|
}
|
|
276
500
|
|
|
277
|
-
function
|
|
278
|
-
return
|
|
279
|
-
.trim()
|
|
280
|
-
.toLowerCase()
|
|
281
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
282
|
-
.replace(/^-+|-+$/g, "")
|
|
283
|
-
.slice(0, 63);
|
|
501
|
+
export function normalizeValidationResult(result: true | string): string | undefined {
|
|
502
|
+
return result === true ? undefined : result;
|
|
284
503
|
}
|
|
285
504
|
|
|
286
505
|
function printHelp() {
|
|
@@ -289,16 +508,17 @@ Usage:
|
|
|
289
508
|
bun run index.ts [directory] [options]
|
|
290
509
|
|
|
291
510
|
Options:
|
|
292
|
-
--
|
|
293
|
-
--
|
|
294
|
-
--
|
|
295
|
-
--
|
|
296
|
-
--
|
|
297
|
-
--
|
|
298
|
-
--
|
|
299
|
-
--
|
|
300
|
-
--
|
|
301
|
-
--
|
|
302
|
-
--
|
|
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
|
|
303
523
|
`);
|
|
304
524
|
}
|
package/src/gcp.test.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { attachBillingAccount, createProject, listAccessibleProjects, listOpenBillingAccounts, type GcpApi } from "./gcp";
|
|
3
|
+
|
|
4
|
+
test("listAccessibleProjects filters deleted projects and sorts by name", async () => {
|
|
5
|
+
const api: GcpApi = {
|
|
6
|
+
async listProjects() {
|
|
7
|
+
return [
|
|
8
|
+
{ projectId: "b", name: "bravo" },
|
|
9
|
+
{ projectId: "a", name: "alpha" },
|
|
10
|
+
{ projectId: "z", name: "zulu", lifecycleState: "DELETE_REQUESTED" },
|
|
11
|
+
];
|
|
12
|
+
},
|
|
13
|
+
async listBillingAccounts() {
|
|
14
|
+
return [];
|
|
15
|
+
},
|
|
16
|
+
async createProject() {},
|
|
17
|
+
async attachBillingAccount() {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
await expect(listAccessibleProjects(api)).resolves.toEqual([
|
|
21
|
+
{ projectId: "a", name: "alpha" },
|
|
22
|
+
{ projectId: "b", name: "bravo" },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("listOpenBillingAccounts keeps only open accounts", async () => {
|
|
27
|
+
const api: GcpApi = {
|
|
28
|
+
async listProjects() {
|
|
29
|
+
return [];
|
|
30
|
+
},
|
|
31
|
+
async listBillingAccounts() {
|
|
32
|
+
return [
|
|
33
|
+
{ name: "billingAccounts/2", displayName: "B", open: true },
|
|
34
|
+
{ name: "billingAccounts/1", displayName: "A", open: true },
|
|
35
|
+
{ name: "billingAccounts/closed", displayName: "Z", open: false },
|
|
36
|
+
];
|
|
37
|
+
},
|
|
38
|
+
async createProject() {},
|
|
39
|
+
async attachBillingAccount() {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
await expect(listOpenBillingAccounts(api)).resolves.toEqual([
|
|
43
|
+
{ name: "billingAccounts/1", displayName: "A", open: true },
|
|
44
|
+
{ name: "billingAccounts/2", displayName: "B", open: true },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("createProject and attachBillingAccount call the expected endpoints", async () => {
|
|
49
|
+
const calls: string[] = [];
|
|
50
|
+
const api: GcpApi = {
|
|
51
|
+
async listProjects() {
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
async listBillingAccounts() {
|
|
55
|
+
return [];
|
|
56
|
+
},
|
|
57
|
+
async createProject(projectId, name) {
|
|
58
|
+
calls.push(`create:${projectId}:${name}`);
|
|
59
|
+
},
|
|
60
|
+
async attachBillingAccount(projectId, billingAccountName) {
|
|
61
|
+
calls.push(`billing:${projectId}:${billingAccountName}`);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await createProject("anmho-test", "test", api);
|
|
66
|
+
await attachBillingAccount("anmho-test", "billingAccounts/123", api);
|
|
67
|
+
|
|
68
|
+
expect(calls).toHaveLength(2);
|
|
69
|
+
expect(calls[0]).toBe("create:anmho-test:test");
|
|
70
|
+
expect(calls[1]).toBe("billing:anmho-test:billingAccounts/123");
|
|
71
|
+
});
|