create-svc 0.1.9 → 0.1.10
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/README.md +130 -11
- package/package.json +9 -4
- package/src/cli.test.ts +29 -8
- package/src/cli.ts +103 -70
- package/src/naming.test.ts +4 -2
- package/src/naming.ts +9 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.ts +7 -28
- package/src/profiles.ts +28 -0
- package/src/scaffold.test.ts +126 -15
- package/src/scaffold.ts +94 -23
- package/src/vault.test.ts +33 -9
- package/src/vault.ts +4 -3
- package/templates/shared/README.md +135 -24
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
- package/templates/shared/scripts/cloudrun/config.ts +14 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
- package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
- package/templates/shared/scripts/cloudrun/lib.ts +88 -112
- package/templates/shared/scripts/cloudrun/neon.ts +82 -13
- package/templates/shared/service.yaml +44 -1
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-connectrpc/package.json +17 -0
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
- package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
- package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
- package/templates/variants/bun-connectrpc/src/index.ts +294 -22
- package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
- package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-hono/package.json +13 -0
- package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
- package/templates/variants/bun-hono/src/chat/service.ts +384 -0
- package/templates/variants/bun-hono/src/chat/types.ts +142 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +479 -0
- package/templates/variants/bun-hono/src/db/schema.ts +75 -0
- package/templates/variants/bun-hono/src/index.ts +254 -8
- package/templates/variants/bun-hono/src/storage.ts +72 -0
- package/templates/variants/bun-hono/src/webhooks.ts +35 -0
- package/templates/variants/bun-hono/test/app.test.ts +60 -6
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +6 -2
- package/templates/variants/go-chi/buf.gen.yaml +2 -0
- package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
- package/templates/variants/go-chi/cmd/server/main.go +16 -15
- package/templates/variants/go-chi/go.mod +3 -0
- package/templates/variants/go-chi/internal/app/service.go +763 -71
- package/templates/variants/go-chi/internal/config/config.go +22 -7
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
- package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +6 -2
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
- package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
package/src/neon.ts
CHANGED
|
@@ -21,25 +21,27 @@ export function createNeonApi(apiKey = process.env.NEON_API_KEY): NeonApi {
|
|
|
21
21
|
async listProjects() {
|
|
22
22
|
const client = createApiClient({ apiKey: (apiKey?.trim() || (await resolveNeonApiKey())) });
|
|
23
23
|
const payload = await client.listProjects({ limit: 100 });
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const projects = ((payload.data as { projects?: Array<{ id?: string; name?: string }> } | undefined)?.projects ?? []);
|
|
25
|
+
return projects
|
|
26
|
+
.map((project: { id?: string; name?: string }) => ({
|
|
26
27
|
id: project.id ?? "",
|
|
27
28
|
name: project.name ?? project.id ?? "",
|
|
28
29
|
}))
|
|
29
|
-
.filter((project) => project.id)
|
|
30
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
30
|
+
.filter((project: NeonProject) => Boolean(project.id))
|
|
31
|
+
.sort((left: NeonProject, right: NeonProject) => left.name.localeCompare(right.name));
|
|
31
32
|
},
|
|
32
33
|
|
|
33
34
|
async listBranches(projectId: string) {
|
|
34
35
|
const client = createApiClient({ apiKey: (apiKey?.trim() || (await resolveNeonApiKey())) });
|
|
35
36
|
const payload = await client.listProjectBranches({ projectId });
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const branches = ((payload.data as { branches?: Array<{ id?: string; name?: string }> } | undefined)?.branches ?? []);
|
|
38
|
+
return branches
|
|
39
|
+
.map((branch: { id?: string; name?: string }) => ({
|
|
38
40
|
id: branch.id ?? "",
|
|
39
41
|
name: branch.name ?? branch.id ?? "",
|
|
40
42
|
}))
|
|
41
|
-
.filter((branch) => branch.id)
|
|
42
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
43
|
+
.filter((branch: NeonBranch) => Boolean(branch.id))
|
|
44
|
+
.sort((left: NeonBranch, right: NeonBranch) => left.name.localeCompare(right.name));
|
|
43
45
|
},
|
|
44
46
|
};
|
|
45
47
|
}
|
package/src/post-scaffold.ts
CHANGED
|
@@ -13,39 +13,18 @@ type CommandResult = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const decoder = new TextDecoder();
|
|
16
|
+
const encoder = new TextEncoder();
|
|
16
17
|
|
|
17
18
|
export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
18
|
-
if (config.createGithubRepo) {
|
|
19
|
-
initializeRepository(cwd);
|
|
20
|
-
createGitHubRepo(config, cwd);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
if (config.autoDeploy) {
|
|
24
20
|
installProjectDependencies(cwd);
|
|
25
|
-
|
|
26
|
-
run("bun"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return { message: "Repository initialized" };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function initializeRepository(cwd: string) {
|
|
34
|
-
requireCommand("git");
|
|
35
|
-
run("git", ["init", "-b", "main"], { cwd, allowFailure: true });
|
|
36
|
-
run("git", ["add", "."], { cwd });
|
|
37
|
-
run("git", ["commit", "--allow-empty", "-m", "Initial commit"], { cwd, allowFailure: true });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function createGitHubRepo(config: ScaffoldConfig, cwd: string) {
|
|
41
|
-
requireCommand("gh");
|
|
42
|
-
|
|
43
|
-
const existing = run("gh", ["repo", "view", config.githubRepo], { cwd, allowFailure: true });
|
|
44
|
-
if (!existing.success) {
|
|
45
|
-
run("gh", ["repo", "create", config.githubRepo, `--${config.githubVisibility}`, "--source=.", "--remote=origin"], { cwd });
|
|
21
|
+
const command = config.runtime === "bun" ? "bun" : "make";
|
|
22
|
+
run(command, config.runtime === "bun" ? ["run", "bootstrap"] : ["bootstrap"], { cwd });
|
|
23
|
+
run(command, config.runtime === "bun" ? ["run", "deploy"] : ["deploy"], { cwd });
|
|
24
|
+
return { message: "Dependencies installed and first deploy started" };
|
|
46
25
|
}
|
|
47
26
|
|
|
48
|
-
|
|
27
|
+
return { message: "Backend package generated" };
|
|
49
28
|
}
|
|
50
29
|
|
|
51
30
|
function installProjectDependencies(cwd: string) {
|
|
@@ -63,7 +42,7 @@ function run(command: string, args: string[], options: CommandOptions): CommandR
|
|
|
63
42
|
const result = Bun.spawnSync([command, ...args], {
|
|
64
43
|
cwd: options.cwd,
|
|
65
44
|
env: process.env,
|
|
66
|
-
stdin: options.input,
|
|
45
|
+
stdin: options.input === undefined ? undefined : encoder.encode(options.input),
|
|
67
46
|
stdout: options.allowFailure ? "pipe" : "inherit",
|
|
68
47
|
stderr: options.allowFailure ? "pipe" : "inherit",
|
|
69
48
|
});
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const PROFILES = ["microservice"] as const;
|
|
2
|
+
|
|
3
|
+
export type Profile = (typeof PROFILES)[number];
|
|
4
|
+
|
|
5
|
+
export function parseProfile(value: string): Profile {
|
|
6
|
+
if (PROFILES.includes(value as Profile)) {
|
|
7
|
+
return value as Profile;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (value === "app") {
|
|
11
|
+
throw new Error(
|
|
12
|
+
[
|
|
13
|
+
"The app profile moved out of create-svc.",
|
|
14
|
+
"Use the private GitHub template repos anmho/create-app-consumer or anmho/create-app-saas instead.",
|
|
15
|
+
].join(" ")
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw new Error(`Unknown profile: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function exampleForProfile(profile: Profile) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "microservice",
|
|
25
|
+
domain: "waitlist",
|
|
26
|
+
label: "waitlist/launch service",
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/scaffold.test.ts
CHANGED
|
@@ -2,30 +2,28 @@ import { expect, test } from "bun:test";
|
|
|
2
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 { deriveLocalPostgresPort } from "./naming";
|
|
5
6
|
import { DirectoryConflictError, assertTargetDirectoryIsEmpty, scaffoldProject, type ScaffoldConfig } from "./scaffold";
|
|
6
7
|
|
|
7
8
|
function baseConfig(overrides: Partial<ScaffoldConfig> = {}): ScaffoldConfig {
|
|
8
9
|
return {
|
|
9
10
|
directory: "svc",
|
|
10
11
|
serviceName: "dns-api",
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
modulePath: "example.com/dns-api",
|
|
13
|
+
runtime: "bun",
|
|
14
|
+
framework: "hono",
|
|
13
15
|
region: "us-west1",
|
|
14
16
|
gcpProjectMode: "create_new",
|
|
15
17
|
gcpProject: "anmho-dns-api",
|
|
16
18
|
gcpProjectName: "dns-api",
|
|
17
19
|
billingAccount: "billingAccounts/01BD2E-3A6949-8F4C84",
|
|
18
20
|
quotaProjectId: "anmho-infra-prod",
|
|
19
|
-
|
|
20
|
-
githubVisibility: "public",
|
|
21
|
-
createGithubRepo: true,
|
|
22
|
-
autoDeploy: false,
|
|
23
|
-
neonProjectId: "project-123",
|
|
24
|
-
neonBaseBranchId: "br-main",
|
|
25
|
-
neonBaseBranchName: "main",
|
|
21
|
+
profile: "microservice",
|
|
26
22
|
neonDatabaseName: "dns_api",
|
|
23
|
+
apiHostname: "api.dns-api.anmho.com",
|
|
27
24
|
generatorRoot: join(import.meta.dir, ".."),
|
|
28
25
|
...overrides,
|
|
26
|
+
autoDeploy: overrides.autoDeploy ?? false,
|
|
29
27
|
};
|
|
30
28
|
}
|
|
31
29
|
|
|
@@ -40,6 +38,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
40
38
|
for (const variant of cases) {
|
|
41
39
|
const root = await mkdtemp(join(tmpdir(), "create-svc-"));
|
|
42
40
|
const generatedRoot = join(root, `${variant.runtime}-${variant.framework}`);
|
|
41
|
+
const localPort = deriveLocalPostgresPort("dns-api");
|
|
43
42
|
|
|
44
43
|
await scaffoldProject(
|
|
45
44
|
baseConfig({
|
|
@@ -50,37 +49,149 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
50
49
|
);
|
|
51
50
|
|
|
52
51
|
const configScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "config.ts")).text();
|
|
52
|
+
expect(configScript).toContain('profile: "microservice"');
|
|
53
|
+
expect(configScript).toContain('domain: "waitlist"');
|
|
54
|
+
expect(configScript).toContain('kind: "microservice"');
|
|
53
55
|
expect(configScript).toContain(`runtime: "${variant.runtime}"`);
|
|
54
56
|
expect(configScript).toContain(`framework: "${variant.framework}"`);
|
|
55
57
|
expect(configScript).toContain('mode: "create_new"');
|
|
56
58
|
expect(configScript).toContain('quotaProjectId: "anmho-infra-prod"');
|
|
57
|
-
expect(configScript).toContain('projectId: "
|
|
59
|
+
expect(configScript).toContain('projectId: ""');
|
|
60
|
+
expect(configScript).toContain('baseBranchId: ""');
|
|
61
|
+
expect(configScript).toContain('baseBranchName: "main"');
|
|
58
62
|
expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
|
|
63
|
+
expect(configScript).toContain('hostname: "api.dns-api.anmho.com"');
|
|
64
|
+
expect(configScript).toContain('attachmentBucket: "anmho-dns-api-dns-api-attachments"');
|
|
65
|
+
expect(configScript).toContain('attachmentPublicBaseUrl: "https://storage.googleapis.com/anmho-dns-api-dns-api-attachments"');
|
|
66
|
+
expect(configScript).not.toContain("github:");
|
|
59
67
|
|
|
60
68
|
const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
|
|
61
69
|
expect(deployScript).toContain('--billing-project", config.project.quotaProjectId');
|
|
70
|
+
expect(deployScript).toContain("serviceDomain");
|
|
71
|
+
expect(deployScript).toContain("ensureProductionDomainMapping");
|
|
72
|
+
expect(deployScript).toContain("ensureStorageBucket");
|
|
62
73
|
|
|
63
|
-
const
|
|
64
|
-
expect(
|
|
65
|
-
|
|
74
|
+
const bootstrapScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "bootstrap.ts")).text();
|
|
75
|
+
expect(bootstrapScript).toContain("publishProviderRuntimeSecrets");
|
|
76
|
+
|
|
77
|
+
const manifest = await Bun.file(join(generatedRoot, "service.yaml")).text();
|
|
78
|
+
expect(manifest).toContain("CLERK_SECRET_KEY");
|
|
79
|
+
expect(manifest).toContain("STRIPE_SECRET_KEY");
|
|
80
|
+
expect(manifest).toContain("REVENUECAT_API_KEY");
|
|
81
|
+
expect(manifest).toContain("RESEND_API_KEY");
|
|
82
|
+
expect(manifest).toContain("POSTHOG_API_KEY");
|
|
83
|
+
|
|
84
|
+
const gitignore = await Bun.file(join(generatedRoot, ".gitignore")).text();
|
|
85
|
+
expect(gitignore).toContain("node_modules");
|
|
86
|
+
expect(await Bun.file(join(generatedRoot, "website", "package.json")).exists()).toBeFalse();
|
|
87
|
+
|
|
88
|
+
const dockerCompose = await Bun.file(join(generatedRoot, "docker-compose.yml")).text();
|
|
89
|
+
expect(dockerCompose).toContain('image: postgres:16-alpine');
|
|
90
|
+
expect(dockerCompose).toContain(`127.0.0.1:${localPort}:5432`);
|
|
91
|
+
|
|
92
|
+
const envExample = await Bun.file(join(generatedRoot, ".env.example")).text();
|
|
93
|
+
expect(envExample).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api`);
|
|
94
|
+
expect(envExample).toContain("ATTACHMENT_BUCKET=dns-api-local-attachments");
|
|
95
|
+
expect(envExample).toContain("CLERK_SECRET_KEY=");
|
|
96
|
+
expect(envExample).toContain("STRIPE_SECRET_KEY=");
|
|
97
|
+
expect(envExample).toContain("REVENUECAT_API_KEY=");
|
|
98
|
+
expect(envExample).toContain("RESEND_API_KEY=");
|
|
99
|
+
expect(envExample).toContain("POSTHOG_API_KEY=");
|
|
100
|
+
|
|
101
|
+
const localEnv = await Bun.file(join(generatedRoot, ".env.local")).text();
|
|
102
|
+
expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api`);
|
|
103
|
+
expect(localEnv).toContain("ATTACHMENT_PUBLIC_BASE_URL=https://storage.local.invalid/dns-api-local-attachments");
|
|
104
|
+
|
|
105
|
+
expect(await Bun.file(join(generatedRoot, ".github", "workflows", "personal.yml")).exists()).toBeFalse();
|
|
66
106
|
|
|
67
107
|
if (variant.runtime === "go") {
|
|
68
108
|
const goMod = await Bun.file(join(generatedRoot, "go.mod")).text();
|
|
69
109
|
expect(goMod).toContain("connectrpc.com/connect");
|
|
110
|
+
expect(goMod).toContain("module example.com/dns-api");
|
|
111
|
+
expect(goMod).not.toContain("module github.com/anmho/dns-api");
|
|
70
112
|
|
|
71
113
|
const mainGo = await Bun.file(join(generatedRoot, "cmd", "server", "main.go")).text();
|
|
72
|
-
expect(mainGo).toContain("
|
|
114
|
+
expect(mainGo).toContain("NewChatService");
|
|
115
|
+
expect(mainGo).toContain("example.com/dns-api");
|
|
73
116
|
} else {
|
|
74
117
|
const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
|
|
75
118
|
expect(packageJson).toContain('"svc-cloudrun": "./scripts/cloudrun/cli.ts"');
|
|
119
|
+
expect(packageJson).toContain('"dev": "bun run ./src/index.ts"');
|
|
120
|
+
expect(packageJson).toContain('"gen": "bun run ./scripts/codegen.ts"');
|
|
121
|
+
expect(packageJson).toContain('"bootstrap": "bun run ./scripts/cloudrun/cli.ts bootstrap"');
|
|
122
|
+
expect(packageJson).toContain('"deploy": "bun run ./scripts/cloudrun/cli.ts deploy"');
|
|
123
|
+
expect(packageJson).toContain('"cleanup": "bun run ./scripts/cloudrun/cli.ts cleanup"');
|
|
76
124
|
|
|
77
125
|
const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
|
|
78
126
|
expect(makefile).toContain("npx --no-install svc-cloudrun");
|
|
79
127
|
|
|
80
128
|
const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
|
|
81
|
-
|
|
129
|
+
const readme = await Bun.file(join(generatedRoot, "README.md")).text();
|
|
130
|
+
if (variant.framework === "connectrpc") {
|
|
131
|
+
expect(entrypoint).toContain("ChatService");
|
|
132
|
+
expect(gitignore).toContain("gen/");
|
|
133
|
+
expect(readme).toContain("Local introspection");
|
|
134
|
+
} else {
|
|
135
|
+
expect(entrypoint).toContain("/v1/conversations");
|
|
136
|
+
expect(gitignore).not.toContain("gen/");
|
|
137
|
+
expect(readme).not.toContain("Local introspection");
|
|
138
|
+
}
|
|
139
|
+
expect(entrypoint).toContain(variant.framework === "hono" ? "Hono" : "connectNodeAdapter");
|
|
140
|
+
expect(readme).toContain("ATTACHMENT_BUCKET");
|
|
141
|
+
expect(readme).toContain("/webhooks/:provider");
|
|
142
|
+
expect(readme).toContain("microservice profile");
|
|
143
|
+
expect(readme).toContain("waitlist/launch service");
|
|
144
|
+
expect(readme).toContain("Terraform is optional");
|
|
82
145
|
}
|
|
83
146
|
}
|
|
147
|
+
}, 30000);
|
|
148
|
+
|
|
149
|
+
test("scaffolds a backend package cleanly into a nested monorepo-style directory", async () => {
|
|
150
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-monorepo-"));
|
|
151
|
+
const generatedRoot = join(root, "apps", "dns-api");
|
|
152
|
+
|
|
153
|
+
await scaffoldProject(
|
|
154
|
+
baseConfig({
|
|
155
|
+
directory: generatedRoot,
|
|
156
|
+
runtime: "bun",
|
|
157
|
+
framework: "hono",
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const readme = await Bun.file(join(generatedRoot, "README.md")).text();
|
|
162
|
+
expect(readme).toContain("`microservice` profile");
|
|
163
|
+
expect(readme).toContain("api.dns-api.anmho.com");
|
|
164
|
+
expect(readme).toContain("docker compose up -d");
|
|
165
|
+
expect(readme).toContain("local Postgres service in `docker-compose.yml`");
|
|
166
|
+
expect(readme).toContain("gcloud auth login");
|
|
167
|
+
expect(readme).toContain("known-good CLIs");
|
|
168
|
+
expect(readme).toContain("bun run bootstrap");
|
|
169
|
+
expect(readme).toContain("bun run deploy");
|
|
170
|
+
expect(readme).toContain("ATTACHMENT_BUCKET");
|
|
171
|
+
expect(readme).toContain("one-command production bootstrap");
|
|
172
|
+
expect(readme).toContain("waitlist/launch service");
|
|
173
|
+
expect(readme).toContain("Terraform is optional");
|
|
174
|
+
expect(readme).toContain("webhook_events");
|
|
175
|
+
expect(readme).not.toContain("Neon main, preview, and personal branch provisioning");
|
|
176
|
+
expect(readme).not.toContain("GitHub Actions");
|
|
177
|
+
expect(readme).not.toContain("repository");
|
|
178
|
+
|
|
179
|
+
const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
|
|
180
|
+
expect(packageJson).toContain('"hono"');
|
|
181
|
+
|
|
182
|
+
const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
|
|
183
|
+
expect(entrypoint).toContain("/v1/attachments/uploads");
|
|
184
|
+
|
|
185
|
+
expect(await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).exists()).toBeFalse();
|
|
186
|
+
}, 15000);
|
|
187
|
+
|
|
188
|
+
test("microservice profile does not generate a website package", async () => {
|
|
189
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-microservice-profile-"));
|
|
190
|
+
const generatedRoot = join(root, "service");
|
|
191
|
+
|
|
192
|
+
await scaffoldProject(baseConfig({ directory: generatedRoot, profile: "microservice" }));
|
|
193
|
+
|
|
194
|
+
expect(await Bun.file(join(generatedRoot, "website", "package.json")).exists()).toBeFalse();
|
|
84
195
|
});
|
|
85
196
|
|
|
86
197
|
test("detects conflicting files before scaffold generation", async () => {
|
package/src/scaffold.ts
CHANGED
|
@@ -2,30 +2,30 @@ import { mkdir, readdir } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
compactIdentifier,
|
|
5
|
+
compactDatabaseName,
|
|
6
|
+
deriveLocalPostgresPort,
|
|
5
7
|
type Framework,
|
|
6
8
|
type GcpProjectMode,
|
|
7
9
|
type Runtime,
|
|
8
10
|
} from "./naming";
|
|
11
|
+
import { exampleForProfile, type Profile } from "./profiles";
|
|
9
12
|
|
|
10
13
|
export type ScaffoldConfig = {
|
|
11
14
|
directory: string;
|
|
12
15
|
serviceName: string;
|
|
16
|
+
modulePath: string;
|
|
13
17
|
runtime: Runtime;
|
|
14
18
|
framework: Framework;
|
|
19
|
+
profile: Profile;
|
|
15
20
|
region: string;
|
|
16
21
|
gcpProjectMode: GcpProjectMode;
|
|
17
22
|
gcpProject: string;
|
|
18
23
|
gcpProjectName: string;
|
|
19
24
|
billingAccount: string;
|
|
20
25
|
quotaProjectId: string;
|
|
21
|
-
githubRepo: string;
|
|
22
|
-
githubVisibility: "public" | "private";
|
|
23
|
-
createGithubRepo: boolean;
|
|
24
26
|
autoDeploy: boolean;
|
|
25
|
-
neonProjectId: string;
|
|
26
|
-
neonBaseBranchId: string;
|
|
27
|
-
neonBaseBranchName: string;
|
|
28
27
|
neonDatabaseName: string;
|
|
28
|
+
apiHostname: string;
|
|
29
29
|
generatorRoot: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -48,8 +48,9 @@ export async function scaffoldProject(config: ScaffoldConfig) {
|
|
|
48
48
|
const replacements = buildReplacements(config);
|
|
49
49
|
const sharedTemplateRoot = resolve(config.generatorRoot, "templates", "shared");
|
|
50
50
|
const variantTemplateRoot = resolve(config.generatorRoot, "templates", "variants", `${config.runtime}-${config.framework}`);
|
|
51
|
+
const templateRoots = [sharedTemplateRoot, variantTemplateRoot];
|
|
51
52
|
|
|
52
|
-
for (const templateRoot of
|
|
53
|
+
for (const templateRoot of templateRoots) {
|
|
53
54
|
const files = await collectTemplateFiles(templateRoot);
|
|
54
55
|
|
|
55
56
|
for (const relativePath of files) {
|
|
@@ -62,6 +63,8 @@ export async function scaffoldProject(config: ScaffoldConfig) {
|
|
|
62
63
|
await Bun.write(destinationPath, rendered);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
await writeLocalEnvFile(targetDir, replacements);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
async function ensureTargetDirectory(targetDir: string) {
|
|
@@ -91,9 +94,15 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
|
|
|
91
94
|
for (const entry of entries) {
|
|
92
95
|
const nextRelative = relative ? join(relative, entry.name) : entry.name;
|
|
93
96
|
if (entry.isDirectory()) {
|
|
97
|
+
if (nextRelative === ".github" || nextRelative.startsWith(".github/")) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
94
100
|
files.push(...(await collectTemplateFiles(root, nextRelative)));
|
|
95
101
|
continue;
|
|
96
102
|
}
|
|
103
|
+
if (nextRelative === ".github" || nextRelative.startsWith(".github/")) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
97
106
|
files.push(nextRelative);
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -101,19 +110,21 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
|
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
function buildReplacements(config: ScaffoldConfig) {
|
|
104
|
-
const
|
|
105
|
-
const modulePath = `github.com/${config.githubRepo}`;
|
|
113
|
+
const example = exampleForProfile(config.profile);
|
|
106
114
|
const serviceAccountBase = compactIdentifier(config.serviceName, 21);
|
|
107
115
|
const runtimeServiceAccount = `${serviceAccountBase}-runtime@${config.gcpProject}.iam.gserviceaccount.com`;
|
|
108
|
-
const deployerServiceAccount = `${serviceAccountBase}-deployer@${config.gcpProject}.iam.gserviceaccount.com`;
|
|
109
|
-
const wifPoolId = "github";
|
|
110
|
-
const wifProviderId = compactIdentifier(config.serviceName, 32);
|
|
111
116
|
const previewBranchPrefix = `${config.serviceName}-pr`;
|
|
112
117
|
const personalBranchPrefix = `${config.serviceName}-dev`;
|
|
118
|
+
const remoteAttachmentBucket = `${config.gcpProject}-${config.serviceName}-attachments`;
|
|
119
|
+
const remoteAttachmentPublicBaseUrl = `https://storage.googleapis.com/${remoteAttachmentBucket}`;
|
|
120
|
+
const localDatabaseName = compactDatabaseName(config.serviceName);
|
|
121
|
+
const localDatabasePort = deriveLocalPostgresPort(config.serviceName);
|
|
122
|
+
const localAttachmentBucket = `${config.serviceName}-local-attachments`;
|
|
123
|
+
const localAttachmentPublicBaseUrl = `https://storage.local.invalid/${localAttachmentBucket}`;
|
|
113
124
|
|
|
114
125
|
return {
|
|
115
126
|
SERVICE_NAME: config.serviceName,
|
|
116
|
-
MODULE_PATH: modulePath,
|
|
127
|
+
MODULE_PATH: config.modulePath,
|
|
117
128
|
PROJECT_ID: config.gcpProject,
|
|
118
129
|
PROJECT_NAME: config.gcpProjectName,
|
|
119
130
|
REGION: config.region,
|
|
@@ -121,28 +132,88 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
121
132
|
PROJECT_CREATE_IF_MISSING: String(config.gcpProjectMode === "create_new"),
|
|
122
133
|
BILLING_ACCOUNT: config.billingAccount,
|
|
123
134
|
QUOTA_PROJECT_ID: config.quotaProjectId,
|
|
124
|
-
GITHUB_REPO: config.githubRepo,
|
|
125
|
-
GITHUB_OWNER: githubOwner,
|
|
126
|
-
GITHUB_VISIBILITY: config.githubVisibility,
|
|
127
|
-
GITHUB_CREATE_IF_MISSING: String(config.createGithubRepo),
|
|
128
135
|
AUTO_DEPLOY: String(config.autoDeploy),
|
|
129
136
|
RUNTIME: config.runtime,
|
|
130
137
|
FRAMEWORK: config.framework,
|
|
138
|
+
PROFILE: config.profile,
|
|
139
|
+
EXAMPLE_KIND: example.kind,
|
|
140
|
+
EXAMPLE_DOMAIN: example.domain,
|
|
141
|
+
EXAMPLE_LABEL: example.label,
|
|
131
142
|
CLOUD_RUN_SERVICE: config.serviceName,
|
|
132
|
-
NEON_PROJECT_ID:
|
|
133
|
-
NEON_BASE_BRANCH_ID:
|
|
134
|
-
NEON_BASE_BRANCH_NAME:
|
|
143
|
+
NEON_PROJECT_ID: "",
|
|
144
|
+
NEON_BASE_BRANCH_ID: "",
|
|
145
|
+
NEON_BASE_BRANCH_NAME: "main",
|
|
135
146
|
NEON_DATABASE_NAME: config.neonDatabaseName,
|
|
136
147
|
NEON_ROLE_NAME: "neondb_owner",
|
|
137
148
|
NEON_PREVIEW_BRANCH_PREFIX: previewBranchPrefix,
|
|
138
149
|
NEON_PERSONAL_BRANCH_PREFIX: personalBranchPrefix,
|
|
139
150
|
RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
API_HOSTNAME: config.apiHostname,
|
|
152
|
+
API_BASE_DOMAIN: "anmho.com",
|
|
153
|
+
ATTACHMENT_BUCKET: remoteAttachmentBucket,
|
|
154
|
+
ATTACHMENT_PUBLIC_BASE_URL: remoteAttachmentPublicBaseUrl,
|
|
155
|
+
LOCAL_DATABASE_NAME: localDatabaseName,
|
|
156
|
+
LOCAL_DATABASE_PORT: localDatabasePort,
|
|
157
|
+
LOCAL_DATABASE_USER: "postgres",
|
|
158
|
+
LOCAL_DATABASE_PASSWORD: "postgres",
|
|
159
|
+
LOCAL_ATTACHMENT_BUCKET: localAttachmentBucket,
|
|
160
|
+
LOCAL_ATTACHMENT_PUBLIC_BASE_URL: localAttachmentPublicBaseUrl,
|
|
161
|
+
COMMAND_DEV: config.runtime === "bun" ? "bun run dev" : "make dev",
|
|
162
|
+
COMMAND_MIGRATE: config.runtime === "bun" ? "bun run migrate" : "make migrate",
|
|
163
|
+
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
164
|
+
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
165
|
+
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
166
|
+
COMMAND_BOOTSTRAP: config.runtime === "bun" ? "bun run bootstrap" : "make bootstrap",
|
|
167
|
+
COMMAND_DEPLOY: config.runtime === "bun" ? "bun run deploy" : "make deploy",
|
|
168
|
+
COMMAND_DEPLOY_PERSONAL:
|
|
169
|
+
config.runtime === "bun"
|
|
170
|
+
? 'bun run deploy -- --environment personal --name <slug>'
|
|
171
|
+
: 'make deploy ARGS="--environment personal --name <slug>"',
|
|
172
|
+
COMMAND_DEPLOY_DESTROY:
|
|
173
|
+
config.runtime === "bun"
|
|
174
|
+
? 'bun run deploy -- --destroy --environment personal --name <slug>'
|
|
175
|
+
: 'make deploy ARGS="--destroy --environment personal --name <slug>"',
|
|
176
|
+
COMMAND_CLEANUP: config.runtime === "bun" ? "bun run cleanup" : "make cleanup",
|
|
177
|
+
COMMAND_CLEANUP_PROJECT: config.runtime === "bun" ? "bun run cleanup -- --project" : 'make cleanup ARGS="--project"',
|
|
178
|
+
GITIGNORE_EXTRA: config.framework === "connectrpc" ? "gen/" : "",
|
|
179
|
+
LOCAL_INTROSPECTION_NOTE:
|
|
180
|
+
config.framework === "connectrpc"
|
|
181
|
+
? [
|
|
182
|
+
"",
|
|
183
|
+
"## Local introspection",
|
|
184
|
+
"",
|
|
185
|
+
"When running locally, ConnectRPC variants expose introspection by default.",
|
|
186
|
+
"",
|
|
187
|
+
"- `go + connectrpc`: standard gRPC reflection for tools like `grpcurl list localhost:<port>`",
|
|
188
|
+
"- `bun + connectrpc`: JSON introspection at `/debug/connectrpc`",
|
|
189
|
+
"- override with `ENABLE_RPC_INTROSPECTION=true|false`",
|
|
190
|
+
].join("\n")
|
|
191
|
+
: "",
|
|
143
192
|
};
|
|
144
193
|
}
|
|
145
194
|
|
|
195
|
+
async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
|
|
196
|
+
const envPath = join(targetDir, ".env.local");
|
|
197
|
+
if (await Bun.file(envPath).exists()) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const rendered = renderTemplate(
|
|
202
|
+
[
|
|
203
|
+
"# Generated local development defaults for create-svc.",
|
|
204
|
+
"# This file is user-owned after scaffold and is gitignored.",
|
|
205
|
+
"",
|
|
206
|
+
"DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}",
|
|
207
|
+
"ATTACHMENT_BUCKET={{LOCAL_ATTACHMENT_BUCKET}}",
|
|
208
|
+
"ATTACHMENT_PUBLIC_BASE_URL={{LOCAL_ATTACHMENT_PUBLIC_BASE_URL}}",
|
|
209
|
+
"",
|
|
210
|
+
].join("\n"),
|
|
211
|
+
replacements
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await Bun.write(envPath, rendered);
|
|
215
|
+
}
|
|
216
|
+
|
|
146
217
|
function renderTemplate(input: string, replacements: Record<string, string>) {
|
|
147
218
|
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key: string) => {
|
|
148
219
|
const replacement = replacements[key];
|
package/src/vault.test.ts
CHANGED
|
@@ -14,17 +14,41 @@ test("resolveNeonApiKey prefers NEON_API_KEY from env", async () => {
|
|
|
14
14
|
await expect(resolveNeonApiKey()).resolves.toBe("direct-token");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
test("resolveNeonApiKey reads the purpose-named Neon provider secret by default", async () => {
|
|
18
|
+
delete process.env.NEON_API_KEY;
|
|
19
|
+
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
20
|
+
process.env.VAULT_TOKEN = "token-123";
|
|
21
|
+
|
|
22
|
+
const fetchMock = mock(async (input: string | URL | Request) => {
|
|
23
|
+
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
data: {
|
|
27
|
+
data: {
|
|
28
|
+
api_key: "vault-token",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
{ status: 200 }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
37
|
+
|
|
38
|
+
await expect(resolveNeonApiKey()).resolves.toBe("vault-token");
|
|
39
|
+
});
|
|
40
|
+
|
|
17
41
|
test("readVaultSecret reads KV v2 secret data using existing vault login env", async () => {
|
|
18
42
|
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
19
43
|
process.env.VAULT_TOKEN = "token-123";
|
|
20
44
|
|
|
21
45
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
|
22
|
-
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/
|
|
46
|
+
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
|
|
23
47
|
return new Response(
|
|
24
48
|
JSON.stringify({
|
|
25
49
|
data: {
|
|
26
50
|
data: {
|
|
27
|
-
|
|
51
|
+
api_key: "vault-token",
|
|
28
52
|
},
|
|
29
53
|
},
|
|
30
54
|
}),
|
|
@@ -32,12 +56,12 @@ test("readVaultSecret reads KV v2 secret data using existing vault login env", a
|
|
|
32
56
|
);
|
|
33
57
|
});
|
|
34
58
|
|
|
35
|
-
globalThis.fetch = fetchMock as typeof fetch;
|
|
59
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
36
60
|
|
|
37
61
|
await expect(
|
|
38
62
|
readVaultSecret({
|
|
39
|
-
path: "
|
|
40
|
-
field: "
|
|
63
|
+
path: "prod/providers/neon",
|
|
64
|
+
field: "api_key",
|
|
41
65
|
})
|
|
42
66
|
).resolves.toBe("vault-token");
|
|
43
67
|
});
|
|
@@ -56,7 +80,7 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
|
56
80
|
JSON.stringify({
|
|
57
81
|
data: {
|
|
58
82
|
data: {
|
|
59
|
-
|
|
83
|
+
api_key: "vault-token",
|
|
60
84
|
},
|
|
61
85
|
},
|
|
62
86
|
}),
|
|
@@ -64,12 +88,12 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
|
64
88
|
);
|
|
65
89
|
});
|
|
66
90
|
|
|
67
|
-
globalThis.fetch = fetchMock as typeof fetch;
|
|
91
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
68
92
|
|
|
69
93
|
await expect(
|
|
70
94
|
readVaultSecret({
|
|
71
|
-
path: "
|
|
72
|
-
field: "
|
|
95
|
+
path: "prod/providers/neon",
|
|
96
|
+
field: "api_key",
|
|
73
97
|
})
|
|
74
98
|
).resolves.toBe("vault-token");
|
|
75
99
|
});
|
package/src/vault.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_VAULT_SECRET_MOUNT = "secret";
|
|
5
|
-
const DEFAULT_NEON_API_KEY_PATH = "
|
|
6
|
-
const DEFAULT_NEON_API_KEY_FIELD = "
|
|
5
|
+
const DEFAULT_NEON_API_KEY_PATH = "prod/providers/neon";
|
|
6
|
+
const DEFAULT_NEON_API_KEY_FIELD = "api_key";
|
|
7
7
|
|
|
8
8
|
type VaultSecretOptions = {
|
|
9
9
|
addr?: string;
|
|
@@ -71,7 +71,8 @@ async function resolveVaultToken() {
|
|
|
71
71
|
return direct;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
const home = process.env.HOME?.trim() || homedir();
|
|
75
|
+
const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(home, ".vault-token");
|
|
75
76
|
|
|
76
77
|
try {
|
|
77
78
|
const value = (await Bun.file(tokenFile).text()).trim();
|