create-svc 0.1.7 → 0.1.8

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.7",
3
+ "version": "0.1.8",
4
4
  "description": "Bun-authored CLI to scaffold Go Cloud Run services with Chi, ConnectRPC, Vault, and Cloudflare examples.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
package/src/cli.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
- import { normalizeValidationResult } from "./cli";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { assertDiscoveryReady, normalizeValidationResult, validateServiceNameInput } from "./cli";
3
4
 
4
5
  test("normalizeValidationResult converts success to undefined", () => {
5
6
  expect(normalizeValidationResult(true)).toBeUndefined();
@@ -8,3 +9,31 @@ test("normalizeValidationResult converts success to undefined", () => {
8
9
  test("normalizeValidationResult preserves validation errors", () => {
9
10
  expect(normalizeValidationResult("Service name is required")).toBe("Service name is required");
10
11
  });
12
+
13
+ test("assertDiscoveryReady requires Neon discovery to succeed", () => {
14
+ expect(() =>
15
+ assertDiscoveryReady({
16
+ projects: [],
17
+ billingAccounts: [],
18
+ warnings: [],
19
+ neonError: "Vault secret resolution requires VAULT_ADDR, VAULT_TOKEN, and a secret path",
20
+ })
21
+ ).toThrow(
22
+ "Neon discovery is required before scaffolding. Set NEON_API_KEY, or use Vault by providing VAULT_ADDR and VAULT_TOKEN. Optional overrides: VAULT_SECRET_MOUNT, VAULT_NEON_API_KEY_PATH, VAULT_NEON_API_KEY_FIELD."
23
+ );
24
+ });
25
+
26
+ test("validateServiceNameInput rejects a taken target directory", async () => {
27
+ const cwd = process.cwd();
28
+ const root = "/tmp/create-svc-cli-validation";
29
+ await mkdir(root, { recursive: true });
30
+ await mkdir(`${root}/taken-app`, { recursive: true });
31
+ await Bun.write(`${root}/taken-app/keep.txt`, "x");
32
+
33
+ process.chdir(root);
34
+ try {
35
+ expect(validateServiceNameInput("taken-app")).toBe("Directory already exists and is not empty");
36
+ } finally {
37
+ process.chdir(cwd);
38
+ }
39
+ });
package/src/cli.ts CHANGED
@@ -5,13 +5,13 @@ import {
5
5
  intro,
6
6
  isCancel,
7
7
  log,
8
- note,
9
8
  outro,
10
9
  select,
11
10
  spinner,
12
11
  text,
13
12
  } from "@clack/prompts";
14
13
  import pc from "picocolors";
14
+ import { readdirSync } from "node:fs";
15
15
  import { basename, dirname, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { runPostScaffoldFlow } from "./post-scaffold";
@@ -55,6 +55,7 @@ type DiscoveryState = {
55
55
  neonProjectId?: string;
56
56
  neonBaseBranchId?: string;
57
57
  neonBaseBranchName?: string;
58
+ neonError?: string;
58
59
  warnings: string[];
59
60
  };
60
61
 
@@ -255,18 +256,19 @@ function parseArgs(argv: string[]): ParsedArgs {
255
256
 
256
257
  export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
257
258
  const inferredName = slugify(basename(args.directory ?? "my-service"));
258
- const discoveryPromise = discoverCloudInputs();
259
259
  const serviceName = args.yes
260
260
  ? inferredName
261
- : await promptText("Service name", inferredName, (value) => slugify(value).length > 0 || "Service name is required");
261
+ : await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
262
262
  const directory = args.directory ?? serviceName;
263
263
  const targetDir = resolve(process.cwd(), directory);
264
264
  await assertTargetDirectoryIsEmpty(targetDir);
265
265
 
266
+ const discoveryPromise = discoverCloudInputs();
266
267
  const defaults = deriveDefaults(serviceName);
267
268
  const runtime = await resolveRuntime(args);
268
269
  const framework = await resolveFramework(args, runtime);
269
270
  const discovery = await discoveryPromise;
271
+ assertDiscoveryReady(discovery);
270
272
  const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
271
273
  const githubRepo = args.githubRepo ?? defaults.githubRepo;
272
274
  const region = args.region ?? DEFAULT_REGION;
@@ -487,12 +489,20 @@ async function discoverCloudInputs(): Promise<DiscoveryState> {
487
489
  result.neonBaseBranchId = neonDefaults.baseBranchId;
488
490
  result.neonBaseBranchName = neonDefaults.baseBranchName;
489
491
  } catch (error) {
490
- result.warnings.push(`Skipping Neon discovery: ${formatError(error)}`);
492
+ result.neonError = formatError(error);
491
493
  }
492
494
 
493
495
  return result;
494
496
  }
495
497
 
498
+ export function assertDiscoveryReady(discovery: DiscoveryState) {
499
+ if (!discovery.neonError) {
500
+ return;
501
+ }
502
+
503
+ throw new Error(formatNeonDiscoveryRequirement(discovery.neonError));
504
+ }
505
+
496
506
  function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
497
507
  if (input) {
498
508
  return input;
@@ -536,11 +546,21 @@ function formatError(error: unknown) {
536
546
  return error instanceof Error ? error.message : String(error);
537
547
  }
538
548
 
549
+ function formatNeonDiscoveryRequirement(reason: string) {
550
+ if (reason.includes("Vault secret resolution requires")) {
551
+ return [
552
+ "Neon discovery is required before scaffolding.",
553
+ "Set NEON_API_KEY, or use Vault by providing VAULT_ADDR and VAULT_TOKEN.",
554
+ "Optional overrides: VAULT_SECRET_MOUNT, VAULT_NEON_API_KEY_PATH, VAULT_NEON_API_KEY_FIELD.",
555
+ ].join(" ");
556
+ }
557
+
558
+ return `Neon discovery is required before scaffolding: ${reason}`;
559
+ }
560
+
539
561
  function handleCliError(error: unknown) {
540
562
  if (error instanceof DirectoryConflictError) {
541
- log.error(`The directory ${error.targetDir} contains files that could conflict.`);
542
- note(formatConflictEntries(error.entries), "Conflicting files");
543
- log.message("Either try using a new directory name, or remove the files listed above.");
563
+ log.error(`Target directory already exists and is not empty: ${error.targetDir}`);
544
564
  process.exit(1);
545
565
  }
546
566
 
@@ -548,15 +568,6 @@ function handleCliError(error: unknown) {
548
568
  process.exit(1);
549
569
  }
550
570
 
551
- function formatConflictEntries(entries: string[]) {
552
- const visibleEntries = entries.slice(0, 12);
553
- const lines = visibleEntries.map((entry) => `- ${entry}`);
554
- if (entries.length > visibleEntries.length) {
555
- lines.push(`- ...and ${entries.length - visibleEntries.length} more`);
556
- }
557
- return lines.join("\n");
558
- }
559
-
560
571
  async function promptForExistingProject(projects: GcpProject[]) {
561
572
  const value = await autocomplete({
562
573
  message: "Existing GCP project",
@@ -601,6 +612,29 @@ export function normalizeValidationResult(result: true | string): string | undef
601
612
  return result === true ? undefined : result;
602
613
  }
603
614
 
615
+ export function validateServiceNameInput(rawValue: string, directoryOverride?: string) {
616
+ const serviceName = slugify(rawValue);
617
+ if (!serviceName) {
618
+ return "Service name is required";
619
+ }
620
+
621
+ const directory = directoryOverride ?? serviceName;
622
+ const targetDir = resolve(process.cwd(), directory);
623
+
624
+ try {
625
+ const entries = readdirSync(targetDir);
626
+ if (entries.length > 0) {
627
+ return "Directory already exists and is not empty";
628
+ }
629
+ } catch (error) {
630
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
631
+ return "Unable to check target directory";
632
+ }
633
+ }
634
+
635
+ return true;
636
+ }
637
+
604
638
  function printHelp() {
605
639
  log.message(`
606
640
  Usage:
@@ -72,7 +72,10 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
72
72
  expect(mainGo).toContain("NewDNSService");
73
73
  } else {
74
74
  const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
75
- expect(packageJson).toContain('"bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts"');
75
+ expect(packageJson).toContain('"svc-cloudrun": "./scripts/cloudrun/cli.ts"');
76
+
77
+ const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
78
+ expect(makefile).toContain("npx --no-install svc-cloudrun");
76
79
 
77
80
  const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
78
81
  expect(entrypoint).toContain(variant.framework === "hono" ? "Hono" : "rpc.example.v1.Service/Ping");
@@ -14,6 +14,8 @@ import {
14
14
  gcloud,
15
15
  requireCommand,
16
16
  resolveDeploymentTarget,
17
+ runMain,
18
+ runStep,
17
19
  setGithubVariable,
18
20
  workloadIdentityPoolResource,
19
21
  workloadIdentityProviderResource,
@@ -23,54 +25,67 @@ export async function bootstrap() {
23
25
  requireCommand("gcloud");
24
26
  requireCommand("gh");
25
27
 
26
- ensureProject();
27
- attachBilling();
28
- gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]);
28
+ await runStep("Ensuring GCP project", () => ensureProject());
29
+ await runStep("Attaching billing", () => attachBilling());
30
+ await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
29
31
 
30
- ensureServiceAccount(config.runtimeServiceAccount);
31
- ensureServiceAccount(config.deployerServiceAccount);
32
- ensureArtifactRepository();
32
+ await runStep("Ensuring runtime and deployer service accounts", () => {
33
+ ensureServiceAccount(config.runtimeServiceAccount);
34
+ ensureServiceAccount(config.deployerServiceAccount);
35
+ });
33
36
 
34
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/run.admin");
35
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/cloudbuild.builds.editor");
36
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/artifactregistry.writer");
37
- ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/serviceusage.serviceUsageConsumer");
38
- ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
37
+ await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
39
38
 
40
- ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
39
+ await runStep("Granting project roles", () => {
40
+ ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/run.admin");
41
+ ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/cloudbuild.builds.editor");
42
+ ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/artifactregistry.writer");
43
+ ensureProjectRole(`serviceAccount:${config.deployerServiceAccount}`, "roles/serviceusage.serviceUsageConsumer");
44
+ ensureProjectRole(`serviceAccount:${config.runtimeServiceAccount}`, "roles/secretmanager.secretAccessor");
45
+ ensureServiceAccountRole(config.runtimeServiceAccount, `serviceAccount:${config.deployerServiceAccount}`, "roles/iam.serviceAccountUser");
46
+ });
41
47
 
42
- ensureWorkloadIdentityPool();
43
- ensureWorkloadIdentityProvider();
44
- ensureServiceAccountRole(
45
- config.deployerServiceAccount,
46
- `principalSet://iam.googleapis.com/${workloadIdentityPoolResource()}/attribute.repository/${config.github.repo}`,
47
- "roles/iam.workloadIdentityUser"
48
- );
48
+ await runStep("Ensuring Workload Identity setup", () => {
49
+ ensureWorkloadIdentityPool();
50
+ ensureWorkloadIdentityProvider();
51
+ ensureServiceAccountRole(
52
+ config.deployerServiceAccount,
53
+ `principalSet://iam.googleapis.com/${workloadIdentityPoolResource()}/attribute.repository/${config.github.repo}`,
54
+ "roles/iam.workloadIdentityUser"
55
+ );
56
+ });
49
57
 
50
58
  if (!config.neon.projectId || !config.neon.baseBranchId) {
51
59
  throw new Error("Neon project and base branch must be configured before bootstrap");
52
60
  }
53
61
 
54
62
  const target = resolveDeploymentTarget("main");
55
- await ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName);
56
- const connectionUri = await getConnectionUri(
57
- config.neon.projectId,
58
- config.neon.baseBranchId,
59
- config.neon.databaseName,
60
- config.neon.roleName
61
- );
62
- addSecretVersion(target.databaseSecretName, connectionUri);
63
- ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
63
+ await runStep("Ensuring Neon database", () => ensureDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName));
64
64
 
65
- for (const [name, value] of Object.entries(githubVariables)) {
66
- setGithubVariable(name, value);
67
- }
65
+ await runStep("Publishing database secret", async () => {
66
+ const connectionUri = await getConnectionUri(
67
+ config.neon.projectId,
68
+ config.neon.baseBranchId,
69
+ config.neon.databaseName,
70
+ config.neon.roleName
71
+ );
72
+ addSecretVersion(target.databaseSecretName, connectionUri);
73
+ ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
74
+ });
75
+
76
+ await runStep("Configuring GitHub repository variables", () => {
77
+ for (const [name, value] of Object.entries(githubVariables)) {
78
+ setGithubVariable(name, value);
79
+ }
68
80
 
69
- setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
70
- setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
81
+ setGithubVariable("GCP_WIF_PROVIDER", workloadIdentityProviderResource());
82
+ setGithubVariable("GCP_DEPLOYER_SERVICE_ACCOUNT", config.deployerServiceAccount);
83
+ });
71
84
  }
72
85
 
73
86
  if (import.meta.main) {
74
- await bootstrap();
87
+ await runMain("Bootstrap", async () => {
88
+ await bootstrap();
89
+ return `Bootstrap finished for ${config.serviceName}`;
90
+ });
75
91
  }
76
-
@@ -0,0 +1,100 @@
1
+ import { log } from "@clack/prompts";
2
+ import { config, githubVariables } from "./config";
3
+ import { deleteBranch, deleteDatabase, listBranches } from "./neon";
4
+ import {
5
+ deleteGithubRepository,
6
+ deleteGithubVariable,
7
+ deleteProject,
8
+ deleteSecret,
9
+ deleteService,
10
+ deleteServiceAccount,
11
+ deleteWorkloadIdentityProvider,
12
+ listCloudRunServices,
13
+ listSecrets,
14
+ parseCleanupArgs,
15
+ requireCommand,
16
+ runMain,
17
+ runStep,
18
+ } from "./lib";
19
+
20
+ function matchesServiceResource(name: string) {
21
+ return name === config.serviceName || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
22
+ }
23
+
24
+ function matchesSecretResource(name: string) {
25
+ return name === `${config.serviceName}-database-url` || name.startsWith(`${config.serviceName}-pr-`) || name.startsWith(`${config.serviceName}-dev-`);
26
+ }
27
+
28
+ export async function cleanup(args = Bun.argv.slice(2)) {
29
+ requireCommand("gcloud");
30
+
31
+ const options = parseCleanupArgs(args);
32
+
33
+ const services = await runStep("Finding Cloud Run services", () => listCloudRunServices());
34
+ const serviceNames = services.filter(matchesServiceResource);
35
+ await runStep("Deleting Cloud Run services", () => {
36
+ for (const serviceName of serviceNames) {
37
+ deleteService(serviceName);
38
+ }
39
+ });
40
+
41
+ const secrets = await runStep("Finding service secrets", () => listSecrets());
42
+ const secretNames = secrets.filter(matchesSecretResource);
43
+ await runStep("Deleting service secrets", () => {
44
+ for (const secretName of secretNames) {
45
+ deleteSecret(secretName);
46
+ }
47
+ });
48
+
49
+ if (config.neon.projectId && config.neon.baseBranchId) {
50
+ const branches = await runStep("Finding Neon branches", () => listBranches(config.neon.projectId));
51
+ const disposableBranches = branches.filter(
52
+ (branch) => branch.name.startsWith(`${config.neon.previewBranchPrefix}-`) || branch.name.startsWith(`${config.neon.personalBranchPrefix}-`)
53
+ );
54
+
55
+ await runStep("Deleting Neon preview and personal branches", async () => {
56
+ for (const branch of disposableBranches) {
57
+ await deleteBranch(config.neon.projectId, branch.id);
58
+ }
59
+ });
60
+
61
+ await runStep("Deleting Neon service database", () =>
62
+ deleteDatabase(config.neon.projectId, config.neon.baseBranchId, config.neon.databaseName)
63
+ );
64
+ } else {
65
+ log.step("Skipping Neon cleanup because Neon is not configured");
66
+ }
67
+
68
+ await runStep("Deleting service-specific identity resources", () => {
69
+ deleteWorkloadIdentityProvider();
70
+ deleteServiceAccount(config.runtimeServiceAccount);
71
+ deleteServiceAccount(config.deployerServiceAccount);
72
+ });
73
+
74
+ if (Bun.which("gh")) {
75
+ await runStep("Deleting GitHub repository variables", () => {
76
+ for (const name of [...Object.keys(githubVariables), "GCP_WIF_PROVIDER", "GCP_DEPLOYER_SERVICE_ACCOUNT"]) {
77
+ deleteGithubVariable(name);
78
+ }
79
+ });
80
+
81
+ if (options.destroyRepo) {
82
+ await runStep(`Deleting GitHub repository ${config.github.repo}`, () => deleteGithubRepository());
83
+ }
84
+ } else if (options.destroyRepo) {
85
+ throw new Error("gh is required to delete the GitHub repository");
86
+ } else {
87
+ log.step("Skipping GitHub cleanup because gh is not installed");
88
+ }
89
+
90
+ if (options.destroyProject) {
91
+ await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
92
+ return `Deleted project ${config.project.id}`;
93
+ }
94
+
95
+ return `Cleanup finished for ${config.serviceName}`;
96
+ }
97
+
98
+ if (import.meta.main) {
99
+ await runMain("Cleanup", () => cleanup(Bun.argv.slice(2)));
100
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { bootstrap } from "./bootstrap";
4
+ import { cleanup } from "./cleanup";
5
+ import { deploy } from "./deploy";
6
+ import { runMain } from "./lib";
7
+
8
+ async function main(argv = Bun.argv.slice(2)) {
9
+ const [command, ...rest] = argv;
10
+
11
+ if (command === "bootstrap") {
12
+ await runMain("Bootstrap", async () => {
13
+ await bootstrap();
14
+ return "Bootstrap finished";
15
+ });
16
+ return;
17
+ }
18
+
19
+ if (command === "deploy") {
20
+ await runMain("Deploy", () => deploy(rest));
21
+ return;
22
+ }
23
+
24
+ if (command === "cleanup") {
25
+ await runMain("Cleanup", () => cleanup(rest));
26
+ return;
27
+ }
28
+
29
+ throw new Error("Usage: svc-cloudrun <bootstrap|deploy|cleanup> [args]");
30
+ }
31
+
32
+ if (import.meta.main) {
33
+ await main();
34
+ }
@@ -1,5 +1,5 @@
1
- import { bootstrap } from "./bootstrap";
2
1
  import { config } from "./config";
2
+ import { bootstrap } from "./bootstrap";
3
3
  import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches } from "./neon";
4
4
  import {
5
5
  addSecretVersion,
@@ -11,6 +11,8 @@ import {
11
11
  parseDeployArgs,
12
12
  requireCommand,
13
13
  resolveDeploymentTarget,
14
+ runMain,
15
+ runStep,
14
16
  serviceUrl,
15
17
  writeRenderedManifest,
16
18
  } from "./lib";
@@ -31,52 +33,65 @@ export async function deploy(args = Bun.argv.slice(2)) {
31
33
  throw new Error("Refusing to destroy the main environment");
32
34
  }
33
35
 
34
- deleteService(target.serviceName);
35
-
36
- const branches = await listBranches(config.neon.projectId);
37
- const branch = branches.find((candidate) => candidate.name === target.branchName);
38
- if (branch) {
39
- await deleteBranch(config.neon.projectId, branch.id);
40
- }
41
- return;
36
+ await runStep(`Deleting Cloud Run service ${target.serviceName}`, () => deleteService(target.serviceName));
37
+ await runStep(`Deleting Neon branch ${target.branchName}`, async () => {
38
+ const branches = await listBranches(config.neon.projectId);
39
+ const branch = branches.find((candidate) => candidate.name === target.branchName);
40
+ if (branch) {
41
+ await deleteBranch(config.neon.projectId, branch.id);
42
+ }
43
+ });
44
+ return `Destroyed ${target.serviceName}`;
42
45
  }
43
46
 
44
- ensureArtifactRepository();
47
+ await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
45
48
 
46
49
  let branchId = config.neon.baseBranchId;
47
50
  if (options.environment !== "main") {
48
- const branch = await ensureBranch(config.neon.projectId, target.branchName, config.neon.baseBranchId);
51
+ const branch = await runStep(`Ensuring Neon branch ${target.branchName}`, () =>
52
+ ensureBranch(config.neon.projectId, target.branchName, config.neon.baseBranchId)
53
+ );
49
54
  branchId = branch.id;
50
55
  }
51
56
 
52
- await ensureDatabase(config.neon.projectId, branchId, config.neon.databaseName);
53
- const connectionUri = await getConnectionUri(config.neon.projectId, branchId, config.neon.databaseName, config.neon.roleName);
54
- addSecretVersion(target.databaseSecretName, connectionUri);
55
- ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
57
+ await runStep("Publishing environment database secret", async () => {
58
+ await ensureDatabase(config.neon.projectId, branchId, config.neon.databaseName);
59
+ const connectionUri = await getConnectionUri(config.neon.projectId, branchId, config.neon.databaseName, config.neon.roleName);
60
+ addSecretVersion(target.databaseSecretName, connectionUri);
61
+ ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
62
+ });
56
63
 
57
64
  const image = imageUrl();
58
- gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image]);
65
+ await runStep("Building container image", () =>
66
+ gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
67
+ );
68
+
69
+ const renderedManifestPath = await runStep("Rendering Cloud Run manifest", () => writeRenderedManifest(image, target));
70
+
71
+ await runStep(`Deploying Cloud Run service ${target.serviceName}`, () =>
72
+ gcloud(["run", "services", "replace", renderedManifestPath.pathname, "--project", config.project.id, "--region", config.region])
73
+ );
59
74
 
60
- const renderedManifestPath = await writeRenderedManifest(image, target);
61
- gcloud(["run", "services", "replace", renderedManifestPath.pathname, "--project", config.project.id, "--region", config.region]);
62
- gcloud([
63
- "run",
64
- "services",
65
- "add-iam-policy-binding",
66
- target.serviceName,
67
- "--project",
68
- config.project.id,
69
- "--region",
70
- config.region,
71
- "--member",
72
- "allUsers",
73
- "--role",
74
- "roles/run.invoker",
75
- ]);
75
+ await runStep("Granting public invoker access", () =>
76
+ gcloud([
77
+ "run",
78
+ "services",
79
+ "add-iam-policy-binding",
80
+ target.serviceName,
81
+ "--project",
82
+ config.project.id,
83
+ "--region",
84
+ config.region,
85
+ "--member",
86
+ "allUsers",
87
+ "--role",
88
+ "roles/run.invoker",
89
+ ])
90
+ );
76
91
 
77
- console.log(serviceUrl(target.serviceName));
92
+ return serviceUrl(target.serviceName);
78
93
  }
79
94
 
80
95
  if (import.meta.main) {
81
- await deploy();
96
+ await runMain("Deploy", () => deploy(Bun.argv.slice(2)));
82
97
  }
@@ -1,8 +1,8 @@
1
+ import { intro, log, outro, spinner } from "@clack/prompts";
1
2
  import { config } from "./config";
2
3
 
3
4
  type CommandOptions = {
4
5
  allowFailure?: boolean;
5
- capture?: boolean;
6
6
  input?: string;
7
7
  };
8
8
 
@@ -13,6 +13,11 @@ type DeployArgs = {
13
13
  name?: string;
14
14
  };
15
15
 
16
+ type CleanupArgs = {
17
+ destroyProject: boolean;
18
+ destroyRepo: boolean;
19
+ };
20
+
16
21
  type DeploymentTarget = {
17
22
  environment: "main" | "preview" | "personal";
18
23
  serviceName: string;
@@ -20,36 +25,60 @@ type DeploymentTarget = {
20
25
  databaseSecretName: string;
21
26
  };
22
27
 
28
+ type CommandResult = {
29
+ success: boolean;
30
+ stdout: string;
31
+ stderr: string;
32
+ exitCode: number;
33
+ };
34
+
23
35
  const decoder = new TextDecoder();
24
36
 
37
+ export class CommandError extends Error {
38
+ command: string;
39
+ args: string[];
40
+ stdout: string;
41
+ stderr: string;
42
+ exitCode: number;
43
+
44
+ constructor(command: string, args: string[], result: CommandResult) {
45
+ super(`command failed: ${command} ${args.join(" ")}`);
46
+ this.name = "CommandError";
47
+ this.command = command;
48
+ this.args = args;
49
+ this.stdout = result.stdout;
50
+ this.stderr = result.stderr;
51
+ this.exitCode = result.exitCode;
52
+ }
53
+ }
54
+
25
55
  export function requireCommand(name: string) {
26
56
  if (!Bun.which(name)) {
27
57
  throw new Error(`missing required command: ${name}`);
28
58
  }
29
59
  }
30
60
 
31
- export function run(command: string, args: string[], options: CommandOptions = {}) {
61
+ export function run(command: string, args: string[], options: CommandOptions = {}): CommandResult {
32
62
  const result = Bun.spawnSync([command, ...args], {
33
63
  cwd: process.cwd(),
34
64
  env: process.env,
35
65
  stdin: options.input,
36
- stdout: options.capture || options.allowFailure ? "pipe" : "inherit",
37
- stderr: options.capture || options.allowFailure ? "pipe" : "inherit",
66
+ stdout: "pipe",
67
+ stderr: "pipe",
38
68
  });
39
69
 
40
- const stdout = result.stdout ? decoder.decode(result.stdout).trim() : "";
41
- const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
42
-
43
- if (!result.success && !options.allowFailure) {
44
- throw new Error([`command failed: ${command} ${args.join(" ")}`, stdout, stderr].filter(Boolean).join("\n"));
45
- }
46
-
47
- return {
70
+ const commandResult: CommandResult = {
48
71
  success: result.success,
49
- stdout,
50
- stderr,
72
+ stdout: result.stdout ? decoder.decode(result.stdout).trim() : "",
73
+ stderr: result.stderr ? decoder.decode(result.stderr).trim() : "",
51
74
  exitCode: result.exitCode,
52
75
  };
76
+
77
+ if (!commandResult.success && !options.allowFailure) {
78
+ throw new CommandError(command, args, commandResult);
79
+ }
80
+
81
+ return commandResult;
53
82
  }
54
83
 
55
84
  export function gcloud(args: string[], options: CommandOptions = {}) {
@@ -64,6 +93,40 @@ export function gh(args: string[], options: CommandOptions = {}) {
64
93
  return run("gh", args, options);
65
94
  }
66
95
 
96
+ export async function runStep<T>(label: string, task: () => Promise<T> | T) {
97
+ const indicator = spinner();
98
+ indicator.start(label);
99
+
100
+ try {
101
+ const result = await task();
102
+ indicator.stop(label);
103
+ return result;
104
+ } catch (error) {
105
+ indicator.stop(`${label} failed`);
106
+ throw new Error(`${label} failed\n${formatError(error)}`);
107
+ }
108
+ }
109
+
110
+ export async function runMain(name: string, task: () => Promise<string | void>) {
111
+ intro(name);
112
+
113
+ try {
114
+ const message = await task();
115
+ outro(message || "Done");
116
+ } catch (error) {
117
+ log.error(formatError(error));
118
+ process.exit(1);
119
+ }
120
+ }
121
+
122
+ export function formatError(error: unknown) {
123
+ if (error instanceof CommandError) {
124
+ return [error.message, error.stderr || error.stdout].filter(Boolean).join("\n");
125
+ }
126
+
127
+ return error instanceof Error ? error.message : String(error);
128
+ }
129
+
67
130
  export function ensureProject() {
68
131
  if (gcloud(["projects", "describe", config.project.id], { allowFailure: true }).success) {
69
132
  return;
@@ -89,6 +152,10 @@ export function ensureServiceAccount(email: string) {
89
152
  gcloud(["iam", "service-accounts", "create", accountId, "--project", config.project.id, "--display-name", accountId]);
90
153
  }
91
154
 
155
+ export function deleteServiceAccount(email: string) {
156
+ gcloud(["iam", "service-accounts", "delete", email, "--project", config.project.id, "--quiet"], { allowFailure: true });
157
+ }
158
+
92
159
  export function ensureProjectRole(member: string, role: string) {
93
160
  gcloud(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
94
161
  }
@@ -125,6 +192,18 @@ export function ensureSecretAccessor(secretName: string, member: string) {
125
192
  gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
126
193
  }
127
194
 
195
+ export function listSecrets() {
196
+ return gcloud(["secrets", "list", "--project", config.project.id, "--format=value(name)"]).stdout
197
+ .split("\n")
198
+ .map((line) => line.trim())
199
+ .filter(Boolean)
200
+ .map((name) => name.split("/").pop() ?? name);
201
+ }
202
+
203
+ export function deleteSecret(secretName: string) {
204
+ gcloud(["secrets", "delete", secretName, "--project", config.project.id, "--quiet"], { allowFailure: true });
205
+ }
206
+
128
207
  export function ensureArtifactRepository() {
129
208
  if (
130
209
  gcloud(
@@ -150,7 +229,7 @@ export function ensureArtifactRepository() {
150
229
  }
151
230
 
152
231
  export function projectNumber() {
153
- return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"], { capture: true }).stdout;
232
+ return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
154
233
  }
155
234
 
156
235
  export function workloadIdentityPoolResource() {
@@ -229,12 +308,40 @@ export function ensureWorkloadIdentityProvider() {
229
308
  ]);
230
309
  }
231
310
 
311
+ export function deleteWorkloadIdentityProvider() {
312
+ gcloud(
313
+ [
314
+ "iam",
315
+ "workload-identity-pools",
316
+ "providers",
317
+ "delete",
318
+ config.workloadIdentityProviderId,
319
+ "--project",
320
+ config.project.id,
321
+ "--location",
322
+ "global",
323
+ "--workload-identity-pool",
324
+ config.workloadIdentityPoolId,
325
+ "--quiet",
326
+ ],
327
+ { allowFailure: true }
328
+ );
329
+ }
330
+
232
331
  export function setGithubVariable(name: string, value: string) {
233
332
  gh(["variable", "set", name, "--repo", config.github.repo, "--body", value]);
234
333
  }
235
334
 
335
+ export function deleteGithubVariable(name: string) {
336
+ gh(["variable", "delete", name, "--repo", config.github.repo], { allowFailure: true });
337
+ }
338
+
339
+ export function deleteGithubRepository() {
340
+ gh(["repo", "delete", config.github.repo, "--yes"]);
341
+ }
342
+
236
343
  export function imageTag() {
237
- const gitSha = run("git", ["rev-parse", "--short", "HEAD"], { allowFailure: true, capture: true }).stdout;
344
+ const gitSha = run("git", ["rev-parse", "--short", "HEAD"], { allowFailure: true }).stdout;
238
345
  return gitSha || `${Date.now()}`;
239
346
  }
240
347
 
@@ -298,6 +405,27 @@ export function parseDeployArgs(argv: string[]): DeployArgs {
298
405
  return parsed;
299
406
  }
300
407
 
408
+ export function parseCleanupArgs(argv: string[]): CleanupArgs {
409
+ const parsed: CleanupArgs = {
410
+ destroyProject: false,
411
+ destroyRepo: false,
412
+ };
413
+
414
+ for (const token of argv) {
415
+ if (token === "--project") {
416
+ parsed.destroyProject = true;
417
+ continue;
418
+ }
419
+
420
+ if (token === "--repo") {
421
+ parsed.destroyRepo = true;
422
+ continue;
423
+ }
424
+ }
425
+
426
+ return parsed;
427
+ }
428
+
301
429
  export function resolveDeploymentTarget(environment: DeployArgs["environment"], rawName?: string): DeploymentTarget {
302
430
  if (environment === "main") {
303
431
  return {
@@ -359,17 +487,27 @@ export async function writeRenderedManifest(image: string, target: DeploymentTar
359
487
 
360
488
  export function serviceUrl(serviceName: string) {
361
489
  return gcloud(
362
- ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"],
363
- { capture: true }
490
+ ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"]
364
491
  ).stdout;
365
492
  }
366
493
 
494
+ export function listCloudRunServices() {
495
+ return gcloud(["run", "services", "list", "--project", config.project.id, "--region", config.region, "--format=value(metadata.name)"]).stdout
496
+ .split("\n")
497
+ .map((line) => line.trim())
498
+ .filter(Boolean);
499
+ }
500
+
367
501
  export function deleteService(serviceName: string) {
368
502
  gcloud(["run", "services", "delete", serviceName, "--project", config.project.id, "--region", config.region, "--quiet"], {
369
503
  allowFailure: true,
370
504
  });
371
505
  }
372
506
 
507
+ export function deleteProject() {
508
+ gcloud(["projects", "delete", config.project.id, "--quiet"]);
509
+ }
510
+
373
511
  function slugify(value: string) {
374
512
  return value
375
513
  .trim()
@@ -377,4 +515,3 @@ function slugify(value: string) {
377
515
  .replace(/[^a-z0-9]+/g, "-")
378
516
  .replace(/^-+|-+$/g, "");
379
517
  }
380
-
@@ -85,6 +85,18 @@ export async function ensureDatabase(projectId: string, branchId: string, databa
85
85
  });
86
86
  }
87
87
 
88
+ export async function deleteDatabase(projectId: string, branchId: string, databaseName: string) {
89
+ try {
90
+ await (await neonClient()).deleteProjectBranchDatabase(projectId, branchId, databaseName);
91
+ } catch (error) {
92
+ const status = (error as { response?: { status?: number } })?.response?.status;
93
+ if (status === 404) {
94
+ return;
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
88
100
  export async function ensureBranch(projectId: string, branchName: string, parentId: string) {
89
101
  const existing = (await listBranches(projectId)).find((branch) => branch.name === branchName);
90
102
  if (existing) {
@@ -0,0 +1,24 @@
1
+ .PHONY: dev gen lint test bootstrap deploy cleanup
2
+
3
+ CLOUDRUN := npx --no-install svc-cloudrun
4
+
5
+ dev:
6
+ bun run ./src/index.ts
7
+
8
+ gen:
9
+ bun run ./scripts/codegen.ts
10
+
11
+ lint:
12
+ bunx tsc --noEmit
13
+
14
+ test:
15
+ bun test
16
+
17
+ bootstrap:
18
+ $(CLOUDRUN) bootstrap
19
+
20
+ deploy:
21
+ $(CLOUDRUN) deploy $(ARGS)
22
+
23
+ cleanup:
24
+ $(CLOUDRUN) cleanup $(ARGS)
@@ -2,15 +2,11 @@
2
2
  "name": "{{SERVICE_NAME}}",
3
3
  "private": true,
4
4
  "type": "module",
5
- "scripts": {
6
- "dev": "bun run ./src/index.ts",
7
- "gen": "bun run ./scripts/codegen.ts",
8
- "lint": "bunx tsc --noEmit",
9
- "test": "bun test",
10
- "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
11
- "deploy": "bun run ./scripts/cloudrun/deploy.ts"
5
+ "bin": {
6
+ "svc-cloudrun": "./scripts/cloudrun/cli.ts"
12
7
  },
13
8
  "dependencies": {
9
+ "@clack/prompts": "^1.2.0",
14
10
  "@neondatabase/api-client": "^2.7.1"
15
11
  },
16
12
  "devDependencies": {
@@ -0,0 +1,24 @@
1
+ .PHONY: dev gen lint test bootstrap deploy cleanup
2
+
3
+ CLOUDRUN := npx --no-install svc-cloudrun
4
+
5
+ dev:
6
+ bun run ./src/index.ts
7
+
8
+ gen:
9
+ bun run ./scripts/codegen.ts
10
+
11
+ lint:
12
+ bunx tsc --noEmit
13
+
14
+ test:
15
+ bun test
16
+
17
+ bootstrap:
18
+ $(CLOUDRUN) bootstrap
19
+
20
+ deploy:
21
+ $(CLOUDRUN) deploy $(ARGS)
22
+
23
+ cleanup:
24
+ $(CLOUDRUN) cleanup $(ARGS)
@@ -2,15 +2,11 @@
2
2
  "name": "{{SERVICE_NAME}}",
3
3
  "private": true,
4
4
  "type": "module",
5
- "scripts": {
6
- "dev": "bun run ./src/index.ts",
7
- "gen": "bun run ./scripts/codegen.ts",
8
- "lint": "bunx tsc --noEmit",
9
- "test": "bun test",
10
- "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
11
- "deploy": "bun run ./scripts/cloudrun/deploy.ts"
5
+ "bin": {
6
+ "svc-cloudrun": "./scripts/cloudrun/cli.ts"
12
7
  },
13
8
  "dependencies": {
9
+ "@clack/prompts": "^1.2.0",
14
10
  "@neondatabase/api-client": "^2.7.1",
15
11
  "hono": "^4.10.1"
16
12
  },
@@ -0,0 +1,25 @@
1
+ .PHONY: dev gen lint test bootstrap deploy cleanup
2
+
3
+ CLOUDRUN := npx --no-install svc-cloudrun
4
+
5
+ dev:
6
+ go run ./cmd/server
7
+
8
+ gen:
9
+ buf generate
10
+
11
+ lint:
12
+ go vet ./...
13
+ buf lint
14
+
15
+ test:
16
+ bun test ./test
17
+
18
+ bootstrap:
19
+ $(CLOUDRUN) bootstrap
20
+
21
+ deploy:
22
+ $(CLOUDRUN) deploy $(ARGS)
23
+
24
+ cleanup:
25
+ $(CLOUDRUN) cleanup $(ARGS)
@@ -2,15 +2,11 @@
2
2
  "name": "{{SERVICE_NAME}}",
3
3
  "private": true,
4
4
  "type": "module",
5
+ "bin": {
6
+ "svc-cloudrun": "./scripts/cloudrun/cli.ts"
7
+ },
5
8
  "dependencies": {
9
+ "@clack/prompts": "^1.2.0",
6
10
  "@neondatabase/api-client": "^2.7.1"
7
- },
8
- "scripts": {
9
- "dev": "go run ./cmd/server",
10
- "gen": "buf generate",
11
- "lint": "go vet ./... && buf lint",
12
- "test": "bun test ./test",
13
- "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
14
- "deploy": "bun run ./scripts/cloudrun/deploy.ts"
15
11
  }
16
12
  }
@@ -1,11 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
 
3
- const decoder = new TextDecoder();
4
-
5
- test(
6
- "go test ./...",
7
- { timeout: 60_000 },
8
- () => {
3
+ test("go test ./...", { timeout: 60_000 }, () => {
9
4
  const result = Bun.spawnSync(["go", "test", "./..."], {
10
5
  cwd: process.cwd(),
11
6
  stdout: "pipe",
@@ -13,7 +8,6 @@ test(
13
8
  env: process.env,
14
9
  });
15
10
 
16
- const output = [decoder.decode(result.stdout), decoder.decode(result.stderr)].join("").trim();
11
+ const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}`.trim();
17
12
  expect(result.exitCode, output || "go test ./... failed").toBe(0);
18
- }
19
- );
13
+ });
@@ -0,0 +1,25 @@
1
+ .PHONY: dev gen lint test bootstrap deploy cleanup
2
+
3
+ CLOUDRUN := npx --no-install svc-cloudrun
4
+
5
+ dev:
6
+ go run ./cmd/server
7
+
8
+ gen:
9
+ buf generate
10
+
11
+ lint:
12
+ go vet ./...
13
+ buf lint
14
+
15
+ test:
16
+ bun test ./test
17
+
18
+ bootstrap:
19
+ $(CLOUDRUN) bootstrap
20
+
21
+ deploy:
22
+ $(CLOUDRUN) deploy $(ARGS)
23
+
24
+ cleanup:
25
+ $(CLOUDRUN) cleanup $(ARGS)
@@ -2,15 +2,11 @@
2
2
  "name": "{{SERVICE_NAME}}",
3
3
  "private": true,
4
4
  "type": "module",
5
+ "bin": {
6
+ "svc-cloudrun": "./scripts/cloudrun/cli.ts"
7
+ },
5
8
  "dependencies": {
9
+ "@clack/prompts": "^1.2.0",
6
10
  "@neondatabase/api-client": "^2.7.1"
7
- },
8
- "scripts": {
9
- "dev": "go run ./cmd/server",
10
- "gen": "buf generate",
11
- "lint": "go vet ./... && buf lint",
12
- "test": "bun test ./test",
13
- "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
14
- "deploy": "bun run ./scripts/cloudrun/deploy.ts"
15
11
  }
16
12
  }
@@ -1,11 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
 
3
- const decoder = new TextDecoder();
4
-
5
- test(
6
- "go test ./...",
7
- { timeout: 60_000 },
8
- () => {
3
+ test("go test ./...", { timeout: 60_000 }, () => {
9
4
  const result = Bun.spawnSync(["go", "test", "./..."], {
10
5
  cwd: process.cwd(),
11
6
  stdout: "pipe",
@@ -13,7 +8,6 @@ test(
13
8
  env: process.env,
14
9
  });
15
10
 
16
- const output = [decoder.decode(result.stdout), decoder.decode(result.stderr)].join("").trim();
11
+ const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}`.trim();
17
12
  expect(result.exitCode, output || "go test ./... failed").toBe(0);
18
- }
19
- );
13
+ });