create-svc 0.1.38 → 0.1.40

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.38",
3
+ "version": "0.1.40",
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
@@ -11,7 +11,13 @@ import {
11
11
  markGitHubRepositoryDeleteOnDestroy,
12
12
  type GitBootstrapResult,
13
13
  } from "./git-bootstrap";
14
- import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
14
+ import {
15
+ assertExistingProjectReadyForAutoDeploy,
16
+ listOpenBillingAccounts,
17
+ listAccessibleProjects,
18
+ type BillingAccount,
19
+ type GcpProject,
20
+ } from "./gcp";
15
21
  import {
16
22
  BILLING_ACCOUNT_DEFAULT,
17
23
  QUOTA_PROJECT_DEFAULT,
@@ -91,6 +97,7 @@ export async function run(argv: string[]) {
91
97
 
92
98
  const config = await resolveConfig(args);
93
99
  const targetDir = resolve(process.cwd(), config.directory);
100
+ await assertPreScaffoldReady(config);
94
101
 
95
102
  note(
96
103
  [
@@ -959,6 +966,14 @@ export function assertDiscoveryReady(discovery: DiscoveryState) {
959
966
  return discovery;
960
967
  }
961
968
 
969
+ async function assertPreScaffoldReady(config: ScaffoldConfig) {
970
+ if (config.target !== "cloudrun" || !config.autoDeploy || config.gcpProjectMode !== "use_existing") {
971
+ return;
972
+ }
973
+
974
+ await assertExistingProjectReadyForAutoDeploy(config.gcpProject);
975
+ }
976
+
962
977
  function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
963
978
  if (input) {
964
979
  return input;
package/src/gcp.test.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { expect, test } from "bun:test";
2
- import { attachBillingAccount, createProject, listAccessibleProjects, listOpenBillingAccounts, type GcpApi } from "./gcp";
2
+ import {
3
+ assertExistingProjectReadyForAutoDeploy,
4
+ attachBillingAccount,
5
+ createProject,
6
+ listAccessibleProjects,
7
+ listOpenBillingAccounts,
8
+ type GcpApi,
9
+ } from "./gcp";
3
10
 
4
11
  test("listAccessibleProjects filters deleted projects and sorts by name", async () => {
5
12
  const api: GcpApi = {
@@ -105,3 +112,70 @@ test("createProject and attachBillingAccount call the expected endpoints", async
105
112
  expect(calls[0]).toBe("create:anmho-test:test");
106
113
  expect(calls[1]).toBe("billing:anmho-test:billingAccounts/123");
107
114
  });
115
+
116
+ test("assertExistingProjectReadyForAutoDeploy passes when project exists with billing", async () => {
117
+ const api: GcpApi = {
118
+ async listProjects() {
119
+ return [];
120
+ },
121
+ async listBillingAccounts() {
122
+ return [];
123
+ },
124
+ async describeProject(projectId) {
125
+ return { projectId, name: "services" };
126
+ },
127
+ async describeBillingProject() {
128
+ return { billingEnabled: true, billingAccountName: "billingAccounts/123" };
129
+ },
130
+ async createProject() {},
131
+ async attachBillingAccount() {},
132
+ };
133
+
134
+ await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).resolves.toBeUndefined();
135
+ });
136
+
137
+ test("assertExistingProjectReadyForAutoDeploy rejects missing projects", async () => {
138
+ const api: GcpApi = {
139
+ async listProjects() {
140
+ return [];
141
+ },
142
+ async listBillingAccounts() {
143
+ return [];
144
+ },
145
+ async describeProject() {
146
+ throw new Error("not found");
147
+ },
148
+ async describeBillingProject() {
149
+ return { billingEnabled: true };
150
+ },
151
+ async createProject() {},
152
+ async attachBillingAccount() {},
153
+ };
154
+
155
+ await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).rejects.toThrow(
156
+ "GCP project anmho-services does not exist or is not accessible"
157
+ );
158
+ });
159
+
160
+ test("assertExistingProjectReadyForAutoDeploy rejects projects without billing", async () => {
161
+ const api: GcpApi = {
162
+ async listProjects() {
163
+ return [];
164
+ },
165
+ async listBillingAccounts() {
166
+ return [];
167
+ },
168
+ async describeProject(projectId) {
169
+ return { projectId, name: "services" };
170
+ },
171
+ async describeBillingProject() {
172
+ return { billingEnabled: false };
173
+ },
174
+ async createProject() {},
175
+ async attachBillingAccount() {},
176
+ };
177
+
178
+ await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).rejects.toThrow(
179
+ "GCP project anmho-services exists but billing is not enabled"
180
+ );
181
+ });
package/src/gcp.ts CHANGED
@@ -10,9 +10,16 @@ export type BillingAccount = {
10
10
  open: boolean;
11
11
  };
12
12
 
13
+ export type BillingProject = {
14
+ billingEnabled?: boolean;
15
+ billingAccountName?: string;
16
+ };
17
+
13
18
  export type GcpApi = {
14
19
  listProjects(): Promise<GcpProject[]>;
15
20
  listBillingAccounts(): Promise<BillingAccount[]>;
21
+ describeProject?(projectId: string): Promise<GcpProject>;
22
+ describeBillingProject?(projectId: string): Promise<BillingProject>;
16
23
  createProject(projectId: string, name: string): Promise<void>;
17
24
  attachBillingAccount(projectId: string, billingAccountName: string): Promise<void>;
18
25
  };
@@ -33,6 +40,20 @@ export function createGcpApi(): GcpApi {
33
40
  );
34
41
  },
35
42
 
43
+ async describeProject(projectId: string) {
44
+ return parseJson<GcpProject>(
45
+ runGcloud(["projects", "describe", projectId, "--format=json(projectId,name,lifecycleState)"]).stdout,
46
+ "GCP project"
47
+ );
48
+ },
49
+
50
+ async describeBillingProject(projectId: string) {
51
+ return parseJson<BillingProject>(
52
+ runGcloud(["beta", "billing", "projects", "describe", projectId, "--format=json(billingEnabled,billingAccountName)"]).stdout,
53
+ "GCP project billing"
54
+ );
55
+ },
56
+
36
57
  async createProject(projectId: string, name: string) {
37
58
  runGcloud(["projects", "create", projectId, "--name", name]);
38
59
  },
@@ -64,6 +85,46 @@ export async function attachBillingAccount(projectId: string, billingAccountName
64
85
  await api.attachBillingAccount(projectId, billingAccountName);
65
86
  }
66
87
 
88
+ export async function assertExistingProjectReadyForAutoDeploy(projectId: string, api = createGcpApi()) {
89
+ try {
90
+ await api.describeProject?.(projectId);
91
+ } catch (error) {
92
+ throw new Error(
93
+ [
94
+ `GCP project ${projectId} does not exist or is not accessible.`,
95
+ "Create and enable billing on that project before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
96
+ formatErrorDetail(error),
97
+ ]
98
+ .filter(Boolean)
99
+ .join("\n")
100
+ );
101
+ }
102
+
103
+ let billing: BillingProject;
104
+ try {
105
+ billing = (await api.describeBillingProject?.(projectId)) ?? {};
106
+ } catch (error) {
107
+ throw new Error(
108
+ [
109
+ `Unable to verify billing for GCP project ${projectId}.`,
110
+ "Fix billing access before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
111
+ formatErrorDetail(error),
112
+ ]
113
+ .filter(Boolean)
114
+ .join("\n")
115
+ );
116
+ }
117
+
118
+ if (!billing.billingEnabled) {
119
+ throw new Error(
120
+ [
121
+ `GCP project ${projectId} exists but billing is not enabled.`,
122
+ "Link billing before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
123
+ ].join("\n")
124
+ );
125
+ }
126
+ }
127
+
67
128
  function runGcloud(args: string[]) {
68
129
  const result = Bun.spawnSync(["gcloud", ...args], {
69
130
  stdout: "pipe",
@@ -87,6 +148,11 @@ function accountSortName(account: BillingAccount) {
87
148
  return account.displayName || account.name;
88
149
  }
89
150
 
151
+ function formatErrorDetail(error: unknown) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ return message ? `Details: ${message}` : undefined;
154
+ }
155
+
90
156
  function parseJson<T>(value: string, label: string): T {
91
157
  try {
92
158
  return JSON.parse(value) as T;
@@ -70,6 +70,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
70
70
  expect(serviceConfig).toContain('"api_key_secret_name": "dns-api-temporal-api-key"');
71
71
  expect(serviceConfig).toContain('"project_mode": "create_new"');
72
72
  expect(serviceConfig).toContain('"quota_project_id": "anmho-infra-prod"');
73
+ expect(serviceConfig).toContain('"artifact_repository": "cloud-run"');
74
+ expect(serviceConfig).not.toContain("cloudbuild.googleapis.com");
73
75
  expect(serviceConfig).toContain('"jwks_url": "https://auth.anmho.com/api/auth/jwks"');
74
76
  expect(serviceConfig).toContain('"git": {');
75
77
  expect(serviceConfig).toContain('"repository": "dns-api"');
@@ -75,8 +75,9 @@ export async function cleanup(args = Bun.argv.slice(2)) {
75
75
  }
76
76
 
77
77
  await requireDestroyConfirmation(options.force);
78
+ const deleteGitHubRepository = await confirmGitHubRepositoryDeletion(plan.githubRepository, options.force);
78
79
 
79
- await deletePlannedResources(plan);
80
+ await deletePlannedResources(plan, { deleteGitHubRepository });
80
81
 
81
82
  if (options.destroyProject) {
82
83
  await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
@@ -87,7 +88,7 @@ export async function cleanup(args = Bun.argv.slice(2)) {
87
88
  return `Destroy finished for ${config.serviceName}`;
88
89
  }
89
90
 
90
- async function deletePlannedResources(plan: DestroyPlan) {
91
+ async function deletePlannedResources(plan: DestroyPlan, options: { deleteGitHubRepository: boolean }) {
91
92
  const tasks: ParallelTask[] = [
92
93
  {
93
94
  label: "Stopping local dev resources",
@@ -135,7 +136,7 @@ async function deletePlannedResources(plan: DestroyPlan) {
135
136
  },
136
137
  ];
137
138
 
138
- if (plan.githubRepository) {
139
+ if (plan.githubRepository && options.deleteGitHubRepository) {
139
140
  tasks.push({
140
141
  label: `Deleting GitHub repository ${plan.githubRepository}`,
141
142
  task: () => deleteGitHubRepository(plan.githubRepository!),
@@ -246,7 +247,10 @@ function planGitHubRepository(plan: DestroyPlan) {
246
247
  }
247
248
 
248
249
  plan.githubRepository = repository;
249
- plan.resources.push({ label: `GitHub repository ${repository}`, detail: "private generated repo" });
250
+ plan.skipped.push({
251
+ label: `GitHub repository ${repository}`,
252
+ detail: "owned by this service CLI run; deletion is optional and defaults to no",
253
+ });
250
254
  }
251
255
 
252
256
  function deleteGitHubRepository(repository: string) {
@@ -400,6 +404,41 @@ async function requireDestroyConfirmation(force: boolean) {
400
404
  }
401
405
  }
402
406
 
407
+ async function confirmGitHubRepositoryDeletion(repository: string | undefined, force: boolean) {
408
+ if (!repository) {
409
+ return false;
410
+ }
411
+
412
+ if (force) {
413
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
414
+ return false;
415
+ }
416
+
417
+ if (!process.stdin.isTTY) {
418
+ return false;
419
+ }
420
+
421
+ const deleteAnswer = await confirm({
422
+ message: `Delete GitHub repository ${repository}?`,
423
+ initialValue: false,
424
+ });
425
+ if (isCancel(deleteAnswer) || !deleteAnswer) {
426
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
427
+ return false;
428
+ }
429
+
430
+ const confirmAnswer = await confirm({
431
+ message: `Confirm deleting GitHub repository ${repository}? This cannot be undone.`,
432
+ initialValue: false,
433
+ });
434
+ if (isCancel(confirmAnswer) || !confirmAnswer) {
435
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
436
+ return false;
437
+ }
438
+
439
+ return true;
440
+ }
441
+
403
442
  if (import.meta.main) {
404
443
  await runMain("Destroy", () => cleanup(Bun.argv.slice(2)));
405
444
  }
@@ -0,0 +1,90 @@
1
+ export type DeployArgs = {
2
+ build: "local" | "cloudbuild";
3
+ ci: boolean;
4
+ destroy: boolean;
5
+ environment: "main" | "preview" | "personal";
6
+ name?: string;
7
+ };
8
+
9
+ export function parseDeployArgs(argv: string[]): DeployArgs {
10
+ const parsed: DeployArgs = {
11
+ build: parseBuildStrategy(process.env.SERVICE_BUILD_STRATEGY || process.env.SERVICE_BUILD),
12
+ ci: false,
13
+ destroy: false,
14
+ environment: "main",
15
+ };
16
+
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const token = argv[i];
19
+ if (!token) {
20
+ continue;
21
+ }
22
+
23
+ const next = argv[i + 1];
24
+ const readValue = () => {
25
+ if (!next || next.startsWith("-")) {
26
+ throw new Error(`Missing value for ${token}`);
27
+ }
28
+ i += 1;
29
+ return next;
30
+ };
31
+
32
+ if (token === "--ci") {
33
+ parsed.ci = true;
34
+ continue;
35
+ }
36
+
37
+ if (token === "--destroy") {
38
+ parsed.destroy = true;
39
+ continue;
40
+ }
41
+
42
+ if (token === "--build") {
43
+ parsed.build = parseBuildStrategy(readValue());
44
+ continue;
45
+ }
46
+
47
+ if (token.startsWith("--build=")) {
48
+ parsed.build = parseBuildStrategy(token.slice("--build=".length));
49
+ continue;
50
+ }
51
+
52
+ if (token === "--cloud-build") {
53
+ parsed.build = "cloudbuild";
54
+ continue;
55
+ }
56
+
57
+ if (token === "--environment") {
58
+ parsed.environment = readValue() as DeployArgs["environment"];
59
+ continue;
60
+ }
61
+
62
+ if (token.startsWith("--environment=")) {
63
+ parsed.environment = token.slice("--environment=".length) as DeployArgs["environment"];
64
+ continue;
65
+ }
66
+
67
+ if (token === "--name") {
68
+ parsed.name = readValue();
69
+ continue;
70
+ }
71
+
72
+ if (token.startsWith("--name=")) {
73
+ parsed.name = token.slice("--name=".length);
74
+ continue;
75
+ }
76
+ }
77
+
78
+ return parsed;
79
+ }
80
+
81
+ function parseBuildStrategy(value: string | undefined): DeployArgs["build"] {
82
+ if (!value || value === "local") {
83
+ return "local";
84
+ }
85
+ if (value === "cloudbuild" || value === "cloud-build") {
86
+ return "cloudbuild";
87
+ }
88
+ throw new Error(`Unknown build strategy: ${value}`);
89
+ }
90
+
@@ -4,6 +4,7 @@ import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranc
4
4
  import {
5
5
  addSecretVersion,
6
6
  deleteService,
7
+ dockerStreaming,
7
8
  ensureArtifactRepository,
8
9
  ensureProductionDomainMapping,
9
10
  ensureSecretAccessor,
@@ -74,9 +75,18 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
74
75
  });
75
76
  }
76
77
  const image = imageUrl();
77
- await runStep("Building container image in Cloud Build", () =>
78
- gcloudStreaming(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
79
- );
78
+ if (options.build === "cloudbuild") {
79
+ await runStep("Building container image in Cloud Build", () =>
80
+ gcloudStreaming(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
81
+ );
82
+ } else {
83
+ requireCommand("docker");
84
+ await runStep("Authenticating Docker to Artifact Registry", () =>
85
+ gcloud(["auth", "configure-docker", `${config.region}-docker.pkg.dev`, "--quiet"])
86
+ );
87
+ await runStep("Building container image locally", () => dockerStreaming(["build", "-t", image, "."]));
88
+ await runStep("Pushing container image to Artifact Registry", () => dockerStreaming(["push", image]));
89
+ }
80
90
 
81
91
  const renderedManifestPath = await runStep("Rendering Cloud Run manifest", () => writeRenderedManifest(image, target));
82
92
 
@@ -0,0 +1,29 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { parseDeployArgs } from "./deploy-args";
3
+
4
+ const originalBuild = process.env.SERVICE_BUILD;
5
+ const originalBuildStrategy = process.env.SERVICE_BUILD_STRATEGY;
6
+
7
+ afterEach(() => {
8
+ process.env.SERVICE_BUILD = originalBuild;
9
+ process.env.SERVICE_BUILD_STRATEGY = originalBuildStrategy;
10
+ });
11
+
12
+ test("parseDeployArgs defaults to local image builds", () => {
13
+ delete process.env.SERVICE_BUILD;
14
+ delete process.env.SERVICE_BUILD_STRATEGY;
15
+
16
+ expect(parseDeployArgs([]).build).toBe("local");
17
+ });
18
+
19
+ test("parseDeployArgs accepts explicit Cloud Build fallback", () => {
20
+ expect(parseDeployArgs(["--build", "cloudbuild"]).build).toBe("cloudbuild");
21
+ expect(parseDeployArgs(["--build=cloud-build"]).build).toBe("cloudbuild");
22
+ expect(parseDeployArgs(["--cloud-build"]).build).toBe("cloudbuild");
23
+ });
24
+
25
+ test("parseDeployArgs accepts build strategy from env", () => {
26
+ process.env.SERVICE_BUILD_STRATEGY = "cloudbuild";
27
+
28
+ expect(parseDeployArgs([]).build).toBe("cloudbuild");
29
+ });
@@ -2,6 +2,7 @@ import { intro, log, outro, spinner } from "@clack/prompts";
2
2
  import { join } from "node:path";
3
3
  import { config } from "./config";
4
4
  import { serviceRoot } from "../runtime";
5
+ import { parseDeployArgs, type DeployArgs } from "./deploy-args";
5
6
 
6
7
  type CommandOptions = {
7
8
  allowFailure?: boolean;
@@ -9,13 +10,6 @@ type CommandOptions = {
9
10
  env?: Record<string, string | undefined>;
10
11
  };
11
12
 
12
- type DeployArgs = {
13
- ci: boolean;
14
- destroy: boolean;
15
- environment: "main" | "preview" | "personal";
16
- name?: string;
17
- };
18
-
19
13
  type CleanupArgs = {
20
14
  destroyProject: boolean;
21
15
  force: boolean;
@@ -125,6 +119,10 @@ export async function gcloudStreaming(args: string[], options: CommandOptions =
125
119
  return runStreaming("gcloud", normalized, options);
126
120
  }
127
121
 
122
+ export async function dockerStreaming(args: string[], options: CommandOptions = {}) {
123
+ return runStreaming("docker", args, options);
124
+ }
125
+
128
126
  export async function runStreaming(command: string, args: string[], options: CommandOptions = {}): Promise<CommandResult> {
129
127
  const child = Bun.spawn([command, ...args], {
130
128
  cwd: process.cwd(),
@@ -454,61 +452,7 @@ export function imageUrl(tag = imageTag()) {
454
452
  return `${artifactImageBase()}:${tag}`;
455
453
  }
456
454
 
457
- export function parseDeployArgs(argv: string[]): DeployArgs {
458
- const parsed: DeployArgs = {
459
- ci: false,
460
- destroy: false,
461
- environment: "main",
462
- };
463
-
464
- for (let i = 0; i < argv.length; i += 1) {
465
- const token = argv[i];
466
- if (!token) {
467
- continue;
468
- }
469
-
470
- const next = argv[i + 1];
471
- const readValue = () => {
472
- if (!next || next.startsWith("-")) {
473
- throw new Error(`Missing value for ${token}`);
474
- }
475
- i += 1;
476
- return next;
477
- };
478
-
479
- if (token === "--ci") {
480
- parsed.ci = true;
481
- continue;
482
- }
483
-
484
- if (token === "--destroy") {
485
- parsed.destroy = true;
486
- continue;
487
- }
488
-
489
- if (token === "--environment") {
490
- parsed.environment = readValue() as DeployArgs["environment"];
491
- continue;
492
- }
493
-
494
- if (token.startsWith("--environment=")) {
495
- parsed.environment = token.slice("--environment=".length) as DeployArgs["environment"];
496
- continue;
497
- }
498
-
499
- if (token === "--name") {
500
- parsed.name = readValue();
501
- continue;
502
- }
503
-
504
- if (token.startsWith("--name=")) {
505
- parsed.name = token.slice("--name=".length);
506
- continue;
507
- }
508
- }
509
-
510
- return parsed;
511
- }
455
+ export { parseDeployArgs };
512
456
 
513
457
  export function parseCleanupArgs(argv: string[]): CleanupArgs {
514
458
  const parsed: CleanupArgs = {
@@ -97,7 +97,9 @@ export async function main(argv = Bun.argv.slice(2)) {
97
97
  await requireDestroyConfirmation(rest.includes("--force"));
98
98
  const wranglerArgs = rest.filter((arg) => arg !== "--force");
99
99
  await stopLocalDev({ dockerCompose: false });
100
- deleteGitHubRepositoryIfOwned();
100
+ if (await confirmGitHubRepositoryDeletion(rest.includes("--force"))) {
101
+ deleteGitHubRepositoryIfOwned();
102
+ }
101
103
  await deleteHyperdrive();
102
104
  run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
103
105
  await deleteNeonDatabase();
@@ -152,6 +154,47 @@ function deleteGitHubRepositoryIfOwned() {
152
154
  run("gh", ["repo", "delete", repository, "--yes"]);
153
155
  }
154
156
 
157
+ async function confirmGitHubRepositoryDeletion(force: boolean) {
158
+ const repository = `${config.git.owner}/${config.git.repository}`;
159
+ if (!config.git.deleteOnDestroy) {
160
+ log.step(
161
+ `Skipping GitHub repository ${repository}: ${
162
+ config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled"
163
+ }`
164
+ );
165
+ return false;
166
+ }
167
+
168
+ if (force) {
169
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
170
+ return false;
171
+ }
172
+
173
+ if (!process.stdin.isTTY) {
174
+ return false;
175
+ }
176
+
177
+ const deleteAnswer = await confirm({
178
+ message: `Delete GitHub repository ${repository}?`,
179
+ initialValue: false,
180
+ });
181
+ if (isCancel(deleteAnswer) || !deleteAnswer) {
182
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
183
+ return false;
184
+ }
185
+
186
+ const confirmAnswer = await confirm({
187
+ message: `Confirm deleting GitHub repository ${repository}? This cannot be undone.`,
188
+ initialValue: false,
189
+ });
190
+ if (isCancel(confirmAnswer) || !confirmAnswer) {
191
+ log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
192
+ return false;
193
+ }
194
+
195
+ return true;
196
+ }
197
+
155
198
  function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
156
199
  if (!Bun.which(command)) {
157
200
  throw new Error(`missing required command: ${command}`);
@@ -66,10 +66,14 @@ Create, deploy, and destroy use:
66
66
 
67
67
  - known-good CLIs first, especially `gcloud`
68
68
  - `gcloud`
69
+ - Docker for local image builds and Artifact Registry pushes
69
70
  - `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
70
71
  - the repo-aware `service` CLI from this package
71
72
 
72
73
  Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
74
+ Cloud Run deploys build and push the container image locally by default. Use
75
+ `service deploy --build cloudbuild` only when you explicitly want Google Cloud
76
+ Build to build the image remotely.
73
77
 
74
78
  Authenticate `gcloud` on the machine before running provisioning commands:
75
79
 
@@ -97,7 +97,6 @@
97
97
  "service_account": "{{RUNTIME_SERVICE_ACCOUNT}}",
98
98
  "required_apis": [
99
99
  "run.googleapis.com",
100
- "cloudbuild.googleapis.com",
101
100
  "artifactregistry.googleapis.com",
102
101
  "iam.googleapis.com",
103
102
  "iamcredentials.googleapis.com",