create-svc 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ autocomplete,
2
3
  cancel,
3
4
  confirm,
4
5
  intro,
@@ -20,15 +21,18 @@ import {
20
21
  BILLING_ACCOUNT_DEFAULT,
21
22
  FRAMEWORKS_BY_RUNTIME,
22
23
  QUOTA_PROJECT_DEFAULT,
23
- buildCreateProjectLabel,
24
- buildGcpProjectOptions,
25
24
  deriveDefaults,
26
25
  slugify,
27
26
  type Framework,
28
27
  type GcpProjectMode,
29
28
  type Runtime,
30
29
  } from "./naming";
31
- import { scaffoldProject, type ScaffoldConfig } from "./scaffold";
30
+ import {
31
+ DirectoryConflictError,
32
+ assertTargetDirectoryIsEmpty,
33
+ scaffoldProject,
34
+ type ScaffoldConfig,
35
+ } from "./scaffold";
32
36
 
33
37
  type ParsedArgs = {
34
38
  directory?: string;
@@ -57,55 +61,59 @@ type DiscoveryState = {
57
61
  const DEFAULT_REGION = "us-west1";
58
62
 
59
63
  export async function run(argv: string[]) {
60
- const args = parseArgs(argv);
61
- if (args.help) {
62
- printHelp();
63
- return;
64
- }
64
+ try {
65
+ const args = parseArgs(argv);
66
+ if (args.help) {
67
+ printHelp();
68
+ return;
69
+ }
70
+
71
+ intro(`${pc.bold("create-svc")} ${pc.dim("Cloud Run scaffold")}`);
72
+
73
+ const config = await resolveConfig(args);
74
+ const targetDir = resolve(process.cwd(), config.directory);
65
75
 
66
- intro(`${pc.bold("create-svc")} ${pc.dim("Cloud Run scaffold")}`);
67
-
68
- const config = await resolveConfig(args);
69
- const targetDir = resolve(process.cwd(), config.directory);
70
-
71
- note(
72
- [
73
- `${pc.bold("Output")}: ${targetDir}`,
74
- `${pc.bold("Runtime")}: ${config.runtime} + ${config.framework}`,
75
- `${pc.bold("Project")}: ${config.gcpProjectMode === "create_new" ? "create" : "use"} ${config.gcpProjectName} (${config.gcpProject})`,
76
- `${pc.bold("GitHub")}: ${config.githubRepo}`,
77
- `${pc.bold("Neon")}: ${config.neonProjectId || "(set later)"} / ${config.neonBaseBranchName || "(set later)"}`,
78
- ].join("\n"),
79
- "Scaffold"
80
- );
81
-
82
- const buildSpinner = spinner();
83
- buildSpinner.start("Generating project files");
84
- await scaffoldProject(config);
85
- buildSpinner.stop("Project files generated");
86
-
87
- const shouldRunPostScaffoldFlow = Boolean(process.stdout.isTTY && process.stdin.isTTY && (config.createGithubRepo || config.autoDeploy));
88
- if (shouldRunPostScaffoldFlow) {
89
- const automationSpinner = spinner();
90
- automationSpinner.start("Running post-scaffold automation");
91
- try {
92
- const result = await runPostScaffoldFlow(config, targetDir);
93
- automationSpinner.stop(result.message);
94
- } catch (error) {
95
- automationSpinner.stop("Post-scaffold automation skipped");
96
- log.warn(error instanceof Error ? error.message : String(error));
76
+ note(
77
+ [
78
+ `${pc.bold("Output")}: ${targetDir}`,
79
+ `${pc.bold("Runtime")}: ${config.runtime} + ${config.framework}`,
80
+ `${pc.bold("Project")}: ${config.gcpProjectMode === "create_new" ? "create" : "use"} ${config.gcpProjectName} (${config.gcpProject})`,
81
+ `${pc.bold("GitHub")}: ${config.githubRepo}`,
82
+ `${pc.bold("Neon")}: ${config.neonProjectId || "(set later)"} / ${config.neonBaseBranchName || "(set later)"}`,
83
+ ].join("\n"),
84
+ "Scaffold"
85
+ );
86
+
87
+ const buildSpinner = spinner();
88
+ buildSpinner.start("Generating project files");
89
+ await scaffoldProject(config);
90
+ buildSpinner.stop("Project files generated");
91
+
92
+ const shouldRunPostScaffoldFlow = Boolean(process.stdout.isTTY && process.stdin.isTTY && (config.createGithubRepo || config.autoDeploy));
93
+ if (shouldRunPostScaffoldFlow) {
94
+ const automationSpinner = spinner();
95
+ automationSpinner.start("Running post-scaffold automation");
96
+ try {
97
+ const result = await runPostScaffoldFlow(config, targetDir);
98
+ automationSpinner.stop(result.message);
99
+ } catch (error) {
100
+ automationSpinner.stop("Post-scaffold automation skipped");
101
+ log.warn(error instanceof Error ? error.message : String(error));
102
+ }
97
103
  }
98
- }
99
104
 
100
- outro(
101
- [
102
- `Next: ${pc.cyan(`cd ${config.directory}`)}`,
103
- `Local dev: ${pc.cyan("bun dev")}`,
104
- `Bootstrap: ${pc.cyan("bun run bootstrap")}`,
105
- `Deploy: ${pc.cyan("bun run deploy")}`,
106
- `Personal env: ${pc.cyan(`bun run deploy -- --environment personal --name ${config.serviceName}`)}`,
107
- ].join("\n")
108
- );
105
+ outro(
106
+ [
107
+ `Next: ${pc.cyan(`cd ${config.directory}`)}`,
108
+ `Local dev: ${pc.cyan("bun dev")}`,
109
+ `Bootstrap: ${pc.cyan("bun run bootstrap")}`,
110
+ `Deploy: ${pc.cyan("bun run deploy")}`,
111
+ `Personal env: ${pc.cyan(`bun run deploy -- --environment personal --name ${config.serviceName}`)}`,
112
+ ].join("\n")
113
+ );
114
+ } catch (error) {
115
+ handleCliError(error);
116
+ }
109
117
  }
110
118
 
111
119
  function parseArgs(argv: string[]): ParsedArgs {
@@ -247,20 +255,23 @@ function parseArgs(argv: string[]): ParsedArgs {
247
255
 
248
256
  export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
249
257
  const inferredName = slugify(basename(args.directory ?? "my-service"));
258
+ const discoveryPromise = discoverCloudInputs();
250
259
  const serviceName = args.yes
251
260
  ? inferredName
252
261
  : await promptText("Service name", inferredName, (value) => slugify(value).length > 0 || "Service name is required");
262
+ const directory = args.directory ?? serviceName;
263
+ const targetDir = resolve(process.cwd(), directory);
264
+ await assertTargetDirectoryIsEmpty(targetDir);
253
265
 
254
266
  const defaults = deriveDefaults(serviceName);
255
- const discovery = await discoverCloudInputs(serviceName);
256
267
  const runtime = await resolveRuntime(args);
257
268
  const framework = await resolveFramework(args, runtime);
269
+ const discovery = await discoveryPromise;
258
270
  const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
259
271
  const githubRepo = args.githubRepo ?? defaults.githubRepo;
260
272
  const region = args.region ?? DEFAULT_REGION;
261
273
  const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
262
274
  const autoDeploy = resolveAutoDeploy(args.autoDeploy);
263
- const directory = args.directory ?? serviceName;
264
275
 
265
276
  if (!args.yes) {
266
277
  const okay = await confirm({
@@ -408,7 +419,8 @@ async function resolveGcpSelection(
408
419
  {
409
420
  value: "use_existing",
410
421
  label: "Use existing project...",
411
- hint: `${discovery.projects.length} available`,
422
+ hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "Unavailable",
423
+ disabled: discovery.projects.length === 0,
412
424
  },
413
425
  ],
414
426
  });
@@ -450,7 +462,7 @@ async function resolveGcpSelection(
450
462
  };
451
463
  }
452
464
 
453
- async function discoverCloudInputs(serviceName: string): Promise<DiscoveryState> {
465
+ async function discoverCloudInputs(): Promise<DiscoveryState> {
454
466
  const result: DiscoveryState = {
455
467
  projects: [],
456
468
  billingAccounts: [],
@@ -470,7 +482,7 @@ async function discoverCloudInputs(serviceName: string): Promise<DiscoveryState>
470
482
  }
471
483
 
472
484
  try {
473
- const neonDefaults = await discoverNeonDefaults(serviceName);
485
+ const neonDefaults = await discoverNeonDefaults();
474
486
  result.neonProjectId = neonDefaults.projectId;
475
487
  result.neonBaseBranchId = neonDefaults.baseBranchId;
476
488
  result.neonBaseBranchName = neonDefaults.baseBranchName;
@@ -524,73 +536,65 @@ function formatError(error: unknown) {
524
536
  return error instanceof Error ? error.message : String(error);
525
537
  }
526
538
 
527
- async function promptForExistingProject(projects: GcpProject[]) {
528
- const pageSize = 10;
529
- let page = 0;
530
-
531
- while (true) {
532
- const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
533
- const pageProjects = projects.slice(page * pageSize, (page + 1) * pageSize);
534
-
535
- const value = await select({
536
- message: `Existing GCP project (${page + 1}/${totalPages})`,
537
- options: [
538
- {
539
- value: "__back__",
540
- label: "Back",
541
- },
542
- ...pageProjects.map((project) => ({
543
- value: project.projectId,
544
- label: project.name,
545
- hint: project.projectId,
546
- })),
547
- ...(page > 0
548
- ? [
549
- {
550
- value: "__previous__",
551
- label: "Previous page",
552
- },
553
- ]
554
- : []),
555
- ...(page < totalPages - 1
556
- ? [
557
- {
558
- value: "__next__",
559
- label: "Next page",
560
- },
561
- ]
562
- : []),
563
- ],
564
- });
539
+ function handleCliError(error: unknown) {
540
+ 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.");
544
+ process.exit(1);
545
+ }
565
546
 
566
- if (isCancel(value)) {
567
- cancel("Aborted");
568
- process.exit(1);
569
- }
547
+ log.error(formatError(error));
548
+ process.exit(1);
549
+ }
570
550
 
571
- if (value === "__previous__") {
572
- page -= 1;
573
- continue;
574
- }
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
+ }
575
559
 
576
- if (value === "__back__") {
577
- return undefined;
578
- }
560
+ async function promptForExistingProject(projects: GcpProject[]) {
561
+ const value = await autocomplete({
562
+ message: "Existing GCP project",
563
+ placeholder: "Search by project name or id",
564
+ maxItems: 10,
565
+ options: [
566
+ {
567
+ value: "__back__",
568
+ label: "Back",
569
+ hint: "Return to project mode",
570
+ },
571
+ ...projects.map((project) => ({
572
+ value: project.projectId,
573
+ label: project.name,
574
+ hint: project.projectId,
575
+ })),
576
+ ],
577
+ });
579
578
 
580
- if (value === "__next__") {
581
- page += 1;
582
- continue;
583
- }
579
+ if (isCancel(value)) {
580
+ cancel("Aborted");
581
+ process.exit(1);
582
+ }
584
583
 
585
- const project = projects.find((candidate) => candidate.projectId === value);
586
- if (project) {
587
- return {
588
- mode: "use_existing" as const,
589
- projectId: project.projectId,
590
- projectName: project.name,
591
- };
592
- }
584
+ if (value === "__back__") {
585
+ return undefined;
593
586
  }
587
+
588
+ const project = projects.find((candidate) => candidate.projectId === value);
589
+ if (project) {
590
+ return {
591
+ mode: "use_existing" as const,
592
+ projectId: project.projectId,
593
+ projectName: project.name,
594
+ };
595
+ }
596
+
597
+ return undefined;
594
598
  }
595
599
 
596
600
  export function normalizeValidationResult(result: true | string): string | undefined {
package/src/neon.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createApiClient } from "@neondatabase/api-client";
2
+ import { resolveNeonApiKey } from "./vault";
2
3
 
3
4
  export type NeonProject = {
4
5
  id: string;
@@ -16,14 +17,9 @@ export type NeonApi = {
16
17
  };
17
18
 
18
19
  export function createNeonApi(apiKey = process.env.NEON_API_KEY): NeonApi {
19
- if (!apiKey?.trim()) {
20
- throw new Error("NEON_API_KEY is not set");
21
- }
22
-
23
- const client = createApiClient({ apiKey });
24
-
25
20
  return {
26
21
  async listProjects() {
22
+ const client = createApiClient({ apiKey: (apiKey?.trim() || (await resolveNeonApiKey())) });
27
23
  const payload = await client.listProjects({ limit: 100 });
28
24
  return (payload.projects ?? [])
29
25
  .map((project) => ({
@@ -35,6 +31,7 @@ export function createNeonApi(apiKey = process.env.NEON_API_KEY): NeonApi {
35
31
  },
36
32
 
37
33
  async listBranches(projectId: string) {
34
+ const client = createApiClient({ apiKey: (apiKey?.trim() || (await resolveNeonApiKey())) });
38
35
  const payload = await client.listProjectBranches({ projectId });
39
36
  return (payload.branches ?? [])
40
37
  .map((branch) => ({
@@ -55,11 +52,11 @@ export async function listBranches(projectId: string, api = createNeonApi()): Pr
55
52
  return api.listBranches(projectId);
56
53
  }
57
54
 
58
- export async function discoverNeonDefaults(serviceName: string, api = createNeonApi()) {
55
+ export async function discoverNeonDefaults(serviceLabel = "this service", api = createNeonApi()) {
59
56
  const projects = await listProjects(api);
60
57
  const project = projects[0];
61
58
  if (!project) {
62
- throw new Error(`No Neon projects are available for ${serviceName}`);
59
+ throw new Error(`No Neon projects are available for ${serviceLabel}`);
63
60
  }
64
61
 
65
62
  const branches = await listBranches(project.id, api);
@@ -21,6 +21,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
21
21
  }
22
22
 
23
23
  if (config.autoDeploy) {
24
+ installProjectDependencies(cwd);
24
25
  run("bun", ["run", "bootstrap"], { cwd });
25
26
  run("bun", ["run", "deploy"], { cwd });
26
27
  return { message: "Repository initialized, pushed, and first deploy started" };
@@ -47,6 +48,11 @@ function createGitHubRepo(config: ScaffoldConfig, cwd: string) {
47
48
  run("git", ["push", "-u", "origin", "main"], { cwd, allowFailure: true });
48
49
  }
49
50
 
51
+ function installProjectDependencies(cwd: string) {
52
+ requireCommand("bun");
53
+ run("bun", ["install"], { cwd });
54
+ }
55
+
50
56
  function requireCommand(name: string) {
51
57
  if (!Bun.which(name)) {
52
58
  throw new Error(`missing required command for post-scaffold automation: ${name}`);
@@ -1,8 +1,8 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdtemp } from "node:fs/promises";
2
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { scaffoldProject, type ScaffoldConfig } from "./scaffold";
5
+ import { DirectoryConflictError, assertTargetDirectoryIsEmpty, scaffoldProject, type ScaffoldConfig } from "./scaffold";
6
6
 
7
7
  function baseConfig(overrides: Partial<ScaffoldConfig> = {}): ScaffoldConfig {
8
8
  return {
@@ -79,3 +79,12 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
79
79
  }
80
80
  }
81
81
  });
82
+
83
+ test("detects conflicting files before scaffold generation", async () => {
84
+ const root = await mkdtemp(join(tmpdir(), "create-svc-conflict-"));
85
+ const generatedRoot = join(root, "existing");
86
+ await mkdir(generatedRoot, { recursive: true });
87
+ await writeFile(join(generatedRoot, "README.md"), "hello");
88
+
89
+ await expect(assertTargetDirectoryIsEmpty(generatedRoot)).rejects.toBeInstanceOf(DirectoryConflictError);
90
+ });
package/src/scaffold.ts CHANGED
@@ -29,6 +29,18 @@ export type ScaffoldConfig = {
29
29
  generatorRoot: string;
30
30
  };
31
31
 
32
+ export class DirectoryConflictError extends Error {
33
+ targetDir: string;
34
+ entries: string[];
35
+
36
+ constructor(targetDir: string, entries: string[]) {
37
+ super(`Target directory already exists and is not empty: ${targetDir}`);
38
+ this.name = "DirectoryConflictError";
39
+ this.targetDir = targetDir;
40
+ this.entries = entries;
41
+ }
42
+ }
43
+
32
44
  export async function scaffoldProject(config: ScaffoldConfig) {
33
45
  const targetDir = resolve(process.cwd(), config.directory);
34
46
  await ensureTargetDirectory(targetDir);
@@ -53,14 +65,18 @@ export async function scaffoldProject(config: ScaffoldConfig) {
53
65
  }
54
66
 
55
67
  async function ensureTargetDirectory(targetDir: string) {
68
+ await assertTargetDirectoryIsEmpty(targetDir);
69
+ await mkdir(targetDir, { recursive: true });
70
+ }
71
+
72
+ export async function assertTargetDirectoryIsEmpty(targetDir: string) {
56
73
  try {
57
74
  const entries = await readdir(targetDir);
58
75
  if (entries.length > 0) {
59
- throw new Error(`Target directory already exists and is not empty: ${targetDir}`);
76
+ throw new DirectoryConflictError(targetDir, entries.sort());
60
77
  }
61
78
  } catch (error) {
62
79
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
63
- await mkdir(targetDir, { recursive: true });
64
80
  return;
65
81
  }
66
82
  throw error;
@@ -0,0 +1,42 @@
1
+ import { afterEach, expect, mock, test } from "bun:test";
2
+ import { readVaultSecret, resolveNeonApiKey } from "./vault";
3
+
4
+ const originalEnv = { ...process.env };
5
+
6
+ afterEach(() => {
7
+ process.env = { ...originalEnv };
8
+ mock.restore();
9
+ });
10
+
11
+ test("resolveNeonApiKey prefers NEON_API_KEY from env", async () => {
12
+ process.env.NEON_API_KEY = "direct-token";
13
+ await expect(resolveNeonApiKey()).resolves.toBe("direct-token");
14
+ });
15
+
16
+ test("readVaultSecret reads KV v2 secret data using existing vault login env", async () => {
17
+ process.env.VAULT_ADDR = "https://vault.example.com";
18
+ process.env.VAULT_TOKEN = "token-123";
19
+
20
+ const fetchMock = mock(async (input: string | URL | Request) => {
21
+ expect(String(input)).toBe("https://vault.example.com/v1/secret/data/provider/neon-api-key");
22
+ return new Response(
23
+ JSON.stringify({
24
+ data: {
25
+ data: {
26
+ value: "vault-token",
27
+ },
28
+ },
29
+ }),
30
+ { status: 200 }
31
+ );
32
+ });
33
+
34
+ globalThis.fetch = fetchMock as typeof fetch;
35
+
36
+ await expect(
37
+ readVaultSecret({
38
+ path: "provider/neon-api-key",
39
+ field: "value",
40
+ })
41
+ ).resolves.toBe("vault-token");
42
+ });
package/src/vault.ts ADDED
@@ -0,0 +1,63 @@
1
+ const DEFAULT_VAULT_SECRET_MOUNT = "secret";
2
+ const DEFAULT_NEON_API_KEY_PATH = "provider/neon-api-key";
3
+ const DEFAULT_NEON_API_KEY_FIELD = "value";
4
+
5
+ type VaultSecretOptions = {
6
+ addr?: string;
7
+ token?: string;
8
+ mount?: string;
9
+ path?: string;
10
+ field?: string;
11
+ };
12
+
13
+ export async function resolveNeonApiKey() {
14
+ const direct = process.env.NEON_API_KEY?.trim();
15
+ if (direct) {
16
+ return direct;
17
+ }
18
+
19
+ return readVaultSecret({
20
+ path: process.env.VAULT_NEON_API_KEY_PATH ?? DEFAULT_NEON_API_KEY_PATH,
21
+ field: process.env.VAULT_NEON_API_KEY_FIELD ?? DEFAULT_NEON_API_KEY_FIELD,
22
+ });
23
+ }
24
+
25
+ export async function readVaultSecret(options: VaultSecretOptions = {}) {
26
+ const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
27
+ const token = options.token ?? process.env.VAULT_TOKEN?.trim() ?? "";
28
+ const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
29
+ const path = options.path?.trim() ?? "";
30
+ const field = options.field?.trim() ?? "value";
31
+
32
+ if (!addr || !token || !path) {
33
+ throw new Error("Vault secret resolution requires VAULT_ADDR, VAULT_TOKEN, and a secret path");
34
+ }
35
+
36
+ const normalizedAddr = addr.replace(/\/+$/g, "");
37
+ const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
38
+ const normalizedPath = path.replace(/^\/+/g, "");
39
+ const url = `${normalizedAddr}/v1/${normalizedMount}/data/${normalizedPath}`;
40
+
41
+ const response = await fetch(url, {
42
+ headers: {
43
+ "X-Vault-Token": token,
44
+ },
45
+ });
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`Vault read failed: ${response.status} ${response.statusText}`);
49
+ }
50
+
51
+ const payload = (await response.json()) as {
52
+ data?: {
53
+ data?: Record<string, string | undefined>;
54
+ };
55
+ };
56
+
57
+ const value = payload.data?.data?.[field]?.trim();
58
+ if (!value) {
59
+ throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
60
+ }
61
+
62
+ return value;
63
+ }
@@ -0,0 +1,10 @@
1
+ # Stable repo-local settings for Neon admin key lookup via Vault.
2
+ # Copy to .env.local and adjust as needed.
3
+
4
+ VAULT_ADDR=https://vault.example.com
5
+ VAULT_SECRET_MOUNT=secret
6
+ VAULT_NEON_API_KEY_PATH=provider/neon-api-key
7
+ VAULT_NEON_API_KEY_FIELD=value
8
+
9
+ # Do not commit VAULT_TOKEN. Prefer `vault login` in your shell session.
10
+
@@ -31,7 +31,33 @@ Bootstrap and deploy use:
31
31
 
32
32
  - `gcloud`
33
33
  - `gh`
34
- - `NEON_API_KEY`
34
+ - `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` + `VAULT_TOKEN`
35
35
 
36
- The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to bootstrap and deploy.
36
+ ## Environment setup
37
+
38
+ For project-specific Vault settings, prefer repo-local config over shell startup files:
39
+
40
+ ```bash
41
+ cp .env.example .env.local
42
+ ```
43
+
44
+ Then edit `.env.local` with your Vault address and secret path overrides.
37
45
 
46
+ For the token itself, prefer a live shell session:
47
+
48
+ ```bash
49
+ vault login
50
+ export VAULT_TOKEN="$(vault print token)"
51
+ ```
52
+
53
+ or however your existing Vault login flow exposes `VAULT_TOKEN`.
54
+
55
+ That keeps stable settings in the repo and keeps the token out of `~/.zshrc`.
56
+
57
+ Optional Vault overrides for Neon admin key lookup:
58
+
59
+ - `VAULT_SECRET_MOUNT` default `secret`
60
+ - `VAULT_NEON_API_KEY_PATH` default `provider/neon-api-key`
61
+ - `VAULT_NEON_API_KEY_FIELD` default `value`
62
+
63
+ The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to bootstrap and deploy.
@@ -6,17 +6,56 @@ type NeonBranch = {
6
6
  name: string;
7
7
  };
8
8
 
9
- function neonClient() {
10
- const apiKey = process.env.NEON_API_KEY?.trim();
9
+ async function resolveNeonApiKey() {
10
+ const direct = process.env.NEON_API_KEY?.trim();
11
+ if (direct) {
12
+ return direct;
13
+ }
14
+
15
+ const addr = process.env.VAULT_ADDR?.trim() ?? "";
16
+ const token = process.env.VAULT_TOKEN?.trim() ?? "";
17
+ const mount = process.env.VAULT_SECRET_MOUNT?.trim() ?? "secret";
18
+ const path = process.env.VAULT_NEON_API_KEY_PATH?.trim() ?? "provider/neon-api-key";
19
+ const field = process.env.VAULT_NEON_API_KEY_FIELD?.trim() ?? "value";
20
+
21
+ if (!addr || !token) {
22
+ throw new Error("NEON_API_KEY is required for Neon provisioning, or set VAULT_ADDR and VAULT_TOKEN");
23
+ }
24
+
25
+ const normalizedAddr = addr.replace(/\/+$/g, "");
26
+ const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
27
+ const normalizedPath = path.replace(/^\/+/g, "");
28
+ const response = await fetch(`${normalizedAddr}/v1/${normalizedMount}/data/${normalizedPath}`, {
29
+ headers: {
30
+ "X-Vault-Token": token,
31
+ },
32
+ });
33
+
34
+ if (!response.ok) {
35
+ throw new Error(`Vault read failed: ${response.status} ${response.statusText}`);
36
+ }
37
+
38
+ const payload = (await response.json()) as {
39
+ data?: {
40
+ data?: Record<string, string | undefined>;
41
+ };
42
+ };
43
+
44
+ const apiKey = payload.data?.data?.[field]?.trim();
11
45
  if (!apiKey) {
12
- throw new Error("NEON_API_KEY is required for Neon provisioning");
46
+ throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
13
47
  }
14
48
 
49
+ return apiKey;
50
+ }
51
+
52
+ async function neonClient() {
53
+ const apiKey = await resolveNeonApiKey();
15
54
  return createApiClient({ apiKey });
16
55
  }
17
56
 
18
57
  export async function listBranches(projectId: string) {
19
- const payload = await neonClient().listProjectBranches({ projectId });
58
+ const payload = await (await neonClient()).listProjectBranches({ projectId });
20
59
  return (payload.branches ?? [])
21
60
  .map((branch) => ({
22
61
  id: branch.id ?? "",
@@ -27,7 +66,7 @@ export async function listBranches(projectId: string) {
27
66
  }
28
67
 
29
68
  export async function ensureDatabase(projectId: string, branchId: string, databaseName: string) {
30
- const client = neonClient();
69
+ const client = await neonClient();
31
70
 
32
71
  try {
33
72
  await client.getProjectBranchDatabase(projectId, branchId, databaseName);
@@ -52,7 +91,7 @@ export async function ensureBranch(projectId: string, branchName: string, parent
52
91
  return existing;
53
92
  }
54
93
 
55
- const payload = await neonClient().createProjectBranch(projectId, {
94
+ const payload = await (await neonClient()).createProjectBranch(projectId, {
56
95
  branch: {
57
96
  name: branchName,
58
97
  parent_id: parentId,
@@ -77,7 +116,7 @@ export async function ensureBranch(projectId: string, branchName: string, parent
77
116
 
78
117
  export async function deleteBranch(projectId: string, branchId: string) {
79
118
  try {
80
- await neonClient().deleteProjectBranch(projectId, branchId);
119
+ await (await neonClient()).deleteProjectBranch(projectId, branchId);
81
120
  } catch (error) {
82
121
  const status = (error as { response?: { status?: number } })?.response?.status;
83
122
  if (status === 404) {
@@ -88,7 +127,7 @@ export async function deleteBranch(projectId: string, branchId: string) {
88
127
  }
89
128
 
90
129
  export async function getConnectionUri(projectId: string, branchId: string, databaseName: string, roleName: string) {
91
- const payload = await neonClient().getConnectionUri({
130
+ const payload = await (await neonClient()).getConnectionUri({
92
131
  projectId,
93
132
  branchId,
94
133
  databaseName,