create-svc 0.1.6 → 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 +1 -1
- package/src/cli.test.ts +30 -1
- package/src/cli.ts +123 -57
- package/src/neon.ts +2 -2
- package/src/scaffold.test.ts +15 -3
- package/src/scaffold.ts +18 -2
- package/templates/shared/.env.example +10 -0
- package/templates/shared/README.md +21 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +50 -35
- package/templates/shared/scripts/cloudrun/cleanup.ts +100 -0
- package/templates/shared/scripts/cloudrun/cli.ts +34 -0
- package/templates/shared/scripts/cloudrun/deploy.ts +49 -34
- package/templates/shared/scripts/cloudrun/lib.ts +156 -19
- package/templates/shared/scripts/cloudrun/neon.ts +12 -0
- package/templates/variants/bun-connectrpc/Makefile +24 -0
- package/templates/variants/bun-connectrpc/package.json +3 -7
- package/templates/variants/bun-hono/Makefile +24 -0
- package/templates/variants/bun-hono/package.json +3 -7
- package/templates/variants/go-chi/Makefile +25 -0
- package/templates/variants/go-chi/package.json +4 -8
- package/templates/variants/go-chi/test/go.test.ts +3 -9
- package/templates/variants/go-connectrpc/Makefile +25 -0
- package/templates/variants/go-connectrpc/package.json +4 -8
- package/templates/variants/go-connectrpc/test/go.test.ts +3 -9
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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";
|
|
@@ -21,15 +21,18 @@ import {
|
|
|
21
21
|
BILLING_ACCOUNT_DEFAULT,
|
|
22
22
|
FRAMEWORKS_BY_RUNTIME,
|
|
23
23
|
QUOTA_PROJECT_DEFAULT,
|
|
24
|
-
buildCreateProjectLabel,
|
|
25
|
-
buildGcpProjectOptions,
|
|
26
24
|
deriveDefaults,
|
|
27
25
|
slugify,
|
|
28
26
|
type Framework,
|
|
29
27
|
type GcpProjectMode,
|
|
30
28
|
type Runtime,
|
|
31
29
|
} from "./naming";
|
|
32
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
DirectoryConflictError,
|
|
32
|
+
assertTargetDirectoryIsEmpty,
|
|
33
|
+
scaffoldProject,
|
|
34
|
+
type ScaffoldConfig,
|
|
35
|
+
} from "./scaffold";
|
|
33
36
|
|
|
34
37
|
type ParsedArgs = {
|
|
35
38
|
directory?: string;
|
|
@@ -52,61 +55,66 @@ type DiscoveryState = {
|
|
|
52
55
|
neonProjectId?: string;
|
|
53
56
|
neonBaseBranchId?: string;
|
|
54
57
|
neonBaseBranchName?: string;
|
|
58
|
+
neonError?: string;
|
|
55
59
|
warnings: string[];
|
|
56
60
|
};
|
|
57
61
|
|
|
58
62
|
const DEFAULT_REGION = "us-west1";
|
|
59
63
|
|
|
60
64
|
export async function run(argv: string[]) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
try {
|
|
66
|
+
const args = parseArgs(argv);
|
|
67
|
+
if (args.help) {
|
|
68
|
+
printHelp();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
intro(`${pc.bold("create-svc")} ${pc.dim("Cloud Run scaffold")}`);
|
|
66
73
|
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
log.warn(error instanceof Error ? error.message : String(error));
|
|
74
|
+
const config = await resolveConfig(args);
|
|
75
|
+
const targetDir = resolve(process.cwd(), config.directory);
|
|
76
|
+
|
|
77
|
+
note(
|
|
78
|
+
[
|
|
79
|
+
`${pc.bold("Output")}: ${targetDir}`,
|
|
80
|
+
`${pc.bold("Runtime")}: ${config.runtime} + ${config.framework}`,
|
|
81
|
+
`${pc.bold("Project")}: ${config.gcpProjectMode === "create_new" ? "create" : "use"} ${config.gcpProjectName} (${config.gcpProject})`,
|
|
82
|
+
`${pc.bold("GitHub")}: ${config.githubRepo}`,
|
|
83
|
+
`${pc.bold("Neon")}: ${config.neonProjectId || "(set later)"} / ${config.neonBaseBranchName || "(set later)"}`,
|
|
84
|
+
].join("\n"),
|
|
85
|
+
"Scaffold"
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const buildSpinner = spinner();
|
|
89
|
+
buildSpinner.start("Generating project files");
|
|
90
|
+
await scaffoldProject(config);
|
|
91
|
+
buildSpinner.stop("Project files generated");
|
|
92
|
+
|
|
93
|
+
const shouldRunPostScaffoldFlow = Boolean(process.stdout.isTTY && process.stdin.isTTY && (config.createGithubRepo || config.autoDeploy));
|
|
94
|
+
if (shouldRunPostScaffoldFlow) {
|
|
95
|
+
const automationSpinner = spinner();
|
|
96
|
+
automationSpinner.start("Running post-scaffold automation");
|
|
97
|
+
try {
|
|
98
|
+
const result = await runPostScaffoldFlow(config, targetDir);
|
|
99
|
+
automationSpinner.stop(result.message);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
automationSpinner.stop("Post-scaffold automation skipped");
|
|
102
|
+
log.warn(error instanceof Error ? error.message : String(error));
|
|
103
|
+
}
|
|
98
104
|
}
|
|
99
|
-
}
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
outro(
|
|
107
|
+
[
|
|
108
|
+
`Next: ${pc.cyan(`cd ${config.directory}`)}`,
|
|
109
|
+
`Local dev: ${pc.cyan("bun dev")}`,
|
|
110
|
+
`Bootstrap: ${pc.cyan("bun run bootstrap")}`,
|
|
111
|
+
`Deploy: ${pc.cyan("bun run deploy")}`,
|
|
112
|
+
`Personal env: ${pc.cyan(`bun run deploy -- --environment personal --name ${config.serviceName}`)}`,
|
|
113
|
+
].join("\n")
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
handleCliError(error);
|
|
117
|
+
}
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
function parseArgs(argv: string[]): ParsedArgs {
|
|
@@ -250,18 +258,22 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
|
250
258
|
const inferredName = slugify(basename(args.directory ?? "my-service"));
|
|
251
259
|
const serviceName = args.yes
|
|
252
260
|
? inferredName
|
|
253
|
-
: await promptText("Service name", inferredName, (value) =>
|
|
261
|
+
: await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
|
|
262
|
+
const directory = args.directory ?? serviceName;
|
|
263
|
+
const targetDir = resolve(process.cwd(), directory);
|
|
264
|
+
await assertTargetDirectoryIsEmpty(targetDir);
|
|
254
265
|
|
|
266
|
+
const discoveryPromise = discoverCloudInputs();
|
|
255
267
|
const defaults = deriveDefaults(serviceName);
|
|
256
|
-
const discovery = await discoverCloudInputs(serviceName);
|
|
257
268
|
const runtime = await resolveRuntime(args);
|
|
258
269
|
const framework = await resolveFramework(args, runtime);
|
|
270
|
+
const discovery = await discoveryPromise;
|
|
271
|
+
assertDiscoveryReady(discovery);
|
|
259
272
|
const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
|
|
260
273
|
const githubRepo = args.githubRepo ?? defaults.githubRepo;
|
|
261
274
|
const region = args.region ?? DEFAULT_REGION;
|
|
262
275
|
const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
|
|
263
276
|
const autoDeploy = resolveAutoDeploy(args.autoDeploy);
|
|
264
|
-
const directory = args.directory ?? serviceName;
|
|
265
277
|
|
|
266
278
|
if (!args.yes) {
|
|
267
279
|
const okay = await confirm({
|
|
@@ -409,7 +421,8 @@ async function resolveGcpSelection(
|
|
|
409
421
|
{
|
|
410
422
|
value: "use_existing",
|
|
411
423
|
label: "Use existing project...",
|
|
412
|
-
hint: `${discovery.projects.length} available
|
|
424
|
+
hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "Unavailable",
|
|
425
|
+
disabled: discovery.projects.length === 0,
|
|
413
426
|
},
|
|
414
427
|
],
|
|
415
428
|
});
|
|
@@ -451,7 +464,7 @@ async function resolveGcpSelection(
|
|
|
451
464
|
};
|
|
452
465
|
}
|
|
453
466
|
|
|
454
|
-
async function discoverCloudInputs(
|
|
467
|
+
async function discoverCloudInputs(): Promise<DiscoveryState> {
|
|
455
468
|
const result: DiscoveryState = {
|
|
456
469
|
projects: [],
|
|
457
470
|
billingAccounts: [],
|
|
@@ -471,17 +484,25 @@ async function discoverCloudInputs(serviceName: string): Promise<DiscoveryState>
|
|
|
471
484
|
}
|
|
472
485
|
|
|
473
486
|
try {
|
|
474
|
-
const neonDefaults = await discoverNeonDefaults(
|
|
487
|
+
const neonDefaults = await discoverNeonDefaults();
|
|
475
488
|
result.neonProjectId = neonDefaults.projectId;
|
|
476
489
|
result.neonBaseBranchId = neonDefaults.baseBranchId;
|
|
477
490
|
result.neonBaseBranchName = neonDefaults.baseBranchName;
|
|
478
491
|
} catch (error) {
|
|
479
|
-
result.
|
|
492
|
+
result.neonError = formatError(error);
|
|
480
493
|
}
|
|
481
494
|
|
|
482
495
|
return result;
|
|
483
496
|
}
|
|
484
497
|
|
|
498
|
+
export function assertDiscoveryReady(discovery: DiscoveryState) {
|
|
499
|
+
if (!discovery.neonError) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
throw new Error(formatNeonDiscoveryRequirement(discovery.neonError));
|
|
504
|
+
}
|
|
505
|
+
|
|
485
506
|
function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
|
|
486
507
|
if (input) {
|
|
487
508
|
return input;
|
|
@@ -525,6 +546,28 @@ function formatError(error: unknown) {
|
|
|
525
546
|
return error instanceof Error ? error.message : String(error);
|
|
526
547
|
}
|
|
527
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
|
+
|
|
561
|
+
function handleCliError(error: unknown) {
|
|
562
|
+
if (error instanceof DirectoryConflictError) {
|
|
563
|
+
log.error(`Target directory already exists and is not empty: ${error.targetDir}`);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
log.error(formatError(error));
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
|
|
528
571
|
async function promptForExistingProject(projects: GcpProject[]) {
|
|
529
572
|
const value = await autocomplete({
|
|
530
573
|
message: "Existing GCP project",
|
|
@@ -569,6 +612,29 @@ export function normalizeValidationResult(result: true | string): string | undef
|
|
|
569
612
|
return result === true ? undefined : result;
|
|
570
613
|
}
|
|
571
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
|
+
|
|
572
638
|
function printHelp() {
|
|
573
639
|
log.message(`
|
|
574
640
|
Usage:
|
package/src/neon.ts
CHANGED
|
@@ -52,11 +52,11 @@ export async function listBranches(projectId: string, api = createNeonApi()): Pr
|
|
|
52
52
|
return api.listBranches(projectId);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export async function discoverNeonDefaults(
|
|
55
|
+
export async function discoverNeonDefaults(serviceLabel = "this service", api = createNeonApi()) {
|
|
56
56
|
const projects = await listProjects(api);
|
|
57
57
|
const project = projects[0];
|
|
58
58
|
if (!project) {
|
|
59
|
-
throw new Error(`No Neon projects are available for ${
|
|
59
|
+
throw new Error(`No Neon projects are available for ${serviceLabel}`);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const branches = await listBranches(project.id, api);
|
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 {
|
|
@@ -72,10 +72,22 @@ 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('"
|
|
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");
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
});
|
|
85
|
+
|
|
86
|
+
test("detects conflicting files before scaffold generation", async () => {
|
|
87
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-conflict-"));
|
|
88
|
+
const generatedRoot = join(root, "existing");
|
|
89
|
+
await mkdir(generatedRoot, { recursive: true });
|
|
90
|
+
await writeFile(join(generatedRoot, "README.md"), "hello");
|
|
91
|
+
|
|
92
|
+
await expect(assertTargetDirectoryIsEmpty(generatedRoot)).rejects.toBeInstanceOf(DirectoryConflictError);
|
|
93
|
+
});
|
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,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
|
+
|
|
@@ -33,6 +33,27 @@ Bootstrap and deploy use:
|
|
|
33
33
|
- `gh`
|
|
34
34
|
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` + `VAULT_TOKEN`
|
|
35
35
|
|
|
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.
|
|
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
|
+
|
|
36
57
|
Optional Vault overrides for Neon admin key lookup:
|
|
37
58
|
|
|
38
59
|
- `VAULT_SECRET_MOUNT` default `secret`
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
await runStep("Ensuring runtime and deployer service accounts", () => {
|
|
33
|
+
ensureServiceAccount(config.runtimeServiceAccount);
|
|
34
|
+
ensureServiceAccount(config.deployerServiceAccount);
|
|
35
|
+
});
|
|
33
36
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
+
}
|