create-svc 0.1.1 → 0.1.3

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 (73) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -1
  3. package/src/cli.test.ts +10 -0
  4. package/src/cli.ts +331 -107
  5. package/src/gcp.test.ts +71 -0
  6. package/src/gcp.ts +97 -0
  7. package/src/naming.test.ts +37 -0
  8. package/src/naming.ts +103 -0
  9. package/src/neon.test.ts +48 -0
  10. package/src/neon.ts +76 -0
  11. package/src/post-scaffold.ts +77 -0
  12. package/src/scaffold.test.ts +66 -31
  13. package/src/scaffold.ts +60 -55
  14. package/templates/root/.github/workflows/deploy.yml +1 -1
  15. package/templates/root/README.md +3 -3
  16. package/templates/shared/.github/workflows/ci.yml +22 -0
  17. package/templates/shared/.github/workflows/deploy.yml +30 -0
  18. package/templates/shared/.github/workflows/personal.yml +41 -0
  19. package/templates/shared/.github/workflows/preview-cleanup.yml +25 -0
  20. package/templates/shared/.github/workflows/preview.yml +29 -0
  21. package/templates/shared/README.md +37 -0
  22. package/templates/shared/scripts/cloudrun/bootstrap.ts +76 -0
  23. package/templates/shared/scripts/cloudrun/config.ts +57 -0
  24. package/templates/shared/scripts/cloudrun/deploy.ts +82 -0
  25. package/templates/shared/scripts/cloudrun/lib.ts +380 -0
  26. package/templates/shared/scripts/cloudrun/neon.ts +104 -0
  27. package/templates/shared/service.yaml +28 -0
  28. package/templates/variants/bun-connectrpc/Dockerfile +13 -0
  29. package/templates/variants/bun-connectrpc/package.json +20 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -0
  31. package/templates/variants/bun-connectrpc/src/index.ts +32 -0
  32. package/templates/variants/bun-connectrpc/test/app.test.ts +17 -0
  33. package/templates/variants/bun-connectrpc/tsconfig.json +10 -0
  34. package/templates/variants/bun-hono/Dockerfile +13 -0
  35. package/templates/variants/bun-hono/package.json +21 -0
  36. package/templates/variants/bun-hono/scripts/codegen.ts +1 -0
  37. package/templates/variants/bun-hono/src/index.ts +24 -0
  38. package/templates/variants/bun-hono/test/app.test.ts +12 -0
  39. package/templates/variants/bun-hono/tsconfig.json +10 -0
  40. package/templates/variants/go-chi/Dockerfile +23 -0
  41. package/templates/variants/go-chi/buf.gen.yaml +10 -0
  42. package/templates/variants/go-chi/buf.yaml +9 -0
  43. package/templates/variants/go-chi/cmd/server/main.go +52 -0
  44. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +623 -0
  45. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  46. package/templates/variants/go-chi/go.mod +10 -0
  47. package/templates/variants/go-chi/internal/app/service.go +109 -0
  48. package/templates/variants/go-chi/internal/app/token_source.go +50 -0
  49. package/templates/variants/go-chi/internal/cloudflare/client.go +160 -0
  50. package/templates/variants/go-chi/internal/config/config.go +23 -0
  51. package/templates/variants/go-chi/internal/connectapi/handler.go +79 -0
  52. package/templates/variants/go-chi/internal/httpapi/routes.go +93 -0
  53. package/templates/variants/go-chi/internal/vault/client.go +148 -0
  54. package/templates/variants/go-chi/package.json +16 -0
  55. package/templates/variants/go-chi/protos/dns/v1/dns.proto +58 -0
  56. package/templates/variants/go-chi/test/go.test.ts +19 -0
  57. package/templates/variants/go-connectrpc/Dockerfile +23 -0
  58. package/templates/variants/go-connectrpc/buf.gen.yaml +10 -0
  59. package/templates/variants/go-connectrpc/buf.yaml +9 -0
  60. package/templates/variants/go-connectrpc/cmd/server/main.go +51 -0
  61. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +623 -0
  62. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  63. package/templates/variants/go-connectrpc/go.mod +10 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +109 -0
  65. package/templates/variants/go-connectrpc/internal/app/token_source.go +50 -0
  66. package/templates/variants/go-connectrpc/internal/cloudflare/client.go +160 -0
  67. package/templates/variants/go-connectrpc/internal/config/config.go +23 -0
  68. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +79 -0
  69. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +93 -0
  70. package/templates/variants/go-connectrpc/internal/vault/client.go +148 -0
  71. package/templates/variants/go-connectrpc/package.json +16 -0
  72. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +58 -0
  73. package/templates/variants/go-connectrpc/test/go.test.ts +19 -0
@@ -0,0 +1,71 @@
1
+ import { expect, test } from "bun:test";
2
+ import { attachBillingAccount, createProject, listAccessibleProjects, listOpenBillingAccounts, type GcpApi } from "./gcp";
3
+
4
+ test("listAccessibleProjects filters deleted projects and sorts by name", async () => {
5
+ const api: GcpApi = {
6
+ async listProjects() {
7
+ return [
8
+ { projectId: "b", name: "bravo" },
9
+ { projectId: "a", name: "alpha" },
10
+ { projectId: "z", name: "zulu", lifecycleState: "DELETE_REQUESTED" },
11
+ ];
12
+ },
13
+ async listBillingAccounts() {
14
+ return [];
15
+ },
16
+ async createProject() {},
17
+ async attachBillingAccount() {},
18
+ };
19
+
20
+ await expect(listAccessibleProjects(api)).resolves.toEqual([
21
+ { projectId: "a", name: "alpha" },
22
+ { projectId: "b", name: "bravo" },
23
+ ]);
24
+ });
25
+
26
+ test("listOpenBillingAccounts keeps only open accounts", async () => {
27
+ const api: GcpApi = {
28
+ async listProjects() {
29
+ return [];
30
+ },
31
+ async listBillingAccounts() {
32
+ return [
33
+ { name: "billingAccounts/2", displayName: "B", open: true },
34
+ { name: "billingAccounts/1", displayName: "A", open: true },
35
+ { name: "billingAccounts/closed", displayName: "Z", open: false },
36
+ ];
37
+ },
38
+ async createProject() {},
39
+ async attachBillingAccount() {},
40
+ };
41
+
42
+ await expect(listOpenBillingAccounts(api)).resolves.toEqual([
43
+ { name: "billingAccounts/1", displayName: "A", open: true },
44
+ { name: "billingAccounts/2", displayName: "B", open: true },
45
+ ]);
46
+ });
47
+
48
+ test("createProject and attachBillingAccount call the expected endpoints", async () => {
49
+ const calls: string[] = [];
50
+ const api: GcpApi = {
51
+ async listProjects() {
52
+ return [];
53
+ },
54
+ async listBillingAccounts() {
55
+ return [];
56
+ },
57
+ async createProject(projectId, name) {
58
+ calls.push(`create:${projectId}:${name}`);
59
+ },
60
+ async attachBillingAccount(projectId, billingAccountName) {
61
+ calls.push(`billing:${projectId}:${billingAccountName}`);
62
+ },
63
+ };
64
+
65
+ await createProject("anmho-test", "test", api);
66
+ await attachBillingAccount("anmho-test", "billingAccounts/123", api);
67
+
68
+ expect(calls).toHaveLength(2);
69
+ expect(calls[0]).toBe("create:anmho-test:test");
70
+ expect(calls[1]).toBe("billing:anmho-test:billingAccounts/123");
71
+ });
package/src/gcp.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { CloudBillingClient } from "@google-cloud/billing";
2
+ import { ProjectsClient } from "@google-cloud/resource-manager";
3
+
4
+ export type GcpProject = {
5
+ projectId: string;
6
+ name: string;
7
+ lifecycleState?: string;
8
+ };
9
+
10
+ export type BillingAccount = {
11
+ name: string;
12
+ displayName: string;
13
+ open: boolean;
14
+ };
15
+
16
+ export type GcpApi = {
17
+ listProjects(): Promise<GcpProject[]>;
18
+ listBillingAccounts(): Promise<BillingAccount[]>;
19
+ createProject(projectId: string, name: string): Promise<void>;
20
+ attachBillingAccount(projectId: string, billingAccountName: string): Promise<void>;
21
+ };
22
+
23
+ export function createGcpApi(
24
+ projectsClient = new ProjectsClient(),
25
+ billingClient = new CloudBillingClient()
26
+ ): GcpApi {
27
+ return {
28
+ async listProjects() {
29
+ const projects: GcpProject[] = [];
30
+ for await (const project of projectsClient.searchProjectsAsync({})) {
31
+ projects.push({
32
+ projectId: project.projectId ?? "",
33
+ name: project.displayName ?? project.projectId ?? "",
34
+ lifecycleState: `${project.state ?? ""}`,
35
+ });
36
+ }
37
+
38
+ return projects
39
+ .filter((project) => project.projectId && project.lifecycleState !== "DELETE_REQUESTED")
40
+ .sort((left, right) => left.name.localeCompare(right.name));
41
+ },
42
+
43
+ async listBillingAccounts() {
44
+ const accounts: BillingAccount[] = [];
45
+ for await (const account of billingClient.listBillingAccountsAsync({})) {
46
+ accounts.push({
47
+ name: account.name ?? "",
48
+ displayName: account.displayName ?? account.name ?? "",
49
+ open: Boolean(account.open),
50
+ });
51
+ }
52
+
53
+ return accounts
54
+ .filter((account) => account.name && account.open)
55
+ .sort((left, right) => left.displayName.localeCompare(right.displayName));
56
+ },
57
+
58
+ async createProject(projectId: string, name: string) {
59
+ const [operation] = await projectsClient.createProject({
60
+ project: {
61
+ projectId,
62
+ displayName: name,
63
+ },
64
+ });
65
+ await operation.promise();
66
+ },
67
+
68
+ async attachBillingAccount(projectId: string, billingAccountName: string) {
69
+ await billingClient.updateProjectBillingInfo({
70
+ name: `projects/${projectId}`,
71
+ projectBillingInfo: {
72
+ billingAccountName,
73
+ },
74
+ });
75
+ },
76
+ };
77
+ }
78
+
79
+ export async function listAccessibleProjects(api = createGcpApi()): Promise<GcpProject[]> {
80
+ return (await api.listProjects())
81
+ .filter((project) => project.projectId && project.lifecycleState !== "DELETE_REQUESTED")
82
+ .sort((left, right) => left.name.localeCompare(right.name));
83
+ }
84
+
85
+ export async function listOpenBillingAccounts(api = createGcpApi()): Promise<BillingAccount[]> {
86
+ return (await api.listBillingAccounts())
87
+ .filter((account) => account.name && account.open)
88
+ .sort((left, right) => left.displayName.localeCompare(right.displayName));
89
+ }
90
+
91
+ export async function createProject(projectId: string, name: string, api = createGcpApi()) {
92
+ await api.createProject(projectId, name);
93
+ }
94
+
95
+ export async function attachBillingAccount(projectId: string, billingAccountName: string, api = createGcpApi()) {
96
+ await api.attachBillingAccount(projectId, billingAccountName);
97
+ }
@@ -0,0 +1,37 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildGcpProjectOptions, compactDatabaseName, compactIdentifier, deriveDefaults } from "./naming";
3
+
4
+ test("deriveDefaults uses the service name for project, repo, and database naming", () => {
5
+ expect(deriveDefaults("edge-api")).toEqual({
6
+ serviceName: "edge-api",
7
+ projectName: "edge-api",
8
+ projectId: "anmho-edge-api",
9
+ githubRepo: "anmho/edge-api",
10
+ cloudRunService: "edge-api",
11
+ neonDatabaseName: "edge_api",
12
+ });
13
+ });
14
+
15
+ test("compactIdentifier preserves length constraints with a stable suffix", () => {
16
+ const value = compactIdentifier("anmho-this-is-a-very-long-service-name-for-cloud-run", 30);
17
+ expect(value.length).toBeLessThanOrEqual(30);
18
+ expect(value.startsWith("anmho-this-is-a-very")).toBeTrue();
19
+ });
20
+
21
+ test("compactDatabaseName switches to underscores", () => {
22
+ expect(compactDatabaseName("preview-worker")).toBe("preview_worker");
23
+ });
24
+
25
+ test("buildGcpProjectOptions puts create-new first", () => {
26
+ const options = buildGcpProjectOptions("preview-worker", "anmho-preview-worker", "preview-worker", [
27
+ { projectId: "anmho-existing", name: "existing" },
28
+ ]);
29
+
30
+ expect(options[0]).toEqual({
31
+ label: "Create new project: preview-worker (anmho-preview-worker)",
32
+ mode: "create_new",
33
+ projectId: "anmho-preview-worker",
34
+ projectName: "preview-worker",
35
+ });
36
+ expect(options[1]?.mode).toBe("use_existing");
37
+ });
package/src/naming.ts ADDED
@@ -0,0 +1,103 @@
1
+ export const BILLING_ACCOUNT_DEFAULT = "billingAccounts/01BD2E-3A6949-8F4C84";
2
+ export const QUOTA_PROJECT_DEFAULT = "anmho-infra-prod";
3
+
4
+ export const FRAMEWORKS_BY_RUNTIME = {
5
+ go: ["chi", "connectrpc"],
6
+ bun: ["hono", "connectrpc"],
7
+ } as const;
8
+
9
+ export type Runtime = keyof typeof FRAMEWORKS_BY_RUNTIME;
10
+ export type Framework = (typeof FRAMEWORKS_BY_RUNTIME)[Runtime][number];
11
+ export type GcpProjectMode = "create_new" | "use_existing";
12
+
13
+ export function slugify(value: string, maxLength = 63) {
14
+ return value
15
+ .trim()
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, maxLength);
20
+ }
21
+
22
+ export function compactIdentifier(
23
+ value: string,
24
+ maxLength: number,
25
+ options: {
26
+ separator?: "-" | "_";
27
+ invalidPattern?: RegExp;
28
+ trimPattern?: RegExp;
29
+ } = {}
30
+ ) {
31
+ const separator = options.separator ?? "-";
32
+ const invalidPattern = options.invalidPattern ?? /[^a-z0-9-]+/g;
33
+ const trimPattern = options.trimPattern ?? /^-+|-+$/g;
34
+
35
+ const normalized = value
36
+ .toLowerCase()
37
+ .replace(invalidPattern, separator)
38
+ .replace(trimPattern, "");
39
+
40
+ if (normalized.length <= maxLength) {
41
+ return normalized || "service";
42
+ }
43
+
44
+ const hash = shortHash(normalized);
45
+ const head = normalized.slice(0, Math.max(1, maxLength - hash.length - 1)).replace(new RegExp(`${separator}+$`), "");
46
+ return `${head}${separator}${hash}`;
47
+ }
48
+
49
+ export function compactDatabaseName(serviceName: string) {
50
+ return compactIdentifier(serviceName.replace(/-/g, "_"), 63, {
51
+ separator: "_",
52
+ invalidPattern: /[^a-z0-9_]+/g,
53
+ trimPattern: /^_+|_+$/g,
54
+ });
55
+ }
56
+
57
+ export function deriveDefaults(serviceName: string) {
58
+ const normalizedServiceName = slugify(serviceName) || "my-service";
59
+
60
+ return {
61
+ serviceName: normalizedServiceName,
62
+ projectName: normalizedServiceName,
63
+ projectId: compactIdentifier(`anmho-${normalizedServiceName}`, 30),
64
+ githubRepo: `anmho/${normalizedServiceName}`,
65
+ cloudRunService: normalizedServiceName,
66
+ neonDatabaseName: compactDatabaseName(normalizedServiceName),
67
+ };
68
+ }
69
+
70
+ export function buildCreateProjectLabel(serviceName: string, projectId: string) {
71
+ return `Create new project: ${serviceName} (${projectId})`;
72
+ }
73
+
74
+ export function buildGcpProjectOptions(
75
+ serviceName: string,
76
+ projectId: string,
77
+ projectName: string,
78
+ projects: Array<{ projectId: string; name: string }>
79
+ ) {
80
+ return [
81
+ {
82
+ label: buildCreateProjectLabel(serviceName, projectId),
83
+ mode: "create_new" as const,
84
+ projectId,
85
+ projectName,
86
+ },
87
+ ...projects.map((project) => ({
88
+ label: `Use existing project: ${project.name} (${project.projectId})`,
89
+ mode: "use_existing" as const,
90
+ projectId: project.projectId,
91
+ projectName: project.name,
92
+ })),
93
+ ];
94
+ }
95
+
96
+ function shortHash(value: string) {
97
+ let hash = 2166136261;
98
+ for (let i = 0; i < value.length; i += 1) {
99
+ hash ^= value.charCodeAt(i);
100
+ hash = Math.imul(hash, 16777619);
101
+ }
102
+ return (hash >>> 0).toString(16).slice(0, 8);
103
+ }
@@ -0,0 +1,48 @@
1
+ import { expect, test } from "bun:test";
2
+ import { discoverNeonDefaults, listBranches, listProjects, type NeonApi } from "./neon";
3
+
4
+ test("listProjects and listBranches sort results", async () => {
5
+ const api: NeonApi = {
6
+ async listProjects() {
7
+ return [
8
+ { id: "p1", name: "alpha" },
9
+ { id: "p2", name: "zulu" },
10
+ ];
11
+ },
12
+ async listBranches() {
13
+ return [
14
+ { id: "b1", name: "main" },
15
+ { id: "b2", name: "zeta" },
16
+ ];
17
+ },
18
+ };
19
+
20
+ await expect(listProjects(api)).resolves.toEqual([
21
+ { id: "p1", name: "alpha" },
22
+ { id: "p2", name: "zulu" },
23
+ ]);
24
+ await expect(listBranches("p1", api)).resolves.toEqual([
25
+ { id: "b1", name: "main" },
26
+ { id: "b2", name: "zeta" },
27
+ ]);
28
+ });
29
+
30
+ test("discoverNeonDefaults prefers the main branch", async () => {
31
+ const api: NeonApi = {
32
+ async listProjects() {
33
+ return [{ id: "project-1", name: "shared" }];
34
+ },
35
+ async listBranches() {
36
+ return [
37
+ { id: "branch-2", name: "feature" },
38
+ { id: "branch-1", name: "main" },
39
+ ];
40
+ },
41
+ };
42
+
43
+ await expect(discoverNeonDefaults("dns-api", api)).resolves.toEqual({
44
+ projectId: "project-1",
45
+ baseBranchId: "branch-1",
46
+ baseBranchName: "main",
47
+ });
48
+ });
package/src/neon.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { createApiClient } from "@neondatabase/api-client";
2
+
3
+ export type NeonProject = {
4
+ id: string;
5
+ name: string;
6
+ };
7
+
8
+ export type NeonBranch = {
9
+ id: string;
10
+ name: string;
11
+ };
12
+
13
+ export type NeonApi = {
14
+ listProjects(): Promise<NeonProject[]>;
15
+ listBranches(projectId: string): Promise<NeonBranch[]>;
16
+ };
17
+
18
+ export function createNeonApi(apiKey = process.env.NEON_API_KEY): NeonApi {
19
+ if (!apiKey?.trim()) {
20
+ throw new Error("NEON_API_KEY is not set");
21
+ }
22
+
23
+ const client = createApiClient({ apiKey });
24
+
25
+ return {
26
+ async listProjects() {
27
+ const payload = await client.listProjects({ limit: 100 });
28
+ return (payload.projects ?? [])
29
+ .map((project) => ({
30
+ id: project.id ?? "",
31
+ name: project.name ?? project.id ?? "",
32
+ }))
33
+ .filter((project) => project.id)
34
+ .sort((left, right) => left.name.localeCompare(right.name));
35
+ },
36
+
37
+ async listBranches(projectId: string) {
38
+ const payload = await client.listProjectBranches({ projectId });
39
+ return (payload.branches ?? [])
40
+ .map((branch) => ({
41
+ id: branch.id ?? "",
42
+ name: branch.name ?? branch.id ?? "",
43
+ }))
44
+ .filter((branch) => branch.id)
45
+ .sort((left, right) => left.name.localeCompare(right.name));
46
+ },
47
+ };
48
+ }
49
+
50
+ export async function listProjects(api = createNeonApi()): Promise<NeonProject[]> {
51
+ return api.listProjects();
52
+ }
53
+
54
+ export async function listBranches(projectId: string, api = createNeonApi()): Promise<NeonBranch[]> {
55
+ return api.listBranches(projectId);
56
+ }
57
+
58
+ export async function discoverNeonDefaults(serviceName: string, api = createNeonApi()) {
59
+ const projects = await listProjects(api);
60
+ const project = projects[0];
61
+ if (!project) {
62
+ throw new Error(`No Neon projects are available for ${serviceName}`);
63
+ }
64
+
65
+ const branches = await listBranches(project.id, api);
66
+ const branch = branches.find((candidate) => candidate.name === "main") ?? branches[0];
67
+ if (!branch) {
68
+ throw new Error(`No Neon branches are available in project ${project.id}`);
69
+ }
70
+
71
+ return {
72
+ projectId: project.id,
73
+ baseBranchId: branch.id,
74
+ baseBranchName: branch.name,
75
+ };
76
+ }
@@ -0,0 +1,77 @@
1
+ import type { ScaffoldConfig } from "./scaffold";
2
+
3
+ type CommandOptions = {
4
+ cwd: string;
5
+ allowFailure?: boolean;
6
+ input?: string;
7
+ };
8
+
9
+ type CommandResult = {
10
+ success: boolean;
11
+ stdout: string;
12
+ stderr: string;
13
+ };
14
+
15
+ const decoder = new TextDecoder();
16
+
17
+ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
18
+ if (config.createGithubRepo) {
19
+ initializeRepository(cwd);
20
+ createGitHubRepo(config, cwd);
21
+ }
22
+
23
+ if (config.autoDeploy) {
24
+ run("bun", ["run", "bootstrap"], { cwd });
25
+ run("bun", ["run", "deploy"], { cwd });
26
+ return { message: "Repository initialized, pushed, and first deploy started" };
27
+ }
28
+
29
+ return { message: "Repository initialized" };
30
+ }
31
+
32
+ function initializeRepository(cwd: string) {
33
+ requireCommand("git");
34
+ run("git", ["init", "-b", "main"], { cwd, allowFailure: true });
35
+ run("git", ["add", "."], { cwd });
36
+ run("git", ["commit", "--allow-empty", "-m", "Initial commit"], { cwd, allowFailure: true });
37
+ }
38
+
39
+ function createGitHubRepo(config: ScaffoldConfig, cwd: string) {
40
+ requireCommand("gh");
41
+
42
+ const existing = run("gh", ["repo", "view", config.githubRepo], { cwd, allowFailure: true });
43
+ if (!existing.success) {
44
+ run("gh", ["repo", "create", config.githubRepo, `--${config.githubVisibility}`, "--source=.", "--remote=origin"], { cwd });
45
+ }
46
+
47
+ run("git", ["push", "-u", "origin", "main"], { cwd, allowFailure: true });
48
+ }
49
+
50
+ function requireCommand(name: string) {
51
+ if (!Bun.which(name)) {
52
+ throw new Error(`missing required command for post-scaffold automation: ${name}`);
53
+ }
54
+ }
55
+
56
+ function run(command: string, args: string[], options: CommandOptions): CommandResult {
57
+ const result = Bun.spawnSync([command, ...args], {
58
+ cwd: options.cwd,
59
+ env: process.env,
60
+ stdin: options.input,
61
+ stdout: options.allowFailure ? "pipe" : "inherit",
62
+ stderr: options.allowFailure ? "pipe" : "inherit",
63
+ });
64
+
65
+ const stdout = result.stdout ? decoder.decode(result.stdout).trim() : "";
66
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
67
+
68
+ if (!result.success && !options.allowFailure) {
69
+ throw new Error([`command failed: ${command} ${args.join(" ")}`, stdout, stderr].filter(Boolean).join("\n"));
70
+ }
71
+
72
+ return {
73
+ success: result.success,
74
+ stdout,
75
+ stderr,
76
+ };
77
+ }
@@ -1,46 +1,81 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdtemp, readdir } from "node:fs/promises";
2
+ import { mkdtemp } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { scaffoldProject } from "./scaffold";
5
+ import { scaffoldProject, type ScaffoldConfig } from "./scaffold";
6
6
 
7
- test("scaffolds the default project shape", async () => {
8
- const root = await mkdtemp(join(tmpdir(), "create-service-"));
9
- const generatedRoot = join(root, "dns-api");
10
-
11
- await scaffoldProject({
12
- directory: generatedRoot,
7
+ function baseConfig(overrides: Partial<ScaffoldConfig> = {}): ScaffoldConfig {
8
+ return {
9
+ directory: "svc",
13
10
  serviceName: "dns-api",
14
- modulePath: "github.com/anmho/dns-api",
15
- projectId: "anmho-infra-prod",
11
+ runtime: "go",
12
+ framework: "chi",
16
13
  region: "us-west1",
14
+ gcpProjectMode: "create_new",
15
+ gcpProject: "anmho-dns-api",
16
+ gcpProjectName: "dns-api",
17
+ billingAccount: "billingAccounts/01BD2E-3A6949-8F4C84",
18
+ quotaProjectId: "anmho-infra-prod",
17
19
  githubRepo: "anmho/dns-api",
18
- vaultAddr: "https://vault.anmho.com",
19
- vaultSecretPath: "provider/cloudflare-api-token",
20
- vaultSecretKey: "value",
21
- cloudflareZoneId: "893c2371cc222826de6e00583f4902ea",
22
- bufModule: "buf.build/anmho/dns-api",
20
+ githubVisibility: "public",
21
+ createGithubRepo: true,
22
+ autoDeploy: false,
23
+ neonProjectId: "project-123",
24
+ neonBaseBranchId: "br-main",
25
+ neonBaseBranchName: "main",
26
+ neonDatabaseName: "dns_api",
23
27
  generatorRoot: join(import.meta.dir, ".."),
24
- });
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ test("scaffolds all runtime/framework variants with shared cloudrun config", async () => {
33
+ const cases: Array<Pick<ScaffoldConfig, "runtime" | "framework">> = [
34
+ { runtime: "go", framework: "chi" },
35
+ { runtime: "go", framework: "connectrpc" },
36
+ { runtime: "bun", framework: "hono" },
37
+ { runtime: "bun", framework: "connectrpc" },
38
+ ];
39
+
40
+ for (const variant of cases) {
41
+ const root = await mkdtemp(join(tmpdir(), "create-svc-"));
42
+ const generatedRoot = join(root, `${variant.runtime}-${variant.framework}`);
43
+
44
+ await scaffoldProject(
45
+ baseConfig({
46
+ directory: generatedRoot,
47
+ runtime: variant.runtime,
48
+ framework: variant.framework,
49
+ })
50
+ );
25
51
 
26
- const entries = await readdir(generatedRoot);
52
+ const configScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "config.ts")).text();
53
+ expect(configScript).toContain(`runtime: "${variant.runtime}"`);
54
+ expect(configScript).toContain(`framework: "${variant.framework}"`);
55
+ expect(configScript).toContain('mode: "create_new"');
56
+ expect(configScript).toContain('quotaProjectId: "anmho-infra-prod"');
57
+ expect(configScript).toContain('projectId: "project-123"');
58
+ expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
27
59
 
28
- expect(entries).toContain("cmd");
29
- expect(entries).toContain("gen");
30
- expect(entries).toContain("internal");
31
- expect(entries).toContain("scripts");
32
- expect(entries).toContain("service.yaml");
60
+ const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
61
+ expect(deployScript).toContain('--billing-project", config.project.quotaProjectId');
33
62
 
34
- const manifest = await Bun.file(join(generatedRoot, "service.yaml")).text();
35
- expect(manifest).toContain("serving.knative.dev/v1");
36
- expect(manifest.includes("{{")).toBeFalse();
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");
37
66
 
38
- const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "deploy.ts")).text();
39
- expect(deployScript).toContain('"run", "services", "replace"');
67
+ if (variant.runtime === "go") {
68
+ const goMod = await Bun.file(join(generatedRoot, "go.mod")).text();
69
+ expect(goMod).toContain("connectrpc.com/connect");
40
70
 
41
- const configScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "config.ts")).text();
42
- expect(configScript).toContain('serviceName: "dns-api"');
71
+ const mainGo = await Bun.file(join(generatedRoot, "cmd", "server", "main.go")).text();
72
+ expect(mainGo).toContain("NewDNSService");
73
+ } else {
74
+ const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
75
+ expect(packageJson).toContain('"bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts"');
43
76
 
44
- const protoStub = await Bun.file(join(generatedRoot, "gen", "dns", "v1", "dns.pb.go")).text();
45
- expect(protoStub).toContain("package dnsv1");
77
+ const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
78
+ expect(entrypoint).toContain(variant.framework === "hono" ? "Hono" : "rpc.example.v1.Service/Ping");
79
+ }
80
+ }
46
81
  });