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 +1 -1
- package/src/cli.ts +119 -115
- package/src/neon.ts +5 -8
- package/src/post-scaffold.ts +6 -0
- package/src/scaffold.test.ts +11 -2
- package/src/scaffold.ts +18 -2
- package/src/vault.test.ts +42 -0
- package/src/vault.ts +63 -0
- package/templates/shared/.env.example +10 -0
- package/templates/shared/README.md +28 -2
- package/templates/shared/scripts/cloudrun/neon.ts +47 -8
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
547
|
+
log.error(formatError(error));
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
570
550
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
579
|
+
if (isCancel(value)) {
|
|
580
|
+
cancel("Aborted");
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
584
583
|
|
|
585
|
-
|
|
586
|
-
|
|
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(
|
|
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 ${
|
|
59
|
+
throw new Error(`No Neon projects are available for ${serviceLabel}`);
|
|
63
60
|
}
|
|
64
61
|
|
|
65
62
|
const branches = await listBranches(project.id, api);
|
package/src/post-scaffold.ts
CHANGED
|
@@ -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}`);
|
package/src/scaffold.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
10
|
-
const
|
|
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(
|
|
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,
|