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/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 { bootstrapGitHubRepository, buildGitBootstrapConfig, commitAndPushGeneratedArtifacts } from "./git-bootstrap";
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
- const isBun = config.runtime === "bun";
134
- outro(
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.directory) {
168
- parsed.directory = token;
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 === "--auto-update") {
192
- parsed.autoUpdate = true;
247
+ if (token === "--no-git") {
248
+ parsed.noGit = true;
193
249
  continue;
194
250
  }
195
251
 
196
- if (token === "--no-update-check") {
197
- parsed.noUpdateCheck = true;
252
+ if (token === "--runtime") {
253
+ parsed.runtime = readValue() as Runtime;
198
254
  continue;
199
255
  }
200
256
 
201
- if (token === "--no-git") {
202
- parsed.noGit = true;
257
+ if (token === "--dir") {
258
+ parsed.directory = readValue();
203
259
  continue;
204
260
  }
205
261
 
206
- if (token === "--runtime") {
207
- parsed.runtime = readValue() as Runtime;
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
- const CURRENT_VERSION = "0.1.9";
328
- const PACKAGE_NAME = "create-svc";
329
-
330
- async function maybeCheckForUpdate(args: ParsedArgs) {
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
- export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
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 false;
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
- value: "__back__",
755
- label: "Back",
756
- hint: "Return to project mode",
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 === "__back__") {
772
- return undefined;
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.message(`
827
- Usage:
828
- service create [service_id] [options]
829
-
830
- Options:
831
- --target <cloudrun|workers> Deploy target for the generated service
832
- --profile <microservice> Compatibility no-op; app workspaces moved out
833
- --runtime <go|bun> Runtime scaffold to generate
834
- --framework <name> Framework for the selected runtime
835
- --module-path <path> Go module path for generated Go scaffolds
836
- --project-mode <mode> create_new or use_existing
837
- --project-id <id> GCP project id
838
- --billing-account <name> Billing account resource name
839
- --quota-project <id> Billing quota project for gcloud calls
840
- --region <region> Cloud Run region
841
- --auto-deploy Run service create and service deploy after scaffold
842
- --no-auto-deploy Scaffold only
843
- --no-git Skip git init, initial commit, GitHub repo creation, and push
844
- --auto-update Re-run through create-svc@latest when a newer version exists
845
- --no-update-check Skip the best-effort npm update check
846
- --yes, -y Accept defaults without prompts
847
- --help, -h Show this message
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) {