create-svc 0.1.13 → 0.1.15
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 +19 -6
- package/package.json +1 -3
- package/src/cli.test.ts +31 -2
- package/src/cli.ts +498 -159
- package/src/gcp.ts +35 -44
- package/src/git-bootstrap.ts +14 -11
- package/src/naming.test.ts +1 -1
- package/src/naming.ts +1 -1
- package/src/post-scaffold.test.ts +17 -1
- package/src/post-scaffold.ts +24 -3
- package/src/scaffold.test.ts +12 -5
- package/src/service.test.ts +12 -1
- package/src/service.ts +26 -0
- package/templates/shared/scripts/authctl.ts +17 -1
- package/templates/shared/scripts/cloudrun/cli.ts +21 -2
- package/templates/targets/workers/scripts/workers/cli.ts +20 -2
- package/templates/variants/go-chi/package.json +9 -1
- package/templates/variants/go-connectrpc/package.json +9 -1
package/src/cli.ts
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
autocomplete,
|
|
3
|
-
cancel,
|
|
4
|
-
confirm,
|
|
5
|
-
intro,
|
|
6
|
-
isCancel,
|
|
7
|
-
log,
|
|
8
|
-
note,
|
|
9
|
-
outro,
|
|
10
|
-
select,
|
|
11
|
-
spinner,
|
|
12
|
-
text,
|
|
13
|
-
} from "@clack/prompts";
|
|
1
|
+
import { autocomplete, cancel, intro, isCancel, log, note, outro, select, spinner, text } from "@clack/prompts";
|
|
14
2
|
import pc from "picocolors";
|
|
15
3
|
import { readdirSync } from "node:fs";
|
|
16
4
|
import { basename, dirname, resolve } from "node:path";
|
|
17
5
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import { runPostScaffoldFlow } from "./post-scaffold";
|
|
19
|
-
import {
|
|
6
|
+
import { buildDeploymentVerificationCommands, runPostScaffoldFlow } from "./post-scaffold";
|
|
7
|
+
import {
|
|
8
|
+
bootstrapGitHubRepository,
|
|
9
|
+
buildGitBootstrapConfig,
|
|
10
|
+
commitAndPushGeneratedArtifacts,
|
|
11
|
+
type GitBootstrapResult,
|
|
12
|
+
} from "./git-bootstrap";
|
|
20
13
|
import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
|
|
21
14
|
import {
|
|
22
15
|
BILLING_ACCOUNT_DEFAULT,
|
|
@@ -39,6 +32,7 @@ import {
|
|
|
39
32
|
} from "./scaffold";
|
|
40
33
|
|
|
41
34
|
type ParsedArgs = {
|
|
35
|
+
serviceName?: string;
|
|
42
36
|
directory?: string;
|
|
43
37
|
target?: DeployTarget;
|
|
44
38
|
runtime?: Runtime;
|
|
@@ -50,8 +44,6 @@ type ParsedArgs = {
|
|
|
50
44
|
billingAccount?: string;
|
|
51
45
|
quotaProjectId?: string;
|
|
52
46
|
autoDeploy?: boolean;
|
|
53
|
-
autoUpdate?: boolean;
|
|
54
|
-
noUpdateCheck?: boolean;
|
|
55
47
|
noGit?: boolean;
|
|
56
48
|
profile: Profile;
|
|
57
49
|
yes: boolean;
|
|
@@ -64,7 +56,25 @@ type DiscoveryState = {
|
|
|
64
56
|
warnings: string[];
|
|
65
57
|
};
|
|
66
58
|
|
|
59
|
+
type GcpSelection = {
|
|
60
|
+
mode: GcpProjectMode;
|
|
61
|
+
projectId: string;
|
|
62
|
+
projectName: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type InteractiveStep = "serviceName" | "target" | "runtime" | "framework" | "modulePath" | "gcp" | "confirm";
|
|
66
|
+
|
|
67
|
+
type InteractiveState = {
|
|
68
|
+
serviceName?: string;
|
|
69
|
+
target?: DeployTarget;
|
|
70
|
+
runtime?: Runtime;
|
|
71
|
+
framework?: Framework;
|
|
72
|
+
modulePath?: string;
|
|
73
|
+
gcpSelection?: GcpSelection;
|
|
74
|
+
};
|
|
75
|
+
|
|
67
76
|
const DEFAULT_REGION = "us-west1";
|
|
77
|
+
const BACK = "__back__" as const;
|
|
68
78
|
|
|
69
79
|
export async function run(argv: string[]) {
|
|
70
80
|
try {
|
|
@@ -74,8 +84,6 @@ export async function run(argv: string[]) {
|
|
|
74
84
|
return;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
await maybeCheckForUpdate(args);
|
|
78
|
-
|
|
79
87
|
intro(`${pc.bold("service")} ${pc.dim("microservice bootstrap")}`);
|
|
80
88
|
|
|
81
89
|
const config = await resolveConfig(args);
|
|
@@ -130,27 +138,75 @@ export async function run(argv: string[]) {
|
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
[
|
|
136
|
-
`Next: ${pc.cyan(`cd ${config.directory}`)}`,
|
|
137
|
-
`Local DB: ${pc.cyan("started by local dev command")}`,
|
|
138
|
-
`Migrate: ${pc.cyan(isBun ? "bun run migrate" : "make migrate")}`,
|
|
139
|
-
`Local dev: ${pc.cyan(isBun ? "bun run dev" : "make dev")}`,
|
|
140
|
-
`Create: ${pc.cyan("service create")}`,
|
|
141
|
-
`Deploy: ${pc.cyan("service deploy")}`,
|
|
142
|
-
config.git.enabled ? `Repository: ${pc.cyan(`https://github.com/anmho/${config.git.repository}`)}` : undefined,
|
|
143
|
-
`Personal env: ${pc.cyan(
|
|
144
|
-
`service deploy --environment personal --name ${config.serviceName}`
|
|
145
|
-
)}`,
|
|
146
|
-
`Production API: ${pc.cyan(`https://${config.apiHostname}`)}`,
|
|
147
|
-
].filter(Boolean).join("\n")
|
|
148
|
-
);
|
|
141
|
+
outro(config.autoDeploy ? "Created and deployed" : "Created");
|
|
142
|
+
console.log(formatCompletionSummary(config, targetDir, gitResult));
|
|
149
143
|
} catch (error) {
|
|
150
144
|
handleCliError(error);
|
|
151
145
|
}
|
|
152
146
|
}
|
|
153
147
|
|
|
148
|
+
function formatCompletionSummary(config: ScaffoldConfig, targetDir: string, gitResult: GitBootstrapResult) {
|
|
149
|
+
const isBun = config.runtime === "bun";
|
|
150
|
+
const devCommand = isBun ? "bun run dev" : "make dev";
|
|
151
|
+
const migrateCommand = isBun ? "bun run migrate" : "make migrate";
|
|
152
|
+
const lifecycleCommands: Array<[string, string]> = config.autoDeploy
|
|
153
|
+
? [
|
|
154
|
+
["service deploy", "Deploys later changes."],
|
|
155
|
+
[`service deploy --environment personal --name ${config.serviceName}`, "Deploys your personal environment."],
|
|
156
|
+
]
|
|
157
|
+
: [
|
|
158
|
+
["service create", "Provisions auth, database, migrations, and the first deploy."],
|
|
159
|
+
["service deploy", "Deploys later changes."],
|
|
160
|
+
];
|
|
161
|
+
const repository =
|
|
162
|
+
gitResult.status === "created"
|
|
163
|
+
? gitResult.url
|
|
164
|
+
: config.git.enabled
|
|
165
|
+
? `https://github.com/${config.git.owner}/${config.git.repository}`
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
"",
|
|
170
|
+
`Success! Created ${config.serviceName} at ${targetDir}`,
|
|
171
|
+
"",
|
|
172
|
+
"Inside that directory, you can run:",
|
|
173
|
+
formatCommand(devCommand, "Starts local development."),
|
|
174
|
+
formatCommand(migrateCommand, "Applies local database migrations."),
|
|
175
|
+
...lifecycleCommands.map(([command, description]) => formatCommand(command, description)),
|
|
176
|
+
"",
|
|
177
|
+
"Control-plane defaults:",
|
|
178
|
+
` Auth issuer: https://auth.anmho.com/api/auth`,
|
|
179
|
+
` Auth resource: api://${config.serviceName}`,
|
|
180
|
+
` Auth token URL: https://auth.anmho.com/api/auth/oauth2/token`,
|
|
181
|
+
` Temporal: disabled by default`,
|
|
182
|
+
` Temporal address: localhost:7233`,
|
|
183
|
+
` Temporal task queue: ${config.serviceName}`,
|
|
184
|
+
` Temporal API key secret: ${config.serviceName}-temporal-api-key`,
|
|
185
|
+
config.runtime === "go" ? ` Go module: ${config.modulePath}` : undefined,
|
|
186
|
+
"",
|
|
187
|
+
config.autoDeploy ? "Verified after deploy:" : "After deploy, verify with:",
|
|
188
|
+
...buildDeploymentVerificationCommands(config).map(formatShellCommand),
|
|
189
|
+
"",
|
|
190
|
+
"We suggest that you begin by typing:",
|
|
191
|
+
"",
|
|
192
|
+
` cd ${config.directory}`,
|
|
193
|
+
` ${devCommand}`,
|
|
194
|
+
"",
|
|
195
|
+
repository ? `Repository: ${repository}` : undefined,
|
|
196
|
+
`Production API: https://${config.apiHostname}`,
|
|
197
|
+
]
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function formatCommand(command: string, description: string) {
|
|
203
|
+
return [` ${command}`, ` ${description}`].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatShellCommand(command: { command: string; args: string[] }) {
|
|
207
|
+
return ` ${[command.command, ...command.args].join(" ")}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
154
210
|
export function parseArgs(argv: string[]): ParsedArgs {
|
|
155
211
|
const parsed: ParsedArgs = {
|
|
156
212
|
profile: "microservice",
|
|
@@ -164,8 +220,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
164
220
|
continue;
|
|
165
221
|
}
|
|
166
222
|
|
|
167
|
-
if (!token.startsWith("-") && !parsed.
|
|
168
|
-
parsed.
|
|
223
|
+
if (!token.startsWith("-") && !parsed.serviceName) {
|
|
224
|
+
parsed.serviceName = token;
|
|
169
225
|
continue;
|
|
170
226
|
}
|
|
171
227
|
|
|
@@ -188,23 +244,23 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
188
244
|
continue;
|
|
189
245
|
}
|
|
190
246
|
|
|
191
|
-
if (token === "--
|
|
192
|
-
parsed.
|
|
247
|
+
if (token === "--no-git") {
|
|
248
|
+
parsed.noGit = true;
|
|
193
249
|
continue;
|
|
194
250
|
}
|
|
195
251
|
|
|
196
|
-
if (token === "--
|
|
197
|
-
parsed.
|
|
252
|
+
if (token === "--runtime") {
|
|
253
|
+
parsed.runtime = readValue() as Runtime;
|
|
198
254
|
continue;
|
|
199
255
|
}
|
|
200
256
|
|
|
201
|
-
if (token === "--
|
|
202
|
-
parsed.
|
|
257
|
+
if (token === "--dir") {
|
|
258
|
+
parsed.directory = readValue();
|
|
203
259
|
continue;
|
|
204
260
|
}
|
|
205
261
|
|
|
206
|
-
if (token
|
|
207
|
-
parsed.
|
|
262
|
+
if (token.startsWith("--dir=")) {
|
|
263
|
+
parsed.directory = token.slice("--dir=".length);
|
|
208
264
|
continue;
|
|
209
265
|
}
|
|
210
266
|
|
|
@@ -324,74 +380,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
324
380
|
return parsed;
|
|
325
381
|
}
|
|
326
382
|
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (args.noUpdateCheck || shouldSkipUpdateCheck()) {
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const latest = await resolveLatestVersion().catch(() => "");
|
|
336
|
-
if (!latest || !isVersionGreater(latest, CURRENT_VERSION)) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const command = `bunx ${PACKAGE_NAME}@latest ${Bun.argv.slice(2).filter((arg) => arg !== "--auto-update").join(" ")}`.trim();
|
|
341
|
-
if (!args.autoUpdate) {
|
|
342
|
-
log.info(`A newer ${PACKAGE_NAME} is available: ${CURRENT_VERSION} -> ${latest}. Run ${command}`);
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const result = Bun.spawnSync(["bunx", `${PACKAGE_NAME}@latest`, ...Bun.argv.slice(2).filter((arg) => arg !== "--auto-update")], {
|
|
347
|
-
stdin: "inherit",
|
|
348
|
-
stdout: "inherit",
|
|
349
|
-
stderr: "inherit",
|
|
350
|
-
env: {
|
|
351
|
-
...process.env,
|
|
352
|
-
CREATE_SERVICE_NO_UPDATE_CHECK: "1",
|
|
353
|
-
},
|
|
354
|
-
});
|
|
355
|
-
process.exit(result.exitCode);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function shouldSkipUpdateCheck() {
|
|
359
|
-
return Boolean(
|
|
360
|
-
process.env.CI ||
|
|
361
|
-
process.env.CODEX_CI ||
|
|
362
|
-
process.env.CREATE_SERVICE_NO_UPDATE_CHECK ||
|
|
363
|
-
process.env.BUN_TEST ||
|
|
364
|
-
process.env.npm_lifecycle_event
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
async function resolveLatestVersion() {
|
|
369
|
-
const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
370
|
-
signal: AbortSignal.timeout(1_500),
|
|
371
|
-
});
|
|
372
|
-
if (!response.ok) {
|
|
373
|
-
return "";
|
|
383
|
+
export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
384
|
+
const inferredName = slugify(args.serviceName ?? basename(args.directory ?? "my-service"));
|
|
385
|
+
if (!args.yes) {
|
|
386
|
+
return resolveInteractiveConfig(args, inferredName);
|
|
374
387
|
}
|
|
375
|
-
const payload = (await response.json()) as { version?: string };
|
|
376
|
-
return payload.version?.trim() ?? "";
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function isVersionGreater(left: string, right: string) {
|
|
380
|
-
const parse = (value: string) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
381
|
-
const [leftMajor = 0, leftMinor = 0, leftPatch = 0] = parse(left);
|
|
382
|
-
const [rightMajor = 0, rightMinor = 0, rightPatch = 0] = parse(right);
|
|
383
|
-
return (
|
|
384
|
-
leftMajor > rightMajor ||
|
|
385
|
-
(leftMajor === rightMajor && leftMinor > rightMinor) ||
|
|
386
|
-
(leftMajor === rightMajor && leftMinor === rightMinor && leftPatch > rightPatch)
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
388
|
|
|
390
|
-
|
|
391
|
-
const inferredName = slugify(basename(args.directory ?? "my-service"));
|
|
392
|
-
const serviceName = args.yes
|
|
393
|
-
? inferredName
|
|
394
|
-
: await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
|
|
389
|
+
const serviceName = inferredName;
|
|
395
390
|
const directory = args.directory ?? serviceName;
|
|
396
391
|
const targetDir = resolve(process.cwd(), directory);
|
|
397
392
|
await assertTargetDirectoryIsEmpty(targetDir);
|
|
@@ -405,22 +400,14 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
|
405
400
|
const modulePath = await resolveModulePath(args, runtime, defaults.modulePath);
|
|
406
401
|
const discovery = await waitForDiscovery(discoveryPromise);
|
|
407
402
|
const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
|
|
403
|
+
if (gcpSelection === BACK) {
|
|
404
|
+
throw new Error("Unexpected back navigation in non-interactive config");
|
|
405
|
+
}
|
|
408
406
|
const region = args.region ?? DEFAULT_REGION;
|
|
409
407
|
const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
|
|
410
408
|
const autoDeploy = resolveAutoDeploy(args.autoDeploy);
|
|
411
409
|
const git = buildGitBootstrapConfig(serviceName, args.noGit);
|
|
412
410
|
|
|
413
|
-
if (!args.yes) {
|
|
414
|
-
const okay = await confirm({
|
|
415
|
-
message: "Create the scaffold with these defaults?",
|
|
416
|
-
initialValue: true,
|
|
417
|
-
});
|
|
418
|
-
if (isCancel(okay) || !okay) {
|
|
419
|
-
cancel("Aborted");
|
|
420
|
-
process.exit(1);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
411
|
for (const warning of discovery.warnings) {
|
|
425
412
|
log.warn(warning);
|
|
426
413
|
}
|
|
@@ -447,6 +434,226 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
|
447
434
|
};
|
|
448
435
|
}
|
|
449
436
|
|
|
437
|
+
async function resolveInteractiveConfig(args: ParsedArgs, initialServiceName: string): Promise<ScaffoldConfig> {
|
|
438
|
+
const state: InteractiveState = {
|
|
439
|
+
serviceName: args.serviceName ? slugify(args.serviceName) : undefined,
|
|
440
|
+
target: args.target,
|
|
441
|
+
runtime: args.runtime,
|
|
442
|
+
framework: args.framework,
|
|
443
|
+
modulePath: args.modulePath,
|
|
444
|
+
};
|
|
445
|
+
let serviceNameDraft = state.serviceName ?? initialServiceName;
|
|
446
|
+
let discovery: DiscoveryState | undefined;
|
|
447
|
+
const discoveryPromise = discoverCloudInputs();
|
|
448
|
+
let step: InteractiveStep = state.serviceName ? "target" : "serviceName";
|
|
449
|
+
|
|
450
|
+
while (true) {
|
|
451
|
+
if (step === "serviceName") {
|
|
452
|
+
const value = await promptText("Service name", serviceNameDraft, (input) => validateServiceNameInput(input, args.directory));
|
|
453
|
+
serviceNameDraft = value;
|
|
454
|
+
state.serviceName = value;
|
|
455
|
+
step = "target";
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!state.serviceName) {
|
|
460
|
+
step = "serviceName";
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const defaults = deriveDefaults(state.serviceName);
|
|
465
|
+
|
|
466
|
+
if (step === "target") {
|
|
467
|
+
if (args.target) {
|
|
468
|
+
state.target = args.target;
|
|
469
|
+
} else {
|
|
470
|
+
const value = await promptSelectWithBack<DeployTarget>(
|
|
471
|
+
"Deploy target",
|
|
472
|
+
[
|
|
473
|
+
{ value: "cloudrun", label: "Cloud Run", hint: "Default" },
|
|
474
|
+
{ value: "workers", label: "Cloudflare Workers" },
|
|
475
|
+
],
|
|
476
|
+
"cloudrun",
|
|
477
|
+
step,
|
|
478
|
+
args,
|
|
479
|
+
state
|
|
480
|
+
);
|
|
481
|
+
if (value === BACK) {
|
|
482
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
state.target = value;
|
|
486
|
+
state.runtime = undefined;
|
|
487
|
+
state.framework = undefined;
|
|
488
|
+
}
|
|
489
|
+
step = "runtime";
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (step === "runtime") {
|
|
494
|
+
if (!state.target) {
|
|
495
|
+
step = "target";
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (state.target === "workers") {
|
|
499
|
+
state.runtime = "bun";
|
|
500
|
+
} else if (args.runtime) {
|
|
501
|
+
state.runtime = args.runtime;
|
|
502
|
+
} else {
|
|
503
|
+
const value = await promptSelectWithBack<Runtime>(
|
|
504
|
+
"Runtime",
|
|
505
|
+
[
|
|
506
|
+
{ value: "go", label: "Go", hint: "Default" },
|
|
507
|
+
{ value: "bun", label: "Bun" },
|
|
508
|
+
],
|
|
509
|
+
"go",
|
|
510
|
+
step,
|
|
511
|
+
args,
|
|
512
|
+
state
|
|
513
|
+
);
|
|
514
|
+
if (value === BACK) {
|
|
515
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
state.runtime = value;
|
|
519
|
+
state.framework = undefined;
|
|
520
|
+
}
|
|
521
|
+
step = "framework";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (step === "framework") {
|
|
526
|
+
if (!state.target || !state.runtime) {
|
|
527
|
+
step = state.target ? "runtime" : "target";
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const allowed = frameworksForTargetRuntime(state.target, state.runtime);
|
|
531
|
+
if (args.framework) {
|
|
532
|
+
if (!allowed.some((framework) => framework === args.framework)) {
|
|
533
|
+
throw new Error(`Framework ${args.framework} is not valid for target ${state.target} and runtime ${state.runtime}`);
|
|
534
|
+
}
|
|
535
|
+
state.framework = args.framework;
|
|
536
|
+
} else {
|
|
537
|
+
const value = await promptSelectWithBack<Framework>(
|
|
538
|
+
"Framework",
|
|
539
|
+
allowed.map((framework, index) => ({
|
|
540
|
+
value: framework,
|
|
541
|
+
label: framework,
|
|
542
|
+
hint: index === 0 ? "Default" : undefined,
|
|
543
|
+
})),
|
|
544
|
+
allowed[0],
|
|
545
|
+
step,
|
|
546
|
+
args,
|
|
547
|
+
state
|
|
548
|
+
);
|
|
549
|
+
if (value === BACK) {
|
|
550
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
state.framework = value;
|
|
554
|
+
}
|
|
555
|
+
step = "modulePath";
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (step === "modulePath") {
|
|
560
|
+
if (!state.runtime) {
|
|
561
|
+
step = "runtime";
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (state.runtime !== "go") {
|
|
565
|
+
state.modulePath = args.modulePath ?? defaults.modulePath;
|
|
566
|
+
} else if (args.modulePath) {
|
|
567
|
+
state.modulePath = args.modulePath.trim();
|
|
568
|
+
} else {
|
|
569
|
+
const value = await promptTextWithBack(
|
|
570
|
+
"Go module path",
|
|
571
|
+
state.modulePath ?? defaults.modulePath,
|
|
572
|
+
(input) => {
|
|
573
|
+
if (!input.trim()) {
|
|
574
|
+
return "Go module path is required";
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
},
|
|
578
|
+
step,
|
|
579
|
+
args,
|
|
580
|
+
state
|
|
581
|
+
);
|
|
582
|
+
if (value === BACK) {
|
|
583
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
state.modulePath = value;
|
|
587
|
+
}
|
|
588
|
+
step = "gcp";
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (step === "gcp") {
|
|
593
|
+
discovery ??= await waitForDiscovery(discoveryPromise);
|
|
594
|
+
const value = await resolveGcpSelection(args, defaults, discovery, {
|
|
595
|
+
allowBack: Boolean(previousPromptStep(step, args, state)),
|
|
596
|
+
});
|
|
597
|
+
if (value === BACK) {
|
|
598
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
state.gcpSelection = value;
|
|
602
|
+
step = "confirm";
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (step === "confirm") {
|
|
607
|
+
if (!state.target || !state.runtime || !state.framework || !state.modulePath || !state.gcpSelection) {
|
|
608
|
+
step = "serviceName";
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const value = await promptSelectWithBack<"create">(
|
|
612
|
+
"Create the scaffold with these defaults?",
|
|
613
|
+
[{ value: "create", label: "Create scaffold", hint: "Default" }],
|
|
614
|
+
"create",
|
|
615
|
+
step,
|
|
616
|
+
args,
|
|
617
|
+
state
|
|
618
|
+
);
|
|
619
|
+
if (value === BACK) {
|
|
620
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const directory = args.directory ?? state.serviceName;
|
|
625
|
+
const targetDir = resolve(process.cwd(), directory);
|
|
626
|
+
await assertTargetDirectoryIsEmpty(targetDir);
|
|
627
|
+
const billingAccount = chooseBillingAccount(args.billingAccount, discovery?.billingAccounts ?? []);
|
|
628
|
+
|
|
629
|
+
for (const warning of discovery?.warnings ?? []) {
|
|
630
|
+
log.warn(warning);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
directory,
|
|
635
|
+
serviceName: state.serviceName,
|
|
636
|
+
modulePath: state.modulePath,
|
|
637
|
+
target: state.target,
|
|
638
|
+
runtime: state.runtime,
|
|
639
|
+
framework: state.framework,
|
|
640
|
+
profile: args.profile,
|
|
641
|
+
region: args.region ?? DEFAULT_REGION,
|
|
642
|
+
gcpProjectMode: state.gcpSelection.mode,
|
|
643
|
+
gcpProject: state.gcpSelection.projectId,
|
|
644
|
+
gcpProjectName: state.gcpSelection.projectName,
|
|
645
|
+
billingAccount,
|
|
646
|
+
quotaProjectId: args.quotaProjectId ?? QUOTA_PROJECT_DEFAULT,
|
|
647
|
+
autoDeploy: resolveAutoDeploy(args.autoDeploy),
|
|
648
|
+
git: buildGitBootstrapConfig(state.serviceName, args.noGit),
|
|
649
|
+
neonDatabaseName: defaults.neonDatabaseName,
|
|
650
|
+
apiHostname: defaults.apiHostname,
|
|
651
|
+
generatorRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
450
657
|
async function waitForDiscovery(discoveryPromise: Promise<DiscoveryState>) {
|
|
451
658
|
const indicator = spinner();
|
|
452
659
|
indicator.start("Discovering GCP defaults");
|
|
@@ -574,8 +781,9 @@ async function resolveModulePath(args: ParsedArgs, runtime: Runtime, initialValu
|
|
|
574
781
|
async function resolveGcpSelection(
|
|
575
782
|
args: ParsedArgs,
|
|
576
783
|
defaults: ReturnType<typeof deriveDefaults>,
|
|
577
|
-
discovery: DiscoveryState
|
|
578
|
-
|
|
784
|
+
discovery: DiscoveryState,
|
|
785
|
+
options: { allowBack?: boolean } = {}
|
|
786
|
+
): Promise<GcpSelection | typeof BACK> {
|
|
579
787
|
if (args.gcpProjectMode && args.gcpProject) {
|
|
580
788
|
const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
|
|
581
789
|
return {
|
|
@@ -614,6 +822,15 @@ async function resolveGcpSelection(
|
|
|
614
822
|
message: "GCP project",
|
|
615
823
|
initialValue: "create_new",
|
|
616
824
|
options: [
|
|
825
|
+
...(options.allowBack
|
|
826
|
+
? [
|
|
827
|
+
{
|
|
828
|
+
value: BACK,
|
|
829
|
+
label: "Back",
|
|
830
|
+
hint: "Return to previous step",
|
|
831
|
+
},
|
|
832
|
+
]
|
|
833
|
+
: []),
|
|
617
834
|
{
|
|
618
835
|
value: "create_new",
|
|
619
836
|
label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
|
|
@@ -633,6 +850,10 @@ async function resolveGcpSelection(
|
|
|
633
850
|
process.exit(1);
|
|
634
851
|
}
|
|
635
852
|
|
|
853
|
+
if (mode === BACK) {
|
|
854
|
+
return BACK;
|
|
855
|
+
}
|
|
856
|
+
|
|
636
857
|
if (mode === "create_new") {
|
|
637
858
|
return {
|
|
638
859
|
mode: "create_new" as const,
|
|
@@ -645,7 +866,10 @@ async function resolveGcpSelection(
|
|
|
645
866
|
throw new Error("No existing GCP projects were discovered");
|
|
646
867
|
}
|
|
647
868
|
|
|
648
|
-
const selected = await promptForExistingProject(discovery.projects);
|
|
869
|
+
const selected = await promptForExistingProject(discovery.projects, options);
|
|
870
|
+
if (selected === BACK) {
|
|
871
|
+
return BACK;
|
|
872
|
+
}
|
|
649
873
|
if (!selected) {
|
|
650
874
|
return resolveGcpSelection(
|
|
651
875
|
{
|
|
@@ -654,7 +878,8 @@ async function resolveGcpSelection(
|
|
|
654
878
|
gcpProject: undefined,
|
|
655
879
|
},
|
|
656
880
|
defaults,
|
|
657
|
-
discovery
|
|
881
|
+
discovery,
|
|
882
|
+
options
|
|
658
883
|
);
|
|
659
884
|
}
|
|
660
885
|
|
|
@@ -704,11 +929,11 @@ function chooseBillingAccount(input: string | undefined, accounts: BillingAccoun
|
|
|
704
929
|
return accounts[0]?.name ?? BILLING_ACCOUNT_DEFAULT;
|
|
705
930
|
}
|
|
706
931
|
|
|
707
|
-
function resolveAutoDeploy(value: boolean | undefined) {
|
|
932
|
+
export function resolveAutoDeploy(value: boolean | undefined) {
|
|
708
933
|
if (value !== undefined) {
|
|
709
934
|
return value;
|
|
710
935
|
}
|
|
711
|
-
return
|
|
936
|
+
return true;
|
|
712
937
|
}
|
|
713
938
|
|
|
714
939
|
async function promptText(
|
|
@@ -730,6 +955,108 @@ async function promptText(
|
|
|
730
955
|
return value.trim();
|
|
731
956
|
}
|
|
732
957
|
|
|
958
|
+
async function promptTextWithBack(
|
|
959
|
+
message: string,
|
|
960
|
+
initialValue: string,
|
|
961
|
+
validate: (value: string) => true | string,
|
|
962
|
+
step: InteractiveStep,
|
|
963
|
+
args: ParsedArgs,
|
|
964
|
+
state: InteractiveState
|
|
965
|
+
): Promise<string | typeof BACK> {
|
|
966
|
+
const allowBack = Boolean(previousPromptStep(step, args, state));
|
|
967
|
+
const value = await text({
|
|
968
|
+
message: allowBack ? `${message} (type "back" to return)` : message,
|
|
969
|
+
initialValue,
|
|
970
|
+
validate: (input) => {
|
|
971
|
+
const normalized = (input ?? "").trim().toLowerCase();
|
|
972
|
+
if (allowBack && (normalized === "back" || normalized === "<")) {
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
return normalizeValidationResult(validate((input ?? "").trim()));
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
if (isCancel(value)) {
|
|
980
|
+
cancel("Aborted");
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const trimmed = value.trim();
|
|
985
|
+
if (allowBack && (trimmed.toLowerCase() === "back" || trimmed === "<")) {
|
|
986
|
+
return BACK;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return trimmed;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function promptSelectWithBack<Value extends string>(
|
|
993
|
+
message: string,
|
|
994
|
+
options: Array<{ value: Value; label?: string; hint?: string; disabled?: boolean }>,
|
|
995
|
+
initialValue: Value | undefined,
|
|
996
|
+
step: InteractiveStep,
|
|
997
|
+
args: ParsedArgs,
|
|
998
|
+
state: InteractiveState
|
|
999
|
+
): Promise<Value | typeof BACK> {
|
|
1000
|
+
const allowBack = Boolean(previousPromptStep(step, args, state));
|
|
1001
|
+
const value = await select<Value | typeof BACK>({
|
|
1002
|
+
message,
|
|
1003
|
+
initialValue,
|
|
1004
|
+
options: [
|
|
1005
|
+
...(allowBack
|
|
1006
|
+
? [
|
|
1007
|
+
{
|
|
1008
|
+
value: BACK,
|
|
1009
|
+
label: "Back",
|
|
1010
|
+
hint: "Return to previous step",
|
|
1011
|
+
},
|
|
1012
|
+
]
|
|
1013
|
+
: []),
|
|
1014
|
+
...options,
|
|
1015
|
+
] as any,
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
if (isCancel(value)) {
|
|
1019
|
+
cancel("Aborted");
|
|
1020
|
+
process.exit(1);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return value;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function previousPromptStep(step: InteractiveStep, args: ParsedArgs, state: InteractiveState): InteractiveStep | undefined {
|
|
1027
|
+
const steps: InteractiveStep[] = ["serviceName", "target", "runtime", "framework", "modulePath", "gcp", "confirm"];
|
|
1028
|
+
const currentIndex = steps.indexOf(step);
|
|
1029
|
+
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
|
1030
|
+
const candidate = steps[index];
|
|
1031
|
+
if (candidate && isPromptableStep(candidate, args, state)) {
|
|
1032
|
+
return candidate;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isPromptableStep(step: InteractiveStep, args: ParsedArgs, state: InteractiveState) {
|
|
1039
|
+
if (step === "serviceName") {
|
|
1040
|
+
return !args.serviceName;
|
|
1041
|
+
}
|
|
1042
|
+
if (step === "target") {
|
|
1043
|
+
return !args.target;
|
|
1044
|
+
}
|
|
1045
|
+
if (step === "runtime") {
|
|
1046
|
+
return state.target !== "workers" && !args.runtime;
|
|
1047
|
+
}
|
|
1048
|
+
if (step === "framework") {
|
|
1049
|
+
return !args.framework;
|
|
1050
|
+
}
|
|
1051
|
+
if (step === "modulePath") {
|
|
1052
|
+
return state.runtime === "go" && !args.modulePath;
|
|
1053
|
+
}
|
|
1054
|
+
if (step === "gcp") {
|
|
1055
|
+
return !args.gcpProjectMode;
|
|
1056
|
+
}
|
|
1057
|
+
return step === "confirm";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
733
1060
|
function formatError(error: unknown) {
|
|
734
1061
|
return error instanceof Error ? error.message : String(error);
|
|
735
1062
|
}
|
|
@@ -744,17 +1071,21 @@ function handleCliError(error: unknown) {
|
|
|
744
1071
|
process.exit(1);
|
|
745
1072
|
}
|
|
746
1073
|
|
|
747
|
-
async function promptForExistingProject(projects: GcpProject[]) {
|
|
1074
|
+
async function promptForExistingProject(projects: GcpProject[], options: { allowBack?: boolean } = {}) {
|
|
748
1075
|
const value = await autocomplete({
|
|
749
1076
|
message: "Existing GCP project",
|
|
750
1077
|
placeholder: "Search by project name or id",
|
|
751
1078
|
maxItems: 10,
|
|
752
1079
|
options: [
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1080
|
+
...(options.allowBack
|
|
1081
|
+
? [
|
|
1082
|
+
{
|
|
1083
|
+
value: BACK,
|
|
1084
|
+
label: "Back",
|
|
1085
|
+
hint: "Return to project mode",
|
|
1086
|
+
},
|
|
1087
|
+
]
|
|
1088
|
+
: []),
|
|
758
1089
|
...projects.map((project) => ({
|
|
759
1090
|
value: project.projectId,
|
|
760
1091
|
label: project.name,
|
|
@@ -768,8 +1099,8 @@ async function promptForExistingProject(projects: GcpProject[]) {
|
|
|
768
1099
|
process.exit(1);
|
|
769
1100
|
}
|
|
770
1101
|
|
|
771
|
-
if (value ===
|
|
772
|
-
return
|
|
1102
|
+
if (value === BACK) {
|
|
1103
|
+
return BACK;
|
|
773
1104
|
}
|
|
774
1105
|
|
|
775
1106
|
const project = projects.find((candidate) => candidate.projectId === value);
|
|
@@ -823,29 +1154,37 @@ export function validateServiceNameInput(rawValue: string, directoryOverride?: s
|
|
|
823
1154
|
}
|
|
824
1155
|
|
|
825
1156
|
function printHelp() {
|
|
826
|
-
log
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
--
|
|
840
|
-
--
|
|
841
|
-
--
|
|
842
|
-
--
|
|
843
|
-
--
|
|
844
|
-
--
|
|
845
|
-
--
|
|
846
|
-
--
|
|
847
|
-
--
|
|
848
|
-
|
|
1157
|
+
console.log(formatScaffoldHelp());
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export function formatScaffoldHelp() {
|
|
1161
|
+
return [
|
|
1162
|
+
"Usage:",
|
|
1163
|
+
" service create <service_id> [options]",
|
|
1164
|
+
"",
|
|
1165
|
+
"Examples:",
|
|
1166
|
+
" service create waitlist-api --target cloudrun --runtime bun --framework hono",
|
|
1167
|
+
" service create waitlist-api --auto-deploy",
|
|
1168
|
+
"",
|
|
1169
|
+
"Options:",
|
|
1170
|
+
" --dir <path> Output directory; defaults to ./<service_id>",
|
|
1171
|
+
" --target <cloudrun|workers> Deploy target for the generated service",
|
|
1172
|
+
" --runtime <go|bun> Runtime scaffold to generate",
|
|
1173
|
+
" --framework <name> Framework for the selected runtime",
|
|
1174
|
+
" --module-path <path> Go module path for generated Go scaffolds",
|
|
1175
|
+
" --project-mode <mode> create_new or use_existing",
|
|
1176
|
+
" --project-id <id> GCP project id",
|
|
1177
|
+
" --billing-account <name> Billing account resource name",
|
|
1178
|
+
" --quota-project <id> Billing quota project for gcloud calls",
|
|
1179
|
+
" --region <region> Cloud Run region",
|
|
1180
|
+
" --auto-deploy Scaffold, run service create, then service deploy (default)",
|
|
1181
|
+
" --no-auto-deploy Scaffold only",
|
|
1182
|
+
" --no-git Skip default private GitHub repo: anmho/<service_id>",
|
|
1183
|
+
" --yes, -y Accept defaults without prompts",
|
|
1184
|
+
" --help, -h Show this message",
|
|
1185
|
+
"",
|
|
1186
|
+
"Inside a generated service repo, run service --help for create, deploy, doctor, auth, and sdk commands.",
|
|
1187
|
+
].join("\n");
|
|
849
1188
|
}
|
|
850
1189
|
|
|
851
1190
|
function matchesProject(project: GcpProject, query: string) {
|