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.
Files changed (91) hide show
  1. package/README.md +130 -11
  2. package/package.json +9 -4
  3. package/src/cli.test.ts +29 -8
  4. package/src/cli.ts +103 -70
  5. package/src/naming.test.ts +4 -2
  6. package/src/naming.ts +9 -1
  7. package/src/neon.ts +10 -8
  8. package/src/post-scaffold.ts +7 -28
  9. package/src/profiles.ts +28 -0
  10. package/src/scaffold.test.ts +126 -15
  11. package/src/scaffold.ts +94 -23
  12. package/src/vault.test.ts +33 -9
  13. package/src/vault.ts +4 -3
  14. package/templates/shared/README.md +135 -24
  15. package/templates/shared/docker-compose.yml +19 -0
  16. package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
  17. package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
  18. package/templates/shared/scripts/cloudrun/config.ts +14 -19
  19. package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
  20. package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
  21. package/templates/shared/scripts/cloudrun/lib.ts +88 -112
  22. package/templates/shared/scripts/cloudrun/neon.ts +82 -13
  23. package/templates/shared/service.yaml +44 -1
  24. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  25. package/templates/variants/bun-connectrpc/Makefile +4 -1
  26. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
  27. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
  28. package/templates/variants/bun-connectrpc/package.json +17 -0
  29. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  31. package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
  32. package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
  33. package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
  34. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  35. package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
  36. package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
  37. package/templates/variants/bun-connectrpc/src/index.ts +294 -22
  38. package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
  39. package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
  40. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  41. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
  42. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  43. package/templates/variants/bun-hono/Makefile +4 -1
  44. package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
  45. package/templates/variants/bun-hono/package.json +13 -0
  46. package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
  47. package/templates/variants/bun-hono/src/chat/service.ts +384 -0
  48. package/templates/variants/bun-hono/src/chat/types.ts +142 -0
  49. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  50. package/templates/variants/bun-hono/src/db/repository.ts +479 -0
  51. package/templates/variants/bun-hono/src/db/schema.ts +75 -0
  52. package/templates/variants/bun-hono/src/index.ts +254 -8
  53. package/templates/variants/bun-hono/src/storage.ts +72 -0
  54. package/templates/variants/bun-hono/src/webhooks.ts +35 -0
  55. package/templates/variants/bun-hono/test/app.test.ts +60 -6
  56. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
  57. package/templates/variants/bun-hono/tsconfig.json +1 -0
  58. package/templates/variants/go-chi/Makefile +6 -2
  59. package/templates/variants/go-chi/buf.gen.yaml +2 -0
  60. package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
  61. package/templates/variants/go-chi/cmd/server/main.go +16 -15
  62. package/templates/variants/go-chi/go.mod +3 -0
  63. package/templates/variants/go-chi/internal/app/service.go +763 -71
  64. package/templates/variants/go-chi/internal/config/config.go +22 -7
  65. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
  66. package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
  67. package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
  68. package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
  69. package/templates/variants/go-chi/test/go.test.ts +4 -1
  70. package/templates/variants/go-connectrpc/Makefile +6 -2
  71. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  72. package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
  73. package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
  74. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
  75. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
  76. package/templates/variants/go-connectrpc/go.mod +4 -0
  77. package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
  78. package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
  79. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
  80. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
  81. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
  82. package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
  83. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
  84. package/templates/shared/.env.example +0 -10
  85. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  86. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  87. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  88. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  89. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  90. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  91. 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
- return (payload.projects ?? [])
25
- .map((project) => ({
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
- return (payload.branches ?? [])
37
- .map((branch) => ({
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
  }
@@ -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
- run("bun", ["run", "bootstrap"], { cwd });
26
- run("bun", ["run", "deploy"], { cwd });
27
- return { message: "Repository initialized, pushed, and first deploy started" };
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
- run("git", ["push", "-u", "origin", "main"], { cwd, allowFailure: true });
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
  });
@@ -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
+ }
@@ -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
- runtime: "go",
12
- framework: "chi",
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
- githubRepo: "anmho/dns-api",
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: "project-123"');
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 workflow = await Bun.file(join(generatedRoot, ".github", "workflows", "personal.yml")).text();
64
- expect(workflow).toContain("workflow_dispatch");
65
- expect(workflow).toContain("--environment personal");
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("NewDNSService");
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
- expect(entrypoint).toContain(variant.framework === "hono" ? "Hono" : "rpc.example.v1.Service/Ping");
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 [sharedTemplateRoot, variantTemplateRoot]) {
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 [githubOwner = "anmho"] = config.githubRepo.split("/");
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: config.neonProjectId,
133
- NEON_BASE_BRANCH_ID: config.neonBaseBranchId,
134
- NEON_BASE_BRANCH_NAME: config.neonBaseBranchName,
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
- DEPLOYER_SERVICE_ACCOUNT: deployerServiceAccount,
141
- WIF_POOL_ID: wifPoolId,
142
- WIF_PROVIDER_ID: wifProviderId,
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/provider/neon-api-key");
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
- value: "vault-token",
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: "provider/neon-api-key",
40
- field: "value",
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
- value: "vault-token",
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: "provider/neon-api-key",
72
- field: "value",
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 = "provider/neon-api-key";
6
- const DEFAULT_NEON_API_KEY_FIELD = "value";
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 tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(homedir(), ".vault-token");
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();