create-svc 0.1.34 → 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.34",
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,10 +1,13 @@
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 {
7
9
  assertOwnedResource,
10
+ deleteArtifactImage,
8
11
  deleteProject,
9
12
  deleteProductionDomainMapping,
10
13
  deleteSecret,
@@ -15,6 +18,7 @@ import {
15
18
  describeSecret,
16
19
  formatError,
17
20
  listCloudRunServices,
21
+ listArtifactImages,
18
22
  listSecrets,
19
23
  parseCleanupArgs,
20
24
  requireCommand,
@@ -50,6 +54,7 @@ type DestroyPlan = {
50
54
  hasProductionDomainMapping: boolean;
51
55
  serviceNames: string[];
52
56
  secretNames: string[];
57
+ artifactImages: string[];
53
58
  neon?: {
54
59
  projectId: string;
55
60
  baseBranchId: string;
@@ -71,58 +76,105 @@ export async function cleanup(args = Bun.argv.slice(2)) {
71
76
 
72
77
  await requireDestroyConfirmation(options.force);
73
78
 
74
- await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
79
+ await deletePlannedResources(plan);
75
80
 
76
- if (plan.githubRepository) {
77
- await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
81
+ if (options.destroyProject) {
82
+ await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
83
+ return `Deleted project ${config.project.id}`;
78
84
  }
79
85
 
80
- await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
81
-
82
- if (plan.hasProductionDomainMapping) {
83
- await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
84
- }
86
+ log.step(`Production API hostname released: ${config.domain.hostname}`);
87
+ return `Destroy finished for ${config.serviceName}`;
88
+ }
85
89
 
86
- const serviceNames = plan.serviceNames;
87
- await runStep("Deleting Cloud Run services", () => {
88
- for (const serviceName of serviceNames) {
89
- assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
90
- deleteService(serviceName);
91
- }
92
- });
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
+ ];
93
137
 
94
- const secretNames = plan.secretNames;
95
- await runStep("Deleting service secrets", () => {
96
- for (const secretName of secretNames) {
97
- assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
98
- deleteSecret(secretName);
99
- }
100
- });
138
+ if (plan.githubRepository) {
139
+ tasks.push({
140
+ label: `Deleting GitHub repository ${plan.githubRepository}`,
141
+ task: () => deleteGitHubRepository(plan.githubRepository!),
142
+ });
143
+ }
101
144
 
102
- const neonPlan = plan.neon;
103
- if (neonPlan) {
104
- await runStep("Deleting Neon preview and personal branches", async () => {
105
- for (const branch of neonPlan.branches) {
106
- await deleteBranch(neonPlan.projectId, branch.id);
107
- }
145
+ if (plan.hasProductionDomainMapping) {
146
+ tasks.push({
147
+ label: `Deleting production domain mapping ${config.domain.hostname}`,
148
+ task: () => deleteProductionDomainMapping(),
108
149
  });
150
+ }
109
151
 
110
- 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
+ );
111
166
  }
112
167
 
113
- await runStep("Deleting Grafana resources", async () => deleteGrafanaResources());
168
+ await runDestroyTasks(tasks);
169
+ }
114
170
 
115
- await runStep("Deleting service-specific identity resources", () => {
116
- 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)}`),
117
177
  });
118
-
119
- if (options.destroyProject) {
120
- await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
121
- return `Deleted project ${config.project.id}`;
122
- }
123
-
124
- log.step(`Production API hostname released: ${config.domain.hostname}`);
125
- return `Destroy finished for ${config.serviceName}`;
126
178
  }
127
179
 
128
180
  async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
@@ -137,12 +189,14 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
137
189
  hasProductionDomainMapping: false,
138
190
  serviceNames: [],
139
191
  secretNames: [],
192
+ artifactImages: [],
140
193
  };
141
194
 
142
195
  planGitHubRepository(plan);
143
196
  await planLocalDev(plan);
144
197
  planProductionDomainMapping(plan);
145
198
  planCloudRunServices(plan);
199
+ planArtifactImages(plan);
146
200
  planSecrets(plan);
147
201
  await planNeon(plan);
148
202
  await planGrafana(plan);
@@ -169,7 +223,7 @@ function planGitHubRepository(plan: DestroyPlan) {
169
223
  if (!config.git.deleteOnDestroy) {
170
224
  plan.skipped.push({
171
225
  label: `GitHub repository ${repository}`,
172
- 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",
173
227
  });
174
228
  return;
175
229
  }
@@ -236,6 +290,21 @@ function planCloudRunServices(plan: DestroyPlan) {
236
290
  }
237
291
  }
238
292
 
293
+ function planArtifactImages(plan: DestroyPlan) {
294
+ try {
295
+ plan.artifactImages = listArtifactImages();
296
+ if (plan.artifactImages.length === 0) {
297
+ plan.skipped.push({ label: `Artifact Registry images for ${config.serviceName}`, detail: "none matched" });
298
+ return;
299
+ }
300
+ for (const image of plan.artifactImages) {
301
+ plan.resources.push({ label: `Artifact Registry image ${image}`, detail: `${config.project.id}/${config.region}` });
302
+ }
303
+ } catch (error) {
304
+ plan.blockers.push(`Artifact Registry images for ${config.serviceName}: ${formatError(error)}`);
305
+ }
306
+ }
307
+
239
308
  function planSecrets(plan: DestroyPlan) {
240
309
  try {
241
310
  plan.secretNames = listSecrets().filter(matchesSecretResource);
@@ -415,6 +415,32 @@ export function ensureArtifactRepository() {
415
415
  ]);
416
416
  }
417
417
 
418
+ export function artifactImageBase() {
419
+ return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}`;
420
+ }
421
+
422
+ export function listArtifactImages() {
423
+ const result = gcloud(
424
+ ["artifacts", "docker", "images", "list", artifactImageBase(), "--include-tags", "--project", config.project.id, "--format=json"],
425
+ { allowFailure: true }
426
+ );
427
+ if (!result.success || !result.stdout) {
428
+ return [];
429
+ }
430
+
431
+ try {
432
+ return (JSON.parse(result.stdout) as Array<{ package?: string; version?: string }>)
433
+ .map((image) => (image.package && image.version ? `${image.package}@${image.version}` : ""))
434
+ .filter(Boolean);
435
+ } catch {
436
+ throw new Error(`Unable to parse Artifact Registry images for ${artifactImageBase()}`);
437
+ }
438
+ }
439
+
440
+ export function deleteArtifactImage(image: string) {
441
+ gcloud(["artifacts", "docker", "images", "delete", image, "--delete-tags", "--project", config.project.id, "--quiet"], { allowFailure: true });
442
+ }
443
+
418
444
  export function projectNumber() {
419
445
  return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
420
446
  }
@@ -425,7 +451,7 @@ export function imageTag() {
425
451
  }
426
452
 
427
453
  export function imageUrl(tag = imageTag()) {
428
- return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}:${tag}`;
454
+ return `${artifactImageBase()}:${tag}`;
429
455
  }
430
456
 
431
457
  export function parseDeployArgs(argv: string[]): DeployArgs {
@@ -708,9 +734,24 @@ export function assertServiceNameAvailable(serviceName: string) {
708
734
 
709
735
  export function deleteProductionDomainMapping() {
710
736
  deleteCloudflareDnsRecord();
711
- gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
712
- allowFailure: true,
713
- });
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
+ );
714
755
  }
715
756
 
716
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