create-svc 0.1.35 → 0.1.36

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.36",
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: [],
@@ -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
 
@@ -108,19 +110,27 @@ export function buildGcpProjectOptions(
108
110
  projectName: string,
109
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 [
112
116
  {
113
- label: buildCreateProjectLabel(serviceName, projectId),
114
- mode: "create_new" as const,
115
- projectId,
116
- projectName,
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,
117
121
  },
118
- ...projects.map((project) => ({
122
+ ...remainingProjects.map((project) => ({
119
123
  label: `Use existing project: ${project.name} (${project.projectId})`,
120
124
  mode: "use_existing" as const,
121
125
  projectId: project.projectId,
122
126
  projectName: project.name,
123
127
  })),
128
+ {
129
+ label: buildCreateProjectLabel(serviceName, projectId),
130
+ mode: "create_new" as const,
131
+ projectId,
132
+ projectName,
133
+ },
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