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.
- package/README.md +1 -1
- package/package.json +4 -1
- package/src/cli.test.ts +10 -0
- package/src/cli.ts +331 -107
- package/src/gcp.test.ts +71 -0
- package/src/gcp.ts +97 -0
- package/src/naming.test.ts +37 -0
- package/src/naming.ts +103 -0
- package/src/neon.test.ts +48 -0
- package/src/neon.ts +76 -0
- package/src/post-scaffold.ts +77 -0
- package/src/scaffold.test.ts +66 -31
- package/src/scaffold.ts +60 -55
- package/templates/root/.github/workflows/deploy.yml +1 -1
- package/templates/root/README.md +3 -3
- package/templates/shared/.github/workflows/ci.yml +22 -0
- package/templates/shared/.github/workflows/deploy.yml +30 -0
- package/templates/shared/.github/workflows/personal.yml +41 -0
- package/templates/shared/.github/workflows/preview-cleanup.yml +25 -0
- package/templates/shared/.github/workflows/preview.yml +29 -0
- package/templates/shared/README.md +37 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +76 -0
- package/templates/shared/scripts/cloudrun/config.ts +57 -0
- package/templates/shared/scripts/cloudrun/deploy.ts +82 -0
- package/templates/shared/scripts/cloudrun/lib.ts +380 -0
- package/templates/shared/scripts/cloudrun/neon.ts +104 -0
- package/templates/shared/service.yaml +28 -0
- package/templates/variants/bun-connectrpc/Dockerfile +13 -0
- package/templates/variants/bun-connectrpc/package.json +20 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -0
- package/templates/variants/bun-connectrpc/src/index.ts +32 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +17 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +10 -0
- package/templates/variants/bun-hono/Dockerfile +13 -0
- package/templates/variants/bun-hono/package.json +21 -0
- package/templates/variants/bun-hono/scripts/codegen.ts +1 -0
- package/templates/variants/bun-hono/src/index.ts +24 -0
- package/templates/variants/bun-hono/test/app.test.ts +12 -0
- package/templates/variants/bun-hono/tsconfig.json +10 -0
- package/templates/variants/go-chi/Dockerfile +23 -0
- package/templates/variants/go-chi/buf.gen.yaml +10 -0
- package/templates/variants/go-chi/buf.yaml +9 -0
- package/templates/variants/go-chi/cmd/server/main.go +52 -0
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +623 -0
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
- package/templates/variants/go-chi/go.mod +10 -0
- package/templates/variants/go-chi/internal/app/service.go +109 -0
- package/templates/variants/go-chi/internal/app/token_source.go +50 -0
- package/templates/variants/go-chi/internal/cloudflare/client.go +160 -0
- package/templates/variants/go-chi/internal/config/config.go +23 -0
- package/templates/variants/go-chi/internal/connectapi/handler.go +79 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +93 -0
- package/templates/variants/go-chi/internal/vault/client.go +148 -0
- package/templates/variants/go-chi/package.json +16 -0
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +58 -0
- package/templates/variants/go-chi/test/go.test.ts +19 -0
- package/templates/variants/go-connectrpc/Dockerfile +23 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +10 -0
- package/templates/variants/go-connectrpc/buf.yaml +9 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +51 -0
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +623 -0
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
- package/templates/variants/go-connectrpc/go.mod +10 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +109 -0
- package/templates/variants/go-connectrpc/internal/app/token_source.go +50 -0
- package/templates/variants/go-connectrpc/internal/cloudflare/client.go +160 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +23 -0
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +79 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +93 -0
- package/templates/variants/go-connectrpc/internal/vault/client.go +148 -0
- package/templates/variants/go-connectrpc/package.json +16 -0
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +58 -0
- package/templates/variants/go-connectrpc/test/go.test.ts +19 -0
package/src/gcp.test.ts
ADDED
|
@@ -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
|
+
}
|
package/src/neon.test.ts
ADDED
|
@@ -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
|
+
}
|
package/src/scaffold.test.ts
CHANGED
|
@@ -1,46 +1,81 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
});
|