create-svc 0.1.14 → 0.1.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -50,8 +50,6 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@clack/prompts": "^1.4.0",
53
- "@google-cloud/billing": "^5.1.2",
54
- "@google-cloud/resource-manager": "^6.2.2",
55
53
  "@neondatabase/api-client": "^2.7.1",
56
54
  "picocolors": "^1.1.1"
57
55
  }
package/src/cli.ts CHANGED
@@ -1,16 +1,4 @@
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";
@@ -68,7 +56,25 @@ type DiscoveryState = {
68
56
  warnings: string[];
69
57
  };
70
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
+
71
76
  const DEFAULT_REGION = "us-west1";
77
+ const BACK = "__back__" as const;
72
78
 
73
79
  export async function run(argv: string[]) {
74
80
  try {
@@ -376,9 +382,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
376
382
 
377
383
  export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
378
384
  const inferredName = slugify(args.serviceName ?? basename(args.directory ?? "my-service"));
379
- const serviceName = args.yes
380
- ? inferredName
381
- : await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
385
+ if (!args.yes) {
386
+ return resolveInteractiveConfig(args, inferredName);
387
+ }
388
+
389
+ const serviceName = inferredName;
382
390
  const directory = args.directory ?? serviceName;
383
391
  const targetDir = resolve(process.cwd(), directory);
384
392
  await assertTargetDirectoryIsEmpty(targetDir);
@@ -392,22 +400,14 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
392
400
  const modulePath = await resolveModulePath(args, runtime, defaults.modulePath);
393
401
  const discovery = await waitForDiscovery(discoveryPromise);
394
402
  const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
403
+ if (gcpSelection === BACK) {
404
+ throw new Error("Unexpected back navigation in non-interactive config");
405
+ }
395
406
  const region = args.region ?? DEFAULT_REGION;
396
407
  const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
397
408
  const autoDeploy = resolveAutoDeploy(args.autoDeploy);
398
409
  const git = buildGitBootstrapConfig(serviceName, args.noGit);
399
410
 
400
- if (!args.yes) {
401
- const okay = await confirm({
402
- message: "Create the scaffold with these defaults?",
403
- initialValue: true,
404
- });
405
- if (isCancel(okay) || !okay) {
406
- cancel("Aborted");
407
- process.exit(1);
408
- }
409
- }
410
-
411
411
  for (const warning of discovery.warnings) {
412
412
  log.warn(warning);
413
413
  }
@@ -434,6 +434,226 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
434
434
  };
435
435
  }
436
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
+
437
657
  async function waitForDiscovery(discoveryPromise: Promise<DiscoveryState>) {
438
658
  const indicator = spinner();
439
659
  indicator.start("Discovering GCP defaults");
@@ -561,8 +781,9 @@ async function resolveModulePath(args: ParsedArgs, runtime: Runtime, initialValu
561
781
  async function resolveGcpSelection(
562
782
  args: ParsedArgs,
563
783
  defaults: ReturnType<typeof deriveDefaults>,
564
- discovery: DiscoveryState
565
- ) {
784
+ discovery: DiscoveryState,
785
+ options: { allowBack?: boolean } = {}
786
+ ): Promise<GcpSelection | typeof BACK> {
566
787
  if (args.gcpProjectMode && args.gcpProject) {
567
788
  const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
568
789
  return {
@@ -601,6 +822,15 @@ async function resolveGcpSelection(
601
822
  message: "GCP project",
602
823
  initialValue: "create_new",
603
824
  options: [
825
+ ...(options.allowBack
826
+ ? [
827
+ {
828
+ value: BACK,
829
+ label: "Back",
830
+ hint: "Return to previous step",
831
+ },
832
+ ]
833
+ : []),
604
834
  {
605
835
  value: "create_new",
606
836
  label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
@@ -620,6 +850,10 @@ async function resolveGcpSelection(
620
850
  process.exit(1);
621
851
  }
622
852
 
853
+ if (mode === BACK) {
854
+ return BACK;
855
+ }
856
+
623
857
  if (mode === "create_new") {
624
858
  return {
625
859
  mode: "create_new" as const,
@@ -632,7 +866,10 @@ async function resolveGcpSelection(
632
866
  throw new Error("No existing GCP projects were discovered");
633
867
  }
634
868
 
635
- const selected = await promptForExistingProject(discovery.projects);
869
+ const selected = await promptForExistingProject(discovery.projects, options);
870
+ if (selected === BACK) {
871
+ return BACK;
872
+ }
636
873
  if (!selected) {
637
874
  return resolveGcpSelection(
638
875
  {
@@ -641,7 +878,8 @@ async function resolveGcpSelection(
641
878
  gcpProject: undefined,
642
879
  },
643
880
  defaults,
644
- discovery
881
+ discovery,
882
+ options
645
883
  );
646
884
  }
647
885
 
@@ -717,6 +955,108 @@ async function promptText(
717
955
  return value.trim();
718
956
  }
719
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
+
720
1060
  function formatError(error: unknown) {
721
1061
  return error instanceof Error ? error.message : String(error);
722
1062
  }
@@ -731,17 +1071,21 @@ function handleCliError(error: unknown) {
731
1071
  process.exit(1);
732
1072
  }
733
1073
 
734
- async function promptForExistingProject(projects: GcpProject[]) {
1074
+ async function promptForExistingProject(projects: GcpProject[], options: { allowBack?: boolean } = {}) {
735
1075
  const value = await autocomplete({
736
1076
  message: "Existing GCP project",
737
1077
  placeholder: "Search by project name or id",
738
1078
  maxItems: 10,
739
1079
  options: [
740
- {
741
- value: "__back__",
742
- label: "Back",
743
- hint: "Return to project mode",
744
- },
1080
+ ...(options.allowBack
1081
+ ? [
1082
+ {
1083
+ value: BACK,
1084
+ label: "Back",
1085
+ hint: "Return to project mode",
1086
+ },
1087
+ ]
1088
+ : []),
745
1089
  ...projects.map((project) => ({
746
1090
  value: project.projectId,
747
1091
  label: project.name,
@@ -755,8 +1099,8 @@ async function promptForExistingProject(projects: GcpProject[]) {
755
1099
  process.exit(1);
756
1100
  }
757
1101
 
758
- if (value === "__back__") {
759
- return undefined;
1102
+ if (value === BACK) {
1103
+ return BACK;
760
1104
  }
761
1105
 
762
1106
  const project = projects.find((candidate) => candidate.projectId === value);
package/src/gcp.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { CloudBillingClient } from "@google-cloud/billing";
2
- import { ProjectsClient } from "@google-cloud/resource-manager";
3
-
4
1
  export type GcpProject = {
5
2
  projectId: string;
6
3
  name: string;
@@ -20,58 +17,29 @@ export type GcpApi = {
20
17
  attachBillingAccount(projectId: string, billingAccountName: string): Promise<void>;
21
18
  };
22
19
 
23
- export function createGcpApi(
24
- projectsClient = new ProjectsClient(),
25
- billingClient = new CloudBillingClient()
26
- ): GcpApi {
20
+ export function createGcpApi(): GcpApi {
27
21
  return {
28
22
  async listProjects() {
29
- const projects: GcpProject[] = [];
30
- for await (const project of projectsClient.searchProjectsAsync({}, { autoPaginate: false })) {
31
- projects.push({
32
- projectId: project.projectId ?? "",
33
- name: project.displayName ?? project.projectId ?? "",
34
- lifecycleState: `${project.state ?? ""}`,
35
- });
36
- }
37
-
38
- return projects
39
- .filter((project) => project.projectId && project.lifecycleState !== "DELETE_REQUESTED")
40
- .sort((left, right) => left.name.localeCompare(right.name));
23
+ return parseJson<GcpProject[]>(
24
+ runGcloud(["projects", "list", "--format=json(projectId,name,lifecycleState)"]).stdout,
25
+ "GCP project discovery"
26
+ );
41
27
  },
42
28
 
43
29
  async listBillingAccounts() {
44
- const accounts: BillingAccount[] = [];
45
- for await (const account of billingClient.listBillingAccountsAsync({}, { autoPaginate: false })) {
46
- accounts.push({
47
- name: account.name ?? "",
48
- displayName: account.displayName ?? account.name ?? "",
49
- open: Boolean(account.open),
50
- });
51
- }
52
-
53
- return accounts
54
- .filter((account) => account.name && account.open)
55
- .sort((left, right) => left.displayName.localeCompare(right.displayName));
30
+ return parseJson<BillingAccount[]>(
31
+ runGcloud(["billing", "accounts", "list", "--format=json(name,displayName,open)"]).stdout,
32
+ "billing account discovery"
33
+ );
56
34
  },
57
35
 
58
36
  async createProject(projectId: string, name: string) {
59
- const [operation] = await projectsClient.createProject({
60
- project: {
61
- projectId,
62
- displayName: name,
63
- },
64
- });
65
- await operation.promise();
37
+ runGcloud(["projects", "create", projectId, "--name", name]);
66
38
  },
67
39
 
68
40
  async attachBillingAccount(projectId: string, billingAccountName: string) {
69
- await billingClient.updateProjectBillingInfo({
70
- name: `projects/${projectId}`,
71
- projectBillingInfo: {
72
- billingAccountName,
73
- },
74
- });
41
+ const account = billingAccountName.replace(/^billingAccounts\//, "");
42
+ runGcloud(["billing", "projects", "link", projectId, "--billing-account", account]);
75
43
  },
76
44
  };
77
45
  }
@@ -95,3 +63,26 @@ export async function createProject(projectId: string, name: string, api = creat
95
63
  export async function attachBillingAccount(projectId: string, billingAccountName: string, api = createGcpApi()) {
96
64
  await api.attachBillingAccount(projectId, billingAccountName);
97
65
  }
66
+
67
+ function runGcloud(args: string[]) {
68
+ const result = Bun.spawnSync(["gcloud", ...args], {
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+
73
+ if (result.exitCode !== 0) {
74
+ throw new Error(result.stderr.toString().trim() || `gcloud ${args.join(" ")} failed`);
75
+ }
76
+
77
+ return {
78
+ stdout: result.stdout.toString(),
79
+ };
80
+ }
81
+
82
+ function parseJson<T>(value: string, label: string): T {
83
+ try {
84
+ return JSON.parse(value) as T;
85
+ } catch (error) {
86
+ throw new Error(`Unable to parse ${label} output: ${(error as Error).message}`);
87
+ }
88
+ }
@@ -84,7 +84,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
84
84
 
85
85
  const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
86
86
  expect(deployScript).toContain('--billing-project", config.project.quotaProjectId');
87
- expect(deployScript).toContain('config.project.mode === "use_existing"');
87
+ expect(deployScript).toContain('projectMode === "use_existing"');
88
88
  expect(deployScript).toContain("serviceDomain");
89
89
  expect(deployScript).toContain("ensureProductionDomainMapping");
90
90
  expect(deployScript).toContain('"domain-mappings",');
@@ -96,7 +96,10 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
96
96
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "integrations.ts")).exists()).toBeFalse();
97
97
  const destroyScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cleanup.ts")).text();
98
98
  expect(destroyScript).toContain("assertOwnedResource");
99
- expect(destroyScript).toContain("assertProductionDomainMappingOwned");
99
+ expect(destroyScript).toContain("Planning resources to destroy");
100
+ expect(destroyScript).toContain("Resources selected for destroy");
101
+ expect(destroyScript).toContain("Destroy cannot continue until resource discovery succeeds");
102
+ expect(destroyScript).toContain("deleteAuthResourceServer");
100
103
  expect(destroyScript).toContain("deleteGrafanaResources");
101
104
  expect(destroyScript).toContain('gcx", ["resources", "delete"');
102
105
  expect(destroyScript).toContain("config.temporal.apiKeySecretName");
@@ -135,6 +138,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
135
138
  expect(envExample).toContain("AUTH_ENABLED=false");
136
139
  expect(envExample).toContain("AUTH_AUDIENCE=api://dns-api");
137
140
  expect(envExample).toContain("CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID=");
141
+ expect(envExample).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
138
142
  expect(envExample).toContain("TEMPORAL_API_KEY=");
139
143
  expect(envExample).toContain("The base waitlist service does not require");
140
144
  expect(envExample).not.toContain("ATTACHMENT_BUCKET=");
@@ -146,6 +150,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
146
150
 
147
151
  const localEnv = await Bun.file(join(generatedRoot, ".env.local")).text();
148
152
  expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
153
+ expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
154
+ expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
149
155
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
150
156
 
151
157
  const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
@@ -223,6 +229,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
223
229
  expect(authctlScript).toContain("resource-servers");
224
230
  expect(authctlScript).toContain("clients");
225
231
  expect(authctlScript).toContain("defaultClientTargetArgs");
232
+ expect(authctlScript).toContain("deleteAuthResourceServer");
233
+ expect(authctlScript).toContain("readAuthctlAccessVaultField");
234
+ expect(authctlScript).toContain("prod/apps/auth/authctl/cloudflare-access");
226
235
  expect(authctlScript).toContain('existsSync("./node_modules/.bin/authctl") ? "./node_modules/.bin/authctl" : Bun.which("authctl")');
227
236
  expect(authctlScript).not.toContain('defaultAuthResourceServerArgs(), "--yes", "--json"');
228
237
  const authScript = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
package/src/scaffold.ts CHANGED
@@ -252,6 +252,14 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
252
252
  "",
253
253
  "DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable",
254
254
  "",
255
+ "VAULT_SECRET_MOUNT=secret",
256
+ "VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access",
257
+ "VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD=AUTH_INTERNAL_BASE_URL",
258
+ "VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
259
+ "VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
260
+ "VAULT_NEON_API_KEY_PATH=prod/providers/neon",
261
+ "VAULT_NEON_API_KEY_FIELD=api_key",
262
+ "",
255
263
  ].join("\n"),
256
264
  replacements
257
265
  );
@@ -104,14 +104,17 @@ The scaffold will use, in order:
104
104
 
105
105
  That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
106
106
 
107
- For production auth registration, `authctl` also needs the auth service's
108
- Cloudflare Access service token:
107
+ For production auth registration, `authctl` loads the auth service's Cloudflare
108
+ Access service token from Vault by default:
109
109
 
110
- ```bash
111
- export AUTH_INTERNAL_BASE_URL="$(vault kv get -mount=secret -field=AUTH_INTERNAL_BASE_URL prod/apps/auth/authctl/cloudflare-access)"
112
- export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID prod/apps/auth/authctl/cloudflare-access)"
113
- export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET prod/apps/auth/authctl/cloudflare-access)"
114
- ```
110
+ - `VAULT_AUTHCTL_ACCESS_PATH` default `prod/apps/auth/authctl/cloudflare-access`
111
+ - `VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD` default `AUTH_INTERNAL_BASE_URL`
112
+ - `VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD` default `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID`
113
+ - `VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD` default `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET`
114
+
115
+ Direct `AUTH_INTERNAL_BASE_URL`, `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID`,
116
+ and `CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET` env values still override
117
+ Vault when set.
115
118
 
116
119
  Before first production create, verify the installed `authctl` exposes the
117
120
  resource-server control-plane command:
@@ -127,6 +130,7 @@ newer before running `{{COMMAND_BOOTSTRAP}}`.
127
130
  Optional remote-only Vault overrides for Neon admin key lookup:
128
131
 
129
132
  - `VAULT_SECRET_MOUNT` default `secret`
133
+ - `VAULT_AUTHCTL_ACCESS_PATH` default `prod/apps/auth/authctl/cloudflare-access`
130
134
  - `VAULT_NEON_API_KEY_PATH` default `prod/providers/neon`
131
135
  - `VAULT_NEON_API_KEY_FIELD` default `api_key`
132
136
 
@@ -58,10 +58,24 @@ export function runAuthCommand(args: string[]) {
58
58
  "authctl is installed but does not expose resource-server commands; install @anmho/authctl@0.1.1 or newer before managing auth resource servers"
59
59
  );
60
60
  }
61
- if (action === "get" || action === "list") {
61
+ if (action === "get" || action === "list" || action === "delete") {
62
62
  if (!command.actions.includes(action)) {
63
63
  throw new Error(`authctl ${command.subject} does not expose ${action}`);
64
64
  }
65
+ if (action === "delete") {
66
+ authctl([
67
+ command.subject,
68
+ action,
69
+ "--resource-server",
70
+ serviceConfig.auth.resource_server.id,
71
+ "--stage",
72
+ serviceConfig.stage_default,
73
+ "--force",
74
+ "--json",
75
+ ...rest,
76
+ ]);
77
+ return `Auth resource server deleted: ${serviceConfig.auth.resource_server.id}`;
78
+ }
65
79
  authctl([command.subject, action, ...rest]);
66
80
  return `Auth resource server ${action} finished`;
67
81
  }
@@ -87,6 +101,25 @@ export function ensureAuthResourceServer() {
87
101
  return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
88
102
  }
89
103
 
104
+ export function deleteAuthResourceServer() {
105
+ const command = resolveResourceServerCommand();
106
+ if (!command?.actions.includes("delete")) {
107
+ return "authctl does not expose resource-server delete; auth resource server was not deleted";
108
+ }
109
+
110
+ authctl([
111
+ command.subject,
112
+ "delete",
113
+ "--resource-server",
114
+ serviceConfig.auth.resource_server.id,
115
+ "--stage",
116
+ serviceConfig.stage_default,
117
+ "--force",
118
+ "--json",
119
+ ]);
120
+ return `Auth resource server deleted: ${serviceConfig.auth.resource_server.id}`;
121
+ }
122
+
90
123
  export function runAuthDoctor(): AuthDoctorResult {
91
124
  if (!authctlPath()) {
92
125
  return {
@@ -185,7 +218,7 @@ function resolveResourceServerCommand(): ResourceServerCommand | undefined {
185
218
  if (!help.success || !output.includes(subject)) {
186
219
  continue;
187
220
  }
188
- const actions = ["upsert", "create", "get", "list"].filter((candidate) => output.includes(candidate));
221
+ const actions = ["upsert", "create", "get", "list", "delete"].filter((candidate) => output.includes(candidate));
189
222
  const mutationAction = actions.includes("upsert") ? "upsert" : actions.includes("create") ? "create" : undefined;
190
223
  if (actions.length > 0) {
191
224
  return { subject, mutationAction, actions };
@@ -202,7 +235,7 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
202
235
 
203
236
  const result = Bun.spawnSync([command, ...args], {
204
237
  cwd: process.cwd(),
205
- env: process.env,
238
+ env: authctlEnvironment(),
206
239
  stdin: "inherit",
207
240
  stdout: "pipe",
208
241
  stderr: "pipe",
@@ -216,7 +249,7 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
216
249
  };
217
250
 
218
251
  if (!output.success && !options.allowFailure) {
219
- throw new Error(`authctl ${args.join(" ")} failed with exit code ${output.exitCode}\n${output.stderr || output.stdout}`);
252
+ throw new Error(formatAuthctlFailure(args, output));
220
253
  }
221
254
 
222
255
  if (output.stdout && !options.quiet) {
@@ -226,6 +259,76 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
226
259
  return output;
227
260
  }
228
261
 
262
+ function formatAuthctlFailure(args: string[], output: CommandResult) {
263
+ const detail = output.stderr || output.stdout;
264
+ if (detail.includes("status_code\":401") || detail.includes("Forbidden. You don't have permission")) {
265
+ return [
266
+ `authctl ${args.join(" ")} failed with exit code ${output.exitCode}`,
267
+ "authctl reached the auth internal API, but Cloudflare Access rejected the request.",
268
+ "The service CLI tried to load the authctl Access token from Vault.",
269
+ "Verify `vault login` works and that this path is readable:",
270
+ " secret/prod/apps/auth/authctl/cloudflare-access",
271
+ ].join("\n");
272
+ }
273
+
274
+ return `authctl ${args.join(" ")} failed with exit code ${output.exitCode}\n${detail}`;
275
+ }
276
+
229
277
  function authctlPath() {
230
278
  return existsSync("./node_modules/.bin/authctl") ? "./node_modules/.bin/authctl" : Bun.which("authctl");
231
279
  }
280
+
281
+ function authctlEnvironment() {
282
+ const env = { ...process.env };
283
+ const fields = [
284
+ {
285
+ envName: "AUTH_INTERNAL_BASE_URL",
286
+ fieldEnvName: "VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD",
287
+ defaultField: "AUTH_INTERNAL_BASE_URL",
288
+ },
289
+ {
290
+ envName: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
291
+ fieldEnvName: "VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD",
292
+ defaultField: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID",
293
+ },
294
+ {
295
+ envName: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
296
+ fieldEnvName: "VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD",
297
+ defaultField: "CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
298
+ },
299
+ ];
300
+
301
+ for (const field of fields) {
302
+ if (env[field.envName]?.trim()) {
303
+ continue;
304
+ }
305
+ const value = readAuthctlAccessVaultField(env, env[field.fieldEnvName]?.trim() || field.defaultField);
306
+ if (value) {
307
+ env[field.envName] = value;
308
+ }
309
+ }
310
+
311
+ return env;
312
+ }
313
+
314
+ function readAuthctlAccessVaultField(env: Record<string, string | undefined>, field: string) {
315
+ const vault = Bun.which("vault");
316
+ if (!vault) {
317
+ return "";
318
+ }
319
+
320
+ const mount = env.VAULT_AUTHCTL_ACCESS_MOUNT?.trim() || env.VAULT_SECRET_MOUNT?.trim() || "secret";
321
+ const path = env.VAULT_AUTHCTL_ACCESS_PATH?.trim() || "prod/apps/auth/authctl/cloudflare-access";
322
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
323
+ cwd: process.cwd(),
324
+ env,
325
+ stdout: "pipe",
326
+ stderr: "pipe",
327
+ });
328
+
329
+ if (!result.success || !result.stdout) {
330
+ return "";
331
+ }
332
+
333
+ return decoder.decode(result.stdout).trim();
334
+ }
@@ -1,4 +1,5 @@
1
1
  import { confirm, isCancel, log } from "@clack/prompts";
2
+ import { deleteAuthResourceServer } from "../authctl";
2
3
  import { config } from "./config";
3
4
  import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
4
5
  import {
@@ -11,6 +12,7 @@ import {
11
12
  describeCloudRunService,
12
13
  describeProductionDomainMapping,
13
14
  describeSecret,
15
+ formatError,
14
16
  listCloudRunServices,
15
17
  listSecrets,
16
18
  parseCleanupArgs,
@@ -34,18 +36,46 @@ function matchesSecretResource(name: string) {
34
36
  );
35
37
  }
36
38
 
39
+ type PlannedResource = {
40
+ label: string;
41
+ detail?: string;
42
+ };
43
+
44
+ type DestroyPlan = {
45
+ resources: PlannedResource[];
46
+ skipped: PlannedResource[];
47
+ blockers: string[];
48
+ hasProductionDomainMapping: boolean;
49
+ serviceNames: string[];
50
+ secretNames: string[];
51
+ neon?: {
52
+ projectId: string;
53
+ baseBranchId: string;
54
+ databaseName: string;
55
+ branches: Array<{ id: string; name: string }>;
56
+ };
57
+ };
58
+
37
59
  export async function cleanup(args = Bun.argv.slice(2)) {
38
60
  requireCommand("gcloud");
39
61
  requireGcloudAuth();
40
62
 
41
63
  const options = parseCleanupArgs(args);
64
+ const plan = await runStep("Planning resources to destroy", () => buildDestroyPlan(options.destroyProject));
65
+ printDestroyPlan(plan);
66
+ if (plan.blockers.length > 0) {
67
+ throw new Error(["Destroy cannot continue until resource discovery succeeds:", ...plan.blockers.map((blocker) => `- ${blocker}`)].join("\n"));
68
+ }
69
+
42
70
  await requireDestroyConfirmation(options.force);
43
71
 
44
- await runStep(`Verifying production domain mapping ${config.domain.hostname}`, () => assertProductionDomainMappingOwned());
45
- await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
72
+ await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
46
73
 
47
- const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
48
- const serviceNames = services.filter(matchesServiceResource);
74
+ if (plan.hasProductionDomainMapping) {
75
+ await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
76
+ }
77
+
78
+ const serviceNames = plan.serviceNames;
49
79
  await runStep("Deleting Cloud Run services", () => {
50
80
  for (const serviceName of serviceNames) {
51
81
  assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
@@ -53,8 +83,7 @@ export async function cleanup(args = Bun.argv.slice(2)) {
53
83
  }
54
84
  });
55
85
 
56
- const secrets = await runStep("Finding service secrets", () => listSecrets());
57
- const secretNames = secrets.filter(matchesSecretResource);
86
+ const secretNames = plan.secretNames;
58
87
  await runStep("Deleting service secrets", () => {
59
88
  for (const secretName of secretNames) {
60
89
  assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
@@ -62,24 +91,15 @@ export async function cleanup(args = Bun.argv.slice(2)) {
62
91
  }
63
92
  });
64
93
 
65
- try {
66
- const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
67
- const branches = await runStep("Finding Neon branches", () => listBranches(neon.projectId));
68
- const disposableBranches = branches.filter(
69
- (branch: { name: string }) =>
70
- branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
71
- );
72
-
94
+ const neonPlan = plan.neon;
95
+ if (neonPlan) {
73
96
  await runStep("Deleting Neon preview and personal branches", async () => {
74
- for (const branch of disposableBranches) {
75
- await deleteBranch(neon.projectId, branch.id);
97
+ for (const branch of neonPlan.branches) {
98
+ await deleteBranch(neonPlan.projectId, branch.id);
76
99
  }
77
100
  });
78
101
 
79
- await runStep("Deleting Neon service database", () => deleteDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
80
- } catch (error) {
81
- log.step("Skipping Neon cleanup because Neon is not configured");
82
- log.step(error instanceof Error ? error.message : String(error));
102
+ await runStep("Deleting Neon service database", () => deleteDatabase(neonPlan.projectId, neonPlan.baseBranchId, neonPlan.databaseName));
83
103
  }
84
104
 
85
105
  await runStep("Deleting Grafana resources", async () => deleteGrafanaResources());
@@ -97,30 +117,144 @@ export async function cleanup(args = Bun.argv.slice(2)) {
97
117
  return `Destroy finished for ${config.serviceName}`;
98
118
  }
99
119
 
100
- async function deleteGrafanaResources() {
101
- if (!(await Bun.file("./grafana").exists())) {
102
- return "No grafana directory configured";
120
+ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
121
+ const plan: DestroyPlan = {
122
+ resources: [
123
+ { label: `Auth resource server ${config.serviceName}`, detail: "stage prod" },
124
+ { label: `Runtime service account ${config.runtimeServiceAccount}`, detail: "if it exists" },
125
+ ],
126
+ skipped: [],
127
+ blockers: [],
128
+ hasProductionDomainMapping: false,
129
+ serviceNames: [],
130
+ secretNames: [],
131
+ };
132
+
133
+ planProductionDomainMapping(plan);
134
+ planCloudRunServices(plan);
135
+ planSecrets(plan);
136
+ await planNeon(plan);
137
+ await planGrafana(plan);
138
+
139
+ if (destroyProject) {
140
+ plan.resources.push({ label: `GCP project ${config.project.id}`, detail: "requested with --project" });
103
141
  }
104
- if (!Bun.which("gcx")) {
105
- return "gcx is not installed; Grafana resources were not deleted";
142
+
143
+ return plan;
144
+ }
145
+
146
+ function planProductionDomainMapping(plan: DestroyPlan) {
147
+ try {
148
+ const mapping = describeProductionDomainMapping();
149
+ if (!mapping) {
150
+ plan.skipped.push({ label: `Production domain mapping ${config.domain.hostname}`, detail: "not found" });
151
+ return;
152
+ }
153
+
154
+ const routeName = mapping.spec?.routeName;
155
+ if (routeName !== config.serviceName) {
156
+ plan.blockers.push(`${config.domain.hostname} maps to ${routeName || "an unknown service"}; refusing to delete ambiguous DNS mapping`);
157
+ return;
158
+ }
159
+
160
+ assertOwnedResource(`Cloud Run service ${routeName}`, describeCloudRunService(routeName));
161
+ plan.hasProductionDomainMapping = true;
162
+ plan.resources.push({ label: `Production domain mapping ${config.domain.hostname}`, detail: `routes to ${routeName}` });
163
+ } catch (error) {
164
+ plan.blockers.push(`Production domain mapping ${config.domain.hostname}: ${formatError(error)}`);
106
165
  }
166
+ }
107
167
 
108
- run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"]);
109
- return "Grafana resources deleted from local manifests";
168
+ function planCloudRunServices(plan: DestroyPlan) {
169
+ try {
170
+ plan.serviceNames = listCloudRunServices().filter(matchesServiceResource);
171
+ if (plan.serviceNames.length === 0) {
172
+ plan.skipped.push({ label: `Cloud Run services in ${config.project.id}/${config.region}`, detail: "none matched" });
173
+ return;
174
+ }
175
+ for (const serviceName of plan.serviceNames) {
176
+ plan.resources.push({ label: `Cloud Run service ${serviceName}`, detail: `${config.project.id}/${config.region}` });
177
+ }
178
+ } catch (error) {
179
+ plan.blockers.push(`Cloud Run services in ${config.project.id}/${config.region}: ${formatError(error)}`);
180
+ }
181
+ }
182
+
183
+ function planSecrets(plan: DestroyPlan) {
184
+ try {
185
+ plan.secretNames = listSecrets().filter(matchesSecretResource);
186
+ if (plan.secretNames.length === 0) {
187
+ plan.skipped.push({ label: `Secret Manager secrets in ${config.project.id}`, detail: "none matched" });
188
+ return;
189
+ }
190
+ for (const secretName of plan.secretNames) {
191
+ plan.resources.push({ label: `Secret Manager secret ${secretName}`, detail: config.project.id });
192
+ }
193
+ } catch (error) {
194
+ plan.blockers.push(`Secret Manager secrets in ${config.project.id}: ${formatError(error)}`);
195
+ }
196
+ }
197
+
198
+ async function planNeon(plan: DestroyPlan) {
199
+ try {
200
+ const neon = await resolveNeonConfig();
201
+ const branches = await listBranches(neon.projectId);
202
+ const disposableBranches = branches.filter(
203
+ (branch: { name: string }) =>
204
+ branch.name.startsWith(`${neon.previewBranchPrefix}-`) || branch.name.startsWith(`${neon.personalBranchPrefix}-`)
205
+ );
206
+
207
+ plan.neon = {
208
+ projectId: neon.projectId,
209
+ baseBranchId: neon.baseBranchId,
210
+ databaseName: neon.databaseName,
211
+ branches: disposableBranches,
212
+ };
213
+ plan.resources.push({ label: `Neon database ${neon.databaseName}`, detail: `${neon.projectId}/${neon.baseBranchName}` });
214
+ for (const branch of disposableBranches) {
215
+ plan.resources.push({ label: `Neon branch ${branch.name}`, detail: neon.projectId });
216
+ }
217
+ } catch (error) {
218
+ plan.skipped.push({ label: "Neon resources", detail: formatError(error) });
219
+ }
110
220
  }
111
221
 
112
- function assertProductionDomainMappingOwned() {
113
- const mapping = describeProductionDomainMapping();
114
- if (!mapping) {
222
+ async function planGrafana(plan: DestroyPlan) {
223
+ if (!(await Bun.file("./grafana").exists())) {
224
+ plan.skipped.push({ label: "Grafana resources", detail: "no ./grafana directory" });
225
+ return;
226
+ }
227
+ if (!Bun.which("gcx")) {
228
+ plan.skipped.push({ label: "Grafana resources", detail: "gcx is not installed" });
115
229
  return;
116
230
  }
231
+ plan.resources.push({ label: "Grafana resources", detail: "./grafana manifests" });
232
+ }
233
+
234
+ function printDestroyPlan(plan: DestroyPlan) {
235
+ const lines = [
236
+ "Resources selected for destroy:",
237
+ ...plan.resources.map((resource) => `- ${resource.label}${resource.detail ? ` (${resource.detail})` : ""}`),
238
+ ];
239
+ if (plan.skipped.length > 0) {
240
+ lines.push("", "Skipped or not found:", ...plan.skipped.map((resource) => `- ${resource.label}${resource.detail ? ` (${resource.detail})` : ""}`));
241
+ }
242
+ if (plan.blockers.length > 0) {
243
+ lines.push("", "Discovery blockers:", ...plan.blockers.map((blocker) => `- ${blocker}`));
244
+ }
245
+ log.step(lines.join("\n"));
246
+ }
117
247
 
118
- const routeName = mapping.spec?.routeName;
119
- if (routeName !== config.serviceName) {
120
- throw new Error(`${config.domain.hostname} maps to ${routeName || "an unknown service"}; refusing to delete ambiguous DNS mapping`);
248
+ async function deleteGrafanaResources() {
249
+ if (!(await Bun.file("./grafana").exists())) {
250
+ return "No grafana directory configured";
251
+ }
252
+ if (!Bun.which("gcx")) {
253
+ return "gcx is not installed; Grafana resources were not deleted";
121
254
  }
122
255
 
123
- assertOwnedResource(`Cloud Run service ${routeName}`, describeCloudRunService(routeName));
256
+ run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"]);
257
+ return "Grafana resources deleted from local manifests";
124
258
  }
125
259
 
126
260
  async function requireDestroyConfirmation(force: boolean) {
@@ -161,7 +161,8 @@ export function ensureProject() {
161
161
  }
162
162
 
163
163
  export function attachBilling() {
164
- if (config.project.mode === "use_existing") {
164
+ const projectMode = config.project.mode as "create_new" | "use_existing";
165
+ if (projectMode === "use_existing") {
165
166
  return "Using existing project billing";
166
167
  }
167
168
  gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);