create-svc 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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,
@@ -362,13 +363,12 @@ async function resolveGcpSelection(
362
363
  defaults: ReturnType<typeof deriveDefaults>,
363
364
  discovery: DiscoveryState
364
365
  ) {
365
- const options = buildGcpProjectOptions(defaults.serviceName, defaults.projectId, defaults.projectName, discovery.projects);
366
-
367
366
  if (args.gcpProjectMode && args.gcpProject) {
367
+ const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
368
368
  return {
369
369
  mode: args.gcpProjectMode,
370
370
  projectId: args.gcpProject,
371
- projectName: args.gcpProjectMode === "create_new" ? defaults.projectName : args.gcpProject,
371
+ projectName: args.gcpProjectMode === "create_new" ? defaults.projectName : existing?.name ?? args.gcpProject,
372
372
  };
373
373
  }
374
374
 
@@ -397,24 +397,51 @@ async function resolveGcpSelection(
397
397
  };
398
398
  }
399
399
 
400
- const value = await select({
400
+ const mode = await select({
401
401
  message: "GCP project",
402
- initialValue: buildCreateProjectLabel(defaults.serviceName, defaults.projectId),
403
- options: options.map((option) => ({
404
- value: option.label,
405
- label: option.label,
406
- hint: option.mode === "create_new" ? "Default" : undefined,
407
- })),
402
+ initialValue: "create_new",
403
+ options: [
404
+ {
405
+ value: "create_new",
406
+ label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
407
+ hint: "Default",
408
+ },
409
+ {
410
+ value: "use_existing",
411
+ label: "Use existing project...",
412
+ hint: `${discovery.projects.length} available`,
413
+ },
414
+ ],
408
415
  });
409
416
 
410
- if (isCancel(value)) {
417
+ if (isCancel(mode)) {
411
418
  cancel("Aborted");
412
419
  process.exit(1);
413
420
  }
414
421
 
415
- const selected = options.find((option) => option.label === value);
422
+ if (mode === "create_new") {
423
+ return {
424
+ mode: "create_new" as const,
425
+ projectId: defaults.projectId,
426
+ projectName: defaults.projectName,
427
+ };
428
+ }
429
+
430
+ if (discovery.projects.length === 0) {
431
+ throw new Error("No existing GCP projects were discovered");
432
+ }
433
+
434
+ const selected = await promptForExistingProject(discovery.projects);
416
435
  if (!selected) {
417
- throw new Error(`Unknown GCP project selection: ${value}`);
436
+ return resolveGcpSelection(
437
+ {
438
+ ...args,
439
+ gcpProjectMode: undefined,
440
+ gcpProject: undefined,
441
+ },
442
+ defaults,
443
+ discovery
444
+ );
418
445
  }
419
446
 
420
447
  return {
@@ -498,6 +525,46 @@ function formatError(error: unknown) {
498
525
  return error instanceof Error ? error.message : String(error);
499
526
  }
500
527
 
528
+ async function promptForExistingProject(projects: GcpProject[]) {
529
+ const value = await autocomplete({
530
+ message: "Existing GCP project",
531
+ placeholder: "Search by project name or id",
532
+ maxItems: 10,
533
+ options: [
534
+ {
535
+ value: "__back__",
536
+ label: "Back",
537
+ hint: "Return to project mode",
538
+ },
539
+ ...projects.map((project) => ({
540
+ value: project.projectId,
541
+ label: project.name,
542
+ hint: project.projectId,
543
+ })),
544
+ ],
545
+ });
546
+
547
+ if (isCancel(value)) {
548
+ cancel("Aborted");
549
+ process.exit(1);
550
+ }
551
+
552
+ if (value === "__back__") {
553
+ return undefined;
554
+ }
555
+
556
+ const project = projects.find((candidate) => candidate.projectId === value);
557
+ if (project) {
558
+ return {
559
+ mode: "use_existing" as const,
560
+ projectId: project.projectId,
561
+ projectName: project.name,
562
+ };
563
+ }
564
+
565
+ return undefined;
566
+ }
567
+
501
568
  export function normalizeValidationResult(result: true | string): string | undefined {
502
569
  return result === true ? undefined : result;
503
570
  }
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) => ({
@@ -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}`);
@@ -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
+ }
@@ -31,7 +31,12 @@ 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
+ Optional Vault overrides for Neon admin key lookup:
37
+
38
+ - `VAULT_SECRET_MOUNT` default `secret`
39
+ - `VAULT_NEON_API_KEY_PATH` default `provider/neon-api-key`
40
+ - `VAULT_NEON_API_KEY_FIELD` default `value`
37
41
 
42
+ 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,