create-svc 0.1.35 → 0.1.37

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 CHANGED
@@ -54,6 +54,10 @@ By default, that scaffolds the repo, installs dependencies, runs the generated
54
54
  repo's `service create`, deploys once, verifies production, starts local dev,
55
55
  and verifies local. Pass `--no-auto-deploy` for scaffold-only generation.
56
56
 
57
+ Cloud Run services default to the shared existing GCP project `anmho-services`.
58
+ Override with `--project-id <id>` or explicitly opt into per-service project
59
+ creation with `--project-mode create_new`.
60
+
57
61
  `--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
58
62
 
59
63
  By default, a standalone generated service is initialized as a git repository,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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",
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import pc from "picocolors";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { basename, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, runPostScaffoldFlow } from "./post-scaffold";
6
+ import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, runPostScaffoldFlow, runPreGitBootstrapFlow } from "./post-scaffold";
7
7
  import {
8
8
  bootstrapGitHubRepository,
9
9
  buildGitBootstrapConfig,
@@ -15,6 +15,8 @@ import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, t
15
15
  import {
16
16
  BILLING_ACCOUNT_DEFAULT,
17
17
  QUOTA_PROJECT_DEFAULT,
18
+ SERVICES_PROJECT_DEFAULT,
19
+ SERVICES_PROJECT_NAME_DEFAULT,
18
20
  deriveDefaults,
19
21
  frameworksForTargetRuntime,
20
22
  parseDeployTarget,
@@ -108,6 +110,11 @@ export async function run(argv: string[]) {
108
110
  await scaffoldProject(config);
109
111
  buildSpinner.stop("Project files generated");
110
112
 
113
+ const preGitResult = runPreGitBootstrapFlow(config, targetDir);
114
+ if (preGitResult.changed) {
115
+ log.step("Generated local SDK artifacts before initial GitHub push");
116
+ }
117
+
111
118
  const gitSpinner = spinner();
112
119
  gitSpinner.start("Preparing git repository");
113
120
  const gitResult = await bootstrapGitHubRepository(targetDir, config.git);
@@ -798,6 +805,15 @@ async function resolveGcpSelection(
798
805
  discovery: DiscoveryState,
799
806
  options: { allowBack?: boolean } = {}
800
807
  ): Promise<GcpSelection | typeof BACK> {
808
+ if (args.gcpProject && !args.gcpProjectMode) {
809
+ const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
810
+ return {
811
+ mode: "use_existing" as const,
812
+ projectId: args.gcpProject,
813
+ projectName: existing?.name ?? args.gcpProject,
814
+ };
815
+ }
816
+
801
817
  if (args.gcpProjectMode && args.gcpProject) {
802
818
  const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
803
819
  return {
@@ -819,22 +835,18 @@ async function resolveGcpSelection(
819
835
  const existing = discovery.projects.find((project) => project.projectId === args.gcpProject);
820
836
  return {
821
837
  mode: "use_existing" as const,
822
- projectId: args.gcpProject ?? discovery.projects[0]?.projectId ?? defaults.projectId,
823
- projectName: existing?.name ?? args.gcpProject ?? defaults.projectName,
838
+ projectId: args.gcpProject ?? SERVICES_PROJECT_DEFAULT,
839
+ projectName: existing?.name ?? args.gcpProject ?? SERVICES_PROJECT_NAME_DEFAULT,
824
840
  };
825
841
  }
826
842
 
827
843
  if (args.yes) {
828
- return {
829
- mode: "create_new" as const,
830
- projectId: defaults.projectId,
831
- projectName: defaults.projectName,
832
- };
844
+ return sharedServicesProjectSelection(discovery);
833
845
  }
834
846
 
835
847
  const mode = await select({
836
848
  message: "GCP project",
837
- initialValue: "create_new",
849
+ initialValue: "use_shared",
838
850
  options: [
839
851
  ...(options.allowBack
840
852
  ? [
@@ -845,16 +857,20 @@ async function resolveGcpSelection(
845
857
  },
846
858
  ]
847
859
  : []),
860
+ {
861
+ value: "use_shared",
862
+ label: `Use shared services project: ${sharedServicesProjectSelection(discovery).projectName} (${SERVICES_PROJECT_DEFAULT})`,
863
+ hint: "Default",
864
+ },
848
865
  {
849
866
  value: "create_new",
850
867
  label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
851
- hint: "Default",
868
+ hint: "May hit billing quota limits",
852
869
  },
853
870
  {
854
871
  value: "use_existing",
855
- label: "Use existing project...",
856
- hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "Unavailable",
857
- disabled: discovery.projects.length === 0,
872
+ label: "Use another existing project...",
873
+ hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "Pass --project-id to use an undiscovered project",
858
874
  },
859
875
  ],
860
876
  });
@@ -868,6 +884,10 @@ async function resolveGcpSelection(
868
884
  return BACK;
869
885
  }
870
886
 
887
+ if (mode === "use_shared") {
888
+ return sharedServicesProjectSelection(discovery);
889
+ }
890
+
871
891
  if (mode === "create_new") {
872
892
  return {
873
893
  mode: "create_new" as const,
@@ -877,7 +897,7 @@ async function resolveGcpSelection(
877
897
  }
878
898
 
879
899
  if (discovery.projects.length === 0) {
880
- throw new Error("No existing GCP projects were discovered");
900
+ return sharedServicesProjectSelection(discovery);
881
901
  }
882
902
 
883
903
  const selected = await promptForExistingProject(discovery.projects, options);
@@ -904,6 +924,15 @@ async function resolveGcpSelection(
904
924
  };
905
925
  }
906
926
 
927
+ function sharedServicesProjectSelection(discovery: DiscoveryState): GcpSelection {
928
+ const project = discovery.projects.find((candidate) => candidate.projectId === SERVICES_PROJECT_DEFAULT);
929
+ return {
930
+ mode: "use_existing" as const,
931
+ projectId: SERVICES_PROJECT_DEFAULT,
932
+ projectName: project?.name ?? SERVICES_PROJECT_NAME_DEFAULT,
933
+ };
934
+ }
935
+
907
936
  async function discoverCloudInputs(): Promise<DiscoveryState> {
908
937
  const result: DiscoveryState = {
909
938
  projects: [],
@@ -1102,7 +1131,7 @@ async function promptForExistingProject(projects: GcpProject[], options: { allow
1102
1131
  : []),
1103
1132
  ...projects.map((project) => ({
1104
1133
  value: project.projectId,
1105
- label: project.name,
1134
+ label: project.name ?? project.projectId,
1106
1135
  hint: project.projectId,
1107
1136
  })),
1108
1137
  ],
@@ -1122,7 +1151,7 @@ async function promptForExistingProject(projects: GcpProject[], options: { allow
1122
1151
  return {
1123
1152
  mode: "use_existing" as const,
1124
1153
  projectId: project.projectId,
1125
- projectName: project.name,
1154
+ projectName: project.name ?? project.projectId,
1126
1155
  };
1127
1156
  }
1128
1157
 
package/src/gcp.test.ts CHANGED
@@ -23,6 +23,42 @@ test("listAccessibleProjects filters deleted projects and sorts by name", async
23
23
  ]);
24
24
  });
25
25
 
26
+ test("listAccessibleProjects tolerates projects without a display name", async () => {
27
+ const api: GcpApi = {
28
+ async listProjects() {
29
+ return [{ projectId: "b" }, { projectId: "a", name: "alpha" }];
30
+ },
31
+ async listBillingAccounts() {
32
+ return [];
33
+ },
34
+ async createProject() {},
35
+ async attachBillingAccount() {},
36
+ };
37
+
38
+ await expect(listAccessibleProjects(api)).resolves.toEqual([{ projectId: "a", name: "alpha" }, { projectId: "b" }]);
39
+ });
40
+
41
+ test("listOpenBillingAccounts tolerates accounts without a display name", async () => {
42
+ const api: GcpApi = {
43
+ async listProjects() {
44
+ return [];
45
+ },
46
+ async listBillingAccounts() {
47
+ return [
48
+ { name: "billingAccounts/2", displayName: "", open: true },
49
+ { name: "billingAccounts/1", displayName: "A", open: true },
50
+ ];
51
+ },
52
+ async createProject() {},
53
+ async attachBillingAccount() {},
54
+ };
55
+
56
+ await expect(listOpenBillingAccounts(api)).resolves.toEqual([
57
+ { name: "billingAccounts/1", displayName: "A", open: true },
58
+ { name: "billingAccounts/2", displayName: "", open: true },
59
+ ]);
60
+ });
61
+
26
62
  test("listOpenBillingAccounts keeps only open accounts", async () => {
27
63
  const api: GcpApi = {
28
64
  async listProjects() {
package/src/gcp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type GcpProject = {
2
2
  projectId: string;
3
- name: string;
3
+ name?: string;
4
4
  lifecycleState?: string;
5
5
  };
6
6
 
@@ -47,13 +47,13 @@ export function createGcpApi(): GcpApi {
47
47
  export async function listAccessibleProjects(api = createGcpApi()): Promise<GcpProject[]> {
48
48
  return (await api.listProjects())
49
49
  .filter((project) => project.projectId && project.lifecycleState !== "DELETE_REQUESTED")
50
- .sort((left, right) => left.name.localeCompare(right.name));
50
+ .sort((left, right) => projectSortName(left).localeCompare(projectSortName(right)));
51
51
  }
52
52
 
53
53
  export async function listOpenBillingAccounts(api = createGcpApi()): Promise<BillingAccount[]> {
54
54
  return (await api.listBillingAccounts())
55
55
  .filter((account) => account.name && account.open)
56
- .sort((left, right) => left.displayName.localeCompare(right.displayName));
56
+ .sort((left, right) => accountSortName(left).localeCompare(accountSortName(right)));
57
57
  }
58
58
 
59
59
  export async function createProject(projectId: string, name: string, api = createGcpApi()) {
@@ -79,6 +79,14 @@ function runGcloud(args: string[]) {
79
79
  };
80
80
  }
81
81
 
82
+ function projectSortName(project: GcpProject) {
83
+ return project.name || project.projectId;
84
+ }
85
+
86
+ function accountSortName(account: BillingAccount) {
87
+ return account.displayName || account.name;
88
+ }
89
+
82
90
  function parseJson<T>(value: string, label: string): T {
83
91
  try {
84
92
  return JSON.parse(value) as T;
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { buildGitBootstrapConfig, findExistingGitWorktree, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
5
+ import { buildGitBootstrapConfig, findExistingGitWorktree, manualGitHubDeleteCommand, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
6
6
 
7
7
  test("buildGitBootstrapConfig defaults to anmho private repo creation", () => {
8
8
  expect(buildGitBootstrapConfig("launch-api", undefined)).toEqual({
@@ -20,6 +20,10 @@ test("buildGitBootstrapConfig honors --no-git", () => {
20
20
  });
21
21
  });
22
22
 
23
+ test("manualGitHubDeleteCommand formats the advisory cleanup command", () => {
24
+ expect(manualGitHubDeleteCommand("anmho/launch-api")).toBe("gh repo delete anmho/launch-api --yes");
25
+ });
26
+
23
27
  test("findExistingGitWorktree detects parent repositories", async () => {
24
28
  const root = await mkdtemp(join(tmpdir(), "create-svc-git-"));
25
29
  run(["git", "init", "-b", "main"], root);
@@ -21,6 +21,10 @@ export function buildGitBootstrapConfig(serviceName: string, noGit: boolean | un
21
21
  };
22
22
  }
23
23
 
24
+ export function manualGitHubDeleteCommand(repository: string) {
25
+ return `gh repo delete ${repository} --yes`;
26
+ }
27
+
24
28
  export async function bootstrapGitHubRepository(targetDir: string, config: GitBootstrapConfig): Promise<GitBootstrapResult> {
25
29
  if (!config.enabled) {
26
30
  return { status: "disabled" };
@@ -1,5 +1,12 @@
1
1
  import { expect, test } from "bun:test";
2
- import { buildGcpProjectOptions, compactDatabaseName, compactIdentifier, deriveDefaults, deriveLocalPostgresPort } from "./naming";
2
+ import {
3
+ SERVICES_PROJECT_DEFAULT,
4
+ buildGcpProjectOptions,
5
+ compactDatabaseName,
6
+ compactIdentifier,
7
+ deriveDefaults,
8
+ deriveLocalPostgresPort,
9
+ } from "./naming";
3
10
 
4
11
  test("deriveDefaults uses the service name for project, repo, and database naming", () => {
5
12
  expect(deriveDefaults("edge-api")).toEqual({
@@ -25,16 +32,17 @@ test("compactDatabaseName switches to underscores", () => {
25
32
  expect(compactDatabaseName("preview-worker")).toBe("preview_worker");
26
33
  });
27
34
 
28
- test("buildGcpProjectOptions puts create-new first", () => {
35
+ test("buildGcpProjectOptions puts the shared services project first", () => {
29
36
  const options = buildGcpProjectOptions("preview-worker", "anmho-preview-worker", "preview-worker", [
30
37
  { projectId: "anmho-existing", name: "existing" },
31
38
  ]);
32
39
 
33
40
  expect(options[0]).toEqual({
34
- label: "Create new project: preview-worker (anmho-preview-worker)",
35
- mode: "create_new",
36
- projectId: "anmho-preview-worker",
37
- projectName: "preview-worker",
41
+ label: `Use shared services project: services (${SERVICES_PROJECT_DEFAULT})`,
42
+ mode: "use_existing",
43
+ projectId: "anmho-services",
44
+ projectName: "services",
38
45
  });
39
46
  expect(options[1]?.mode).toBe("use_existing");
47
+ expect(options[2]?.mode).toBe("create_new");
40
48
  });
package/src/naming.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export const BILLING_ACCOUNT_DEFAULT = "billingAccounts/01BD2E-3A6949-8F4C84";
2
2
  export const QUOTA_PROJECT_DEFAULT = "anmho-infra-prod";
3
+ export const SERVICES_PROJECT_DEFAULT = "anmho-services";
4
+ export const SERVICES_PROJECT_NAME_DEFAULT = "services";
3
5
 
4
6
  export const DEPLOY_TARGETS = ["cloudrun", "workers"] as const;
5
7
 
@@ -106,21 +108,29 @@ export function buildGcpProjectOptions(
106
108
  serviceName: string,
107
109
  projectId: string,
108
110
  projectName: string,
109
- projects: Array<{ projectId: string; name: string }>
111
+ projects: Array<{ projectId: string; name?: string }>
110
112
  ) {
113
+ const servicesProject = projects.find((project) => project.projectId === SERVICES_PROJECT_DEFAULT);
114
+ const remainingProjects = projects.filter((project) => project.projectId !== SERVICES_PROJECT_DEFAULT);
111
115
  return [
116
+ {
117
+ label: `Use shared services project: ${servicesProject?.name ?? SERVICES_PROJECT_NAME_DEFAULT} (${SERVICES_PROJECT_DEFAULT})`,
118
+ mode: "use_existing" as const,
119
+ projectId: SERVICES_PROJECT_DEFAULT,
120
+ projectName: servicesProject?.name ?? SERVICES_PROJECT_NAME_DEFAULT,
121
+ },
122
+ ...remainingProjects.map((project) => ({
123
+ label: `Use existing project: ${project.name ?? project.projectId} (${project.projectId})`,
124
+ mode: "use_existing" as const,
125
+ projectId: project.projectId,
126
+ projectName: project.name ?? project.projectId,
127
+ })),
112
128
  {
113
129
  label: buildCreateProjectLabel(serviceName, projectId),
114
130
  mode: "create_new" as const,
115
131
  projectId,
116
132
  projectName,
117
133
  },
118
- ...projects.map((project) => ({
119
- label: `Use existing project: ${project.name} (${project.projectId})`,
120
- mode: "use_existing" as const,
121
- projectId: project.projectId,
122
- projectName: project.name,
123
- })),
124
134
  ];
125
135
  }
126
136
 
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, buildPostScaffoldCommands } from "./post-scaffold";
2
+ import {
3
+ buildDeploymentVerificationCommands,
4
+ buildLocalVerificationCommands,
5
+ buildPostScaffoldCommands,
6
+ buildPreGitBootstrapCommands,
7
+ } from "./post-scaffold";
3
8
 
4
9
  describe("buildPostScaffoldCommands", () => {
5
10
  test("runs create for HTTP services", () => {
@@ -8,9 +13,8 @@ describe("buildPostScaffoldCommands", () => {
8
13
  ]);
9
14
  });
10
15
 
11
- test("builds SDK artifacts before create for ConnectRPC services", () => {
16
+ test("runs create for ConnectRPC services after pre-git SDK preparation", () => {
12
17
  expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
13
- { command: "service", args: ["sdk", "build"] },
14
18
  { command: "service", args: ["create"] },
15
19
  ]);
16
20
  });
@@ -22,6 +26,17 @@ describe("buildPostScaffoldCommands", () => {
22
26
  });
23
27
  });
24
28
 
29
+ describe("buildPreGitBootstrapCommands", () => {
30
+ test("builds SDK artifacts before the initial GitHub push for ConnectRPC services", () => {
31
+ expect(buildPreGitBootstrapCommands({ framework: "connectrpc" })).toEqual([{ command: "service", args: ["sdk", "build"] }]);
32
+ });
33
+
34
+ test("does not build SDK artifacts for HTTP or Workers services", () => {
35
+ expect(buildPreGitBootstrapCommands({ framework: "hono" })).toEqual([]);
36
+ expect(buildPreGitBootstrapCommands({ target: "workers", framework: "connectrpc" })).toEqual([]);
37
+ });
38
+ });
39
+
25
40
  describe("buildLocalVerificationCommands", () => {
26
41
  test("uses local curl checks for Bun Hono services", () => {
27
42
  expect(buildLocalVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
@@ -44,6 +44,19 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
44
44
  return { message: "Backend package generated" };
45
45
  }
46
46
 
47
+ export function runPreGitBootstrapFlow(config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>, cwd: string) {
48
+ const commands = buildPreGitBootstrapCommands(config);
49
+ if (commands.length === 0) {
50
+ return { changed: false };
51
+ }
52
+
53
+ installProjectDependencies(cwd);
54
+ for (const command of commands) {
55
+ run(command.command, command.args, { cwd });
56
+ }
57
+ return { changed: true };
58
+ }
59
+
47
60
  async function startLocalDevelopment(config: Pick<ScaffoldConfig, "target">, cwd: string) {
48
61
  run("bun", ["run", "migrate"], { cwd });
49
62
  await mkdir(join(cwd, ".service"), { recursive: true });
@@ -222,10 +235,16 @@ function localVerificationHost(config: Partial<Pick<ScaffoldConfig, "target" | "
222
235
  export function buildPostScaffoldCommands(
223
236
  config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
224
237
  ): PostScaffoldCommand[] {
225
- return [
226
- ...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "service", args: ["sdk", "build"] }] : []),
227
- { command: "service", args: ["create"] },
228
- ];
238
+ return [{ command: "service", args: ["create"] }];
239
+ }
240
+
241
+ export function buildPreGitBootstrapCommands(
242
+ config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
243
+ ): PostScaffoldCommand[] {
244
+ if (config.target === "workers" || config.framework !== "connectrpc") {
245
+ return [];
246
+ }
247
+ return [{ command: "service", args: ["sdk", "build"] }];
229
248
  }
230
249
 
231
250
  function installProjectDependencies(cwd: string) {
@@ -1,6 +1,8 @@
1
1
  import { confirm, isCancel, log } from "@clack/prompts";
2
2
  import { deleteAuthResourceServer } from "../authctl";
3
+ import { manualGitHubDeleteCommand } from "../../git-bootstrap";
3
4
  import { buildLocalDevCleanupPlan, stopLocalDev } from "../local-dev";
5
+ import { runParallelTasks, type ParallelTask } from "../parallel-tasks";
4
6
  import { config } from "./config";
5
7
  import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
6
8
  import {
@@ -74,65 +76,105 @@ export async function cleanup(args = Bun.argv.slice(2)) {
74
76
 
75
77
  await requireDestroyConfirmation(options.force);
76
78
 
77
- await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
79
+ await deletePlannedResources(plan);
78
80
 
79
- if (plan.githubRepository) {
80
- await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
81
- }
82
-
83
- await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
84
-
85
- if (plan.hasProductionDomainMapping) {
86
- await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
81
+ if (options.destroyProject) {
82
+ await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
83
+ return `Deleted project ${config.project.id}`;
87
84
  }
88
85
 
89
- const serviceNames = plan.serviceNames;
90
- await runStep("Deleting Cloud Run services", () => {
91
- for (const serviceName of serviceNames) {
92
- assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
93
- deleteService(serviceName);
94
- }
95
- });
86
+ log.step(`Production API hostname released: ${config.domain.hostname}`);
87
+ return `Destroy finished for ${config.serviceName}`;
88
+ }
96
89
 
97
- const artifactImages = plan.artifactImages;
98
- await runStep("Deleting Artifact Registry images", () => {
99
- for (const image of artifactImages) {
100
- deleteArtifactImage(image);
101
- }
102
- });
90
+ async function deletePlannedResources(plan: DestroyPlan) {
91
+ const tasks: ParallelTask[] = [
92
+ {
93
+ label: "Stopping local dev resources",
94
+ task: () => stopLocalDev({ dockerCompose: true, removeVolumes: true }),
95
+ },
96
+ {
97
+ label: `Deleting auth resource server ${config.serviceName}`,
98
+ task: () => deleteAuthResourceServer(),
99
+ },
100
+ {
101
+ label: "Deleting Cloud Run services",
102
+ task: () => {
103
+ for (const serviceName of plan.serviceNames) {
104
+ assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
105
+ deleteService(serviceName);
106
+ }
107
+ },
108
+ },
109
+ {
110
+ label: "Deleting Artifact Registry images",
111
+ task: () => {
112
+ for (const image of plan.artifactImages) {
113
+ deleteArtifactImage(image);
114
+ }
115
+ },
116
+ },
117
+ {
118
+ label: "Deleting service secrets",
119
+ task: () => {
120
+ for (const secretName of plan.secretNames) {
121
+ assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
122
+ deleteSecret(secretName);
123
+ }
124
+ },
125
+ },
126
+ {
127
+ label: "Deleting Grafana resources",
128
+ task: () => deleteGrafanaResources(),
129
+ },
130
+ {
131
+ label: "Deleting service-specific identity resources",
132
+ task: () => {
133
+ deleteServiceAccount(config.runtimeServiceAccount);
134
+ },
135
+ },
136
+ ];
103
137
 
104
- const secretNames = plan.secretNames;
105
- await runStep("Deleting service secrets", () => {
106
- for (const secretName of secretNames) {
107
- assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
108
- deleteSecret(secretName);
109
- }
110
- });
138
+ if (plan.githubRepository) {
139
+ tasks.push({
140
+ label: `Deleting GitHub repository ${plan.githubRepository}`,
141
+ task: () => deleteGitHubRepository(plan.githubRepository!),
142
+ });
143
+ }
111
144
 
112
- const neonPlan = plan.neon;
113
- if (neonPlan) {
114
- await runStep("Deleting Neon preview and personal branches", async () => {
115
- for (const branch of neonPlan.branches) {
116
- await deleteBranch(neonPlan.projectId, branch.id);
117
- }
145
+ if (plan.hasProductionDomainMapping) {
146
+ tasks.push({
147
+ label: `Deleting production domain mapping ${config.domain.hostname}`,
148
+ task: () => deleteProductionDomainMapping(),
118
149
  });
150
+ }
119
151
 
120
- await runStep("Deleting Neon service database", () => deleteDatabase(neonPlan.projectId, neonPlan.baseBranchId, neonPlan.databaseName));
152
+ if (plan.neon) {
153
+ const neonPlan = plan.neon;
154
+ tasks.push(
155
+ {
156
+ label: "Deleting Neon preview and personal branches",
157
+ task: async () => {
158
+ await Promise.all(neonPlan.branches.map((branch) => deleteBranch(neonPlan.projectId, branch.id)));
159
+ },
160
+ },
161
+ {
162
+ label: "Deleting Neon service database",
163
+ task: () => deleteDatabase(neonPlan.projectId, neonPlan.baseBranchId, neonPlan.databaseName),
164
+ }
165
+ );
121
166
  }
122
167
 
123
- await runStep("Deleting Grafana resources", async () => deleteGrafanaResources());
168
+ await runDestroyTasks(tasks);
169
+ }
124
170
 
125
- await runStep("Deleting service-specific identity resources", () => {
126
- deleteServiceAccount(config.runtimeServiceAccount);
171
+ async function runDestroyTasks(tasks: Array<{ label: string; task: () => Promise<unknown> | unknown }>) {
172
+ log.step(`Deleting ${tasks.length} resource groups in parallel`);
173
+ await runParallelTasks(tasks, {
174
+ formatError,
175
+ onSuccess: (label) => log.step(`${label}: done`),
176
+ onFailure: (label, error) => log.error(`${label} failed\n${formatError(error)}`),
127
177
  });
128
-
129
- if (options.destroyProject) {
130
- await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
131
- return `Deleted project ${config.project.id}`;
132
- }
133
-
134
- log.step(`Production API hostname released: ${config.domain.hostname}`);
135
- return `Destroy finished for ${config.serviceName}`;
136
178
  }
137
179
 
138
180
  async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
@@ -181,7 +223,7 @@ function planGitHubRepository(plan: DestroyPlan) {
181
223
  if (!config.git.deleteOnDestroy) {
182
224
  plan.skipped.push({
183
225
  label: `GitHub repository ${repository}`,
184
- detail: config.git.enabled ? "not created by this service CLI run" : "git disabled",
226
+ detail: config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled",
185
227
  });
186
228
  return;
187
229
  }
@@ -734,9 +734,24 @@ export function assertServiceNameAvailable(serviceName: string) {
734
734
 
735
735
  export function deleteProductionDomainMapping() {
736
736
  deleteCloudflareDnsRecord();
737
- gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
738
- allowFailure: true,
739
- });
737
+ gcloud(
738
+ [
739
+ "beta",
740
+ "run",
741
+ "domain-mappings",
742
+ "delete",
743
+ "--domain",
744
+ config.domain.hostname,
745
+ "--project",
746
+ config.project.id,
747
+ "--region",
748
+ config.region,
749
+ "--quiet",
750
+ ],
751
+ {
752
+ allowFailure: true,
753
+ }
754
+ );
740
755
  }
741
756
 
742
757
  export function listCloudRunServices() {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { runParallelTasks } from "./parallel-tasks";
3
+
4
+ describe("runParallelTasks", () => {
5
+ test("runs tasks concurrently and reports success as each task finishes", async () => {
6
+ const completed: string[] = [];
7
+ const startedAt = Date.now();
8
+
9
+ await runParallelTasks(
10
+ [
11
+ { label: "slow", task: () => Bun.sleep(60) },
12
+ { label: "fast", task: () => Bun.sleep(10) },
13
+ ],
14
+ { onSuccess: (label) => completed.push(label) }
15
+ );
16
+
17
+ expect(Date.now() - startedAt).toBeLessThan(100);
18
+ expect(completed).toEqual(["fast", "slow"]);
19
+ });
20
+
21
+ test("collects multiple failures before throwing", async () => {
22
+ const failures: string[] = [];
23
+
24
+ await expect(
25
+ runParallelTasks(
26
+ [
27
+ { label: "one", task: () => Promise.reject(new Error("first")) },
28
+ { label: "two", task: () => Promise.reject(new Error("second")) },
29
+ ],
30
+ { onFailure: (label) => failures.push(label) }
31
+ )
32
+ ).rejects.toThrow("Destroy failed for one or more resource groups:");
33
+
34
+ expect(failures.sort()).toEqual(["one", "two"]);
35
+ });
36
+ });
@@ -0,0 +1,35 @@
1
+ export type ParallelTask = {
2
+ label: string;
3
+ task: () => Promise<unknown> | unknown;
4
+ };
5
+
6
+ type ParallelTaskOptions = {
7
+ onSuccess?: (label: string) => void;
8
+ onFailure?: (label: string, error: unknown) => void;
9
+ formatError?: (error: unknown) => string;
10
+ };
11
+
12
+ export async function runParallelTasks(tasks: ParallelTask[], options: ParallelTaskOptions = {}) {
13
+ const failures: string[] = [];
14
+ const format = options.formatError ?? defaultFormatError;
15
+
16
+ await Promise.all(
17
+ tasks.map(async ({ label, task }) => {
18
+ try {
19
+ await task();
20
+ options.onSuccess?.(label);
21
+ } catch (error) {
22
+ failures.push(`${label}: ${format(error)}`);
23
+ options.onFailure?.(label, error);
24
+ }
25
+ })
26
+ );
27
+
28
+ if (failures.length > 0) {
29
+ throw new Error(["Destroy failed for one or more resource groups:", ...failures.map((failure) => `- ${failure}`)].join("\n"));
30
+ }
31
+ }
32
+
33
+ function defaultFormatError(error: unknown) {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
@@ -3,6 +3,7 @@
3
3
  import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
4
4
  import { createApiClient } from "@neondatabase/api-client";
5
5
  import { Client } from "pg";
6
+ import { manualGitHubDeleteCommand } from "../../git-bootstrap";
6
7
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
7
8
  import { stopLocalDev } from "../local-dev";
8
9
  import { serviceConfig } from "../runtime";
@@ -135,7 +136,11 @@ function formatHelp() {
135
136
  function deleteGitHubRepositoryIfOwned() {
136
137
  const repository = `${config.git.owner}/${config.git.repository}`;
137
138
  if (!config.git.deleteOnDestroy) {
138
- log.step(`Skipping GitHub repository ${repository}: ${config.git.enabled ? "not created by this service CLI run" : "git disabled"}`);
139
+ log.step(
140
+ `Skipping GitHub repository ${repository}: ${
141
+ config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled"
142
+ }`
143
+ );
139
144
  return;
140
145
  }
141
146
  run("gh", ["auth", "status"], { capture: true });
@@ -8,7 +8,7 @@ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run wi
8
8
  - a lightweight `{{EXAMPLE_LABEL}}` example surface
9
9
  - local Docker Compose Postgres for first-run development
10
10
  - the `service` CLI for create, deploy, doctor, dashboards, and destroy
11
- - GCP project create with billing and quota-project-aware `gcloud` calls
11
+ - shared GCP project deployment with quota-project-aware `gcloud` calls
12
12
  - Neon-backed remote database provisioning during create and deploy
13
13
  - Better Auth client-credentials resource-server registration through `authctl`
14
14
  - stage-aware waitlist data and trigger ingestion