create-svc 0.1.0

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 (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/index.ts +5 -0
  4. package/package.json +48 -0
  5. package/src/cli.ts +300 -0
  6. package/src/scaffold.test.ts +46 -0
  7. package/src/scaffold.ts +133 -0
  8. package/templates/root/.github/workflows/buf-publish.yml +19 -0
  9. package/templates/root/.github/workflows/ci.yml +26 -0
  10. package/templates/root/.github/workflows/deploy.yml +22 -0
  11. package/templates/root/Dockerfile +23 -0
  12. package/templates/root/README.md +69 -0
  13. package/templates/root/buf.gen.yaml +10 -0
  14. package/templates/root/buf.yaml +9 -0
  15. package/templates/root/cmd/server/main.go +44 -0
  16. package/templates/root/gen/dns/v1/dns.pb.go +623 -0
  17. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  18. package/templates/root/go.mod +10 -0
  19. package/templates/root/internal/app/service.go +152 -0
  20. package/templates/root/internal/app/token_source.go +50 -0
  21. package/templates/root/internal/cloudflare/client.go +160 -0
  22. package/templates/root/internal/config/config.go +55 -0
  23. package/templates/root/internal/connectapi/handler.go +79 -0
  24. package/templates/root/internal/httpapi/routes.go +93 -0
  25. package/templates/root/internal/vault/client.go +148 -0
  26. package/templates/root/package.json +12 -0
  27. package/templates/root/protos/dns/v1/dns.proto +58 -0
  28. package/templates/root/scripts/cloudrun/bootstrap.ts +65 -0
  29. package/templates/root/scripts/cloudrun/config.ts +50 -0
  30. package/templates/root/scripts/cloudrun/deploy.ts +41 -0
  31. package/templates/root/scripts/cloudrun/lib.ts +244 -0
  32. package/templates/root/service.yaml +50 -0
  33. package/templates/root/test/go.test.ts +19 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Ho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # create-svc
2
+
3
+ `create-svc` is a Bun-authored scaffold CLI for generating a Go Cloud Run service with:
4
+
5
+ - Chi HTTP routes
6
+ - ConnectRPC handlers
7
+ - a real Cloud Run service manifest
8
+ - Bun-based deployment helpers
9
+ - Vault-backed Cloudflare DNS CRUD as the default example
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ bun install
15
+ bun run index.ts my-service
16
+ ```
17
+
18
+ The generated service supports:
19
+
20
+ ```bash
21
+ bun dev
22
+ bun gen
23
+ bun lint
24
+ bun test
25
+ bun deploy
26
+ ```
27
+
28
+ ## Development
29
+
30
+ ```bash
31
+ bun test src
32
+ ```
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { run } from "./src/cli";
4
+
5
+ await run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "create-svc",
3
+ "version": "0.1.0",
4
+ "description": "Bun-authored CLI to scaffold Go Cloud Run services with Chi, ConnectRPC, Vault, and Cloudflare examples.",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "create-svc": "./index.ts",
10
+ "create-service": "./index.ts"
11
+ },
12
+ "files": [
13
+ "index.ts",
14
+ "src",
15
+ "templates",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "dev": "bun run index.ts",
20
+ "test": "bun test src"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/anmho/create-svc.git"
25
+ },
26
+ "homepage": "https://github.com/anmho/create-svc#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/anmho/create-svc/issues"
29
+ },
30
+ "keywords": [
31
+ "bun",
32
+ "cloud-run",
33
+ "connectrpc",
34
+ "go",
35
+ "grpc",
36
+ "scaffold"
37
+ ],
38
+ "devDependencies": {
39
+ "@types/bun": "latest"
40
+ },
41
+ "peerDependencies": {
42
+ "typescript": "^5"
43
+ },
44
+ "dependencies": {
45
+ "@clack/prompts": "^1.2.0",
46
+ "picocolors": "^1.1.1"
47
+ }
48
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,300 @@
1
+ import { cancel, confirm, intro, isCancel, log, note, outro, spinner, text } from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { scaffoldProject, type ScaffoldConfig } from "./scaffold";
6
+
7
+ type ParsedArgs = {
8
+ directory?: string;
9
+ modulePath?: string;
10
+ projectId?: string;
11
+ region?: string;
12
+ githubRepo?: string;
13
+ vaultAddr?: string;
14
+ vaultSecretPath?: string;
15
+ vaultSecretKey?: string;
16
+ cloudflareZoneId?: string;
17
+ bufModule?: string;
18
+ yes: boolean;
19
+ help: boolean;
20
+ };
21
+
22
+ const DEFAULT_REGION = "us-west1";
23
+ const DEFAULT_VAULT_ADDR = "https://vault.anmho.com";
24
+ const DEFAULT_VAULT_SECRET_PATH = "provider/cloudflare-api-token";
25
+ const DEFAULT_VAULT_SECRET_KEY = "value";
26
+ const DEFAULT_CLOUDFLARE_ZONE_ID = "893c2371cc222826de6e00583f4902ea";
27
+
28
+ export async function run(argv: string[]) {
29
+ const args = parseArgs(argv);
30
+ if (args.help) {
31
+ printHelp();
32
+ return;
33
+ }
34
+
35
+ intro(`${pc.bold("create-service")} ${pc.dim("Cloud Run scaffold")}`);
36
+
37
+ const config = await resolveConfig(args);
38
+ const targetDir = resolve(process.cwd(), config.directory);
39
+
40
+ note(
41
+ [
42
+ `${pc.bold("Output")}: ${targetDir}`,
43
+ `${pc.bold("Module")}: ${config.modulePath}`,
44
+ `${pc.bold("Deploy")}: public Cloud Run service with Vault-backed Cloudflare DNS CRUD`,
45
+ ].join("\n"),
46
+ "Scaffold"
47
+ );
48
+
49
+ const buildSpinner = spinner();
50
+ buildSpinner.start("Generating project files");
51
+ await scaffoldProject(config);
52
+ buildSpinner.stop("Project files generated");
53
+
54
+ outro(
55
+ [
56
+ `Next: ${pc.cyan(`cd ${config.directory}`)}`,
57
+ `Local dev: ${pc.cyan("bun dev")}`,
58
+ `Generate stubs: ${pc.cyan("bun gen")}`,
59
+ `First deploy: set ${pc.cyan("BOOTSTRAP_VAULT_ROLE_ID")} and ${pc.cyan("BOOTSTRAP_VAULT_SECRET_ID")}, then run ${pc.cyan("bun deploy")}`,
60
+ ].join("\n")
61
+ );
62
+ }
63
+
64
+ function parseArgs(argv: string[]): ParsedArgs {
65
+ const parsed: ParsedArgs = {
66
+ yes: false,
67
+ help: false,
68
+ };
69
+
70
+ for (let i = 0; i < argv.length; i += 1) {
71
+ const token = argv[i];
72
+ if (!token) {
73
+ continue;
74
+ }
75
+
76
+ if (!token.startsWith("-") && !parsed.directory) {
77
+ parsed.directory = token;
78
+ continue;
79
+ }
80
+
81
+ const next = argv[i + 1];
82
+ const readValue = () => {
83
+ if (!next || next.startsWith("-")) {
84
+ throw new Error(`Missing value for ${token}`);
85
+ }
86
+ i += 1;
87
+ return next;
88
+ };
89
+
90
+ if (token === "--yes" || token === "-y") {
91
+ parsed.yes = true;
92
+ continue;
93
+ }
94
+
95
+ if (token === "--help" || token === "-h") {
96
+ parsed.help = true;
97
+ continue;
98
+ }
99
+
100
+ if (token === "--module") {
101
+ parsed.modulePath = readValue();
102
+ continue;
103
+ }
104
+
105
+ if (token.startsWith("--module=")) {
106
+ parsed.modulePath = token.slice("--module=".length);
107
+ continue;
108
+ }
109
+
110
+ if (token === "--project-id") {
111
+ parsed.projectId = readValue();
112
+ continue;
113
+ }
114
+
115
+ if (token.startsWith("--project-id=")) {
116
+ parsed.projectId = token.slice("--project-id=".length);
117
+ continue;
118
+ }
119
+
120
+ if (token === "--region") {
121
+ parsed.region = readValue();
122
+ continue;
123
+ }
124
+
125
+ if (token.startsWith("--region=")) {
126
+ parsed.region = token.slice("--region=".length);
127
+ continue;
128
+ }
129
+
130
+ if (token === "--github-repo") {
131
+ parsed.githubRepo = readValue();
132
+ continue;
133
+ }
134
+
135
+ if (token.startsWith("--github-repo=")) {
136
+ parsed.githubRepo = token.slice("--github-repo=".length);
137
+ continue;
138
+ }
139
+
140
+ if (token === "--vault-addr") {
141
+ parsed.vaultAddr = readValue();
142
+ continue;
143
+ }
144
+
145
+ if (token.startsWith("--vault-addr=")) {
146
+ parsed.vaultAddr = token.slice("--vault-addr=".length);
147
+ continue;
148
+ }
149
+
150
+ if (token === "--vault-secret-path") {
151
+ parsed.vaultSecretPath = readValue();
152
+ continue;
153
+ }
154
+
155
+ if (token.startsWith("--vault-secret-path=")) {
156
+ parsed.vaultSecretPath = token.slice("--vault-secret-path=".length);
157
+ continue;
158
+ }
159
+
160
+ if (token === "--vault-secret-key") {
161
+ parsed.vaultSecretKey = readValue();
162
+ continue;
163
+ }
164
+
165
+ if (token.startsWith("--vault-secret-key=")) {
166
+ parsed.vaultSecretKey = token.slice("--vault-secret-key=".length);
167
+ continue;
168
+ }
169
+
170
+ if (token === "--cloudflare-zone-id") {
171
+ parsed.cloudflareZoneId = readValue();
172
+ continue;
173
+ }
174
+
175
+ if (token.startsWith("--cloudflare-zone-id=")) {
176
+ parsed.cloudflareZoneId = token.slice("--cloudflare-zone-id=".length);
177
+ continue;
178
+ }
179
+
180
+ if (token === "--buf-module") {
181
+ parsed.bufModule = readValue();
182
+ continue;
183
+ }
184
+
185
+ if (token.startsWith("--buf-module=")) {
186
+ parsed.bufModule = token.slice("--buf-module=".length);
187
+ continue;
188
+ }
189
+
190
+ throw new Error(`Unknown argument: ${token}`);
191
+ }
192
+
193
+ return parsed;
194
+ }
195
+
196
+ async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
197
+ const inferredName = slugify(basename(args.directory ?? "dns-api"));
198
+ const serviceName = args.yes
199
+ ? inferredName
200
+ : await promptText("Service name", inferredName, (value) => slugify(value).length > 0 || "Service name is required");
201
+
202
+ const directory = args.directory ?? serviceName;
203
+ const githubRepo = args.githubRepo ?? `anmho/${serviceName}`;
204
+ const modulePath = args.modulePath ?? `github.com/${githubRepo}`;
205
+ const projectId = args.projectId ?? (args.yes ? "my-gcp-project" : "");
206
+ const region = args.region ?? DEFAULT_REGION;
207
+ const vaultAddr = args.vaultAddr ?? DEFAULT_VAULT_ADDR;
208
+ const vaultSecretPath = args.vaultSecretPath ?? DEFAULT_VAULT_SECRET_PATH;
209
+ const vaultSecretKey = args.vaultSecretKey ?? DEFAULT_VAULT_SECRET_KEY;
210
+ const cloudflareZoneId = args.cloudflareZoneId ?? DEFAULT_CLOUDFLARE_ZONE_ID;
211
+ const bufModule = args.bufModule ?? `buf.build/${githubRepo}`;
212
+
213
+ const confirmedProjectId = projectId || (await promptText("GCP project ID", "my-gcp-project", (value) => value.trim().length > 0 || "Project ID is required"));
214
+ const confirmedModulePath = args.yes
215
+ ? modulePath
216
+ : await promptText("Go module path", modulePath, (value) => value.trim().length > 0 || "Module path is required");
217
+ const confirmedGithubRepo = args.yes
218
+ ? githubRepo
219
+ : await promptText("GitHub repo", githubRepo, (value) => value.includes("/") || "Use owner/repo format");
220
+ const confirmedRegion = args.yes
221
+ ? region
222
+ : await promptText("Cloud Run region", region, (value) => value.trim().length > 0 || "Region is required");
223
+ const confirmedBufModule = args.yes
224
+ ? bufModule
225
+ : await promptText("Buf module", bufModule, (value) => value.trim().length > 0 || "Buf module is required");
226
+
227
+ if (!args.yes) {
228
+ const okay = await confirm({
229
+ message: "Create the scaffold with these defaults?",
230
+ initialValue: true,
231
+ });
232
+ if (isCancel(okay) || !okay) {
233
+ cancel("Aborted");
234
+ process.exit(1);
235
+ }
236
+ }
237
+
238
+ return {
239
+ directory,
240
+ serviceName,
241
+ modulePath: confirmedModulePath,
242
+ projectId: confirmedProjectId,
243
+ region: confirmedRegion,
244
+ githubRepo: confirmedGithubRepo,
245
+ vaultAddr,
246
+ vaultSecretPath,
247
+ vaultSecretKey,
248
+ cloudflareZoneId,
249
+ bufModule: confirmedBufModule,
250
+ generatorRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
251
+ };
252
+ }
253
+
254
+ async function promptText(
255
+ message: string,
256
+ initialValue: string,
257
+ validate: (value: string) => true | string
258
+ ): Promise<string> {
259
+ const value = await text({
260
+ message,
261
+ initialValue,
262
+ validate: (input) => validate(input.trim()),
263
+ });
264
+
265
+ if (isCancel(value)) {
266
+ cancel("Aborted");
267
+ process.exit(1);
268
+ }
269
+
270
+ return value.trim();
271
+ }
272
+
273
+ function slugify(value: string): string {
274
+ return value
275
+ .trim()
276
+ .toLowerCase()
277
+ .replace(/[^a-z0-9]+/g, "-")
278
+ .replace(/^-+|-+$/g, "")
279
+ .slice(0, 63);
280
+ }
281
+
282
+ function printHelp() {
283
+ log.message(`
284
+ Usage:
285
+ bun run index.ts [directory] [options]
286
+
287
+ Options:
288
+ --module <path> Go module path
289
+ --project-id <id> GCP project ID
290
+ --region <region> Cloud Run region
291
+ --github-repo <owner/repo> GitHub repository
292
+ --vault-addr <url> Vault address
293
+ --vault-secret-path <path> Vault KV secret path
294
+ --vault-secret-key <key> Vault KV secret key
295
+ --cloudflare-zone-id <id> Cloudflare zone ID
296
+ --buf-module <module> Buf module name
297
+ --yes, -y Accept defaults without prompts
298
+ --help, -h Show this message
299
+ `);
300
+ }
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { scaffoldProject } from "./scaffold";
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,
13
+ serviceName: "dns-api",
14
+ modulePath: "github.com/anmho/dns-api",
15
+ projectId: "anmho-infra-prod",
16
+ region: "us-west1",
17
+ 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",
23
+ generatorRoot: join(import.meta.dir, ".."),
24
+ });
25
+
26
+ const entries = await readdir(generatedRoot);
27
+
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");
33
+
34
+ const manifest = await Bun.file(join(generatedRoot, "service.yaml")).text();
35
+ expect(manifest).toContain("serving.knative.dev/v1");
36
+ expect(manifest.includes("{{")).toBeFalse();
37
+
38
+ const deployScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "deploy.ts")).text();
39
+ expect(deployScript).toContain('"run", "services", "replace"');
40
+
41
+ const configScript = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "config.ts")).text();
42
+ expect(configScript).toContain('serviceName: "dns-api"');
43
+
44
+ const protoStub = await Bun.file(join(generatedRoot, "gen", "dns", "v1", "dns.pb.go")).text();
45
+ expect(protoStub).toContain("package dnsv1");
46
+ });
@@ -0,0 +1,133 @@
1
+ import { mkdir, readdir } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export type ScaffoldConfig = {
5
+ directory: string;
6
+ serviceName: string;
7
+ modulePath: string;
8
+ projectId: string;
9
+ region: string;
10
+ githubRepo: string;
11
+ vaultAddr: string;
12
+ vaultSecretPath: string;
13
+ vaultSecretKey: string;
14
+ cloudflareZoneId: string;
15
+ bufModule: string;
16
+ generatorRoot: string;
17
+ };
18
+
19
+ export async function scaffoldProject(config: ScaffoldConfig) {
20
+ const targetDir = resolve(process.cwd(), config.directory);
21
+ await ensureTargetDirectory(targetDir);
22
+
23
+ const replacements = buildReplacements(config);
24
+ const templateRoot = resolve(config.generatorRoot, "templates", "root");
25
+ const files = await collectTemplateFiles(templateRoot);
26
+
27
+ for (const relativePath of files) {
28
+ const sourcePath = join(templateRoot, relativePath);
29
+ const destinationPath = join(targetDir, relativePath);
30
+ const raw = await Bun.file(sourcePath).text();
31
+ const rendered = renderTemplate(raw, replacements);
32
+
33
+ await mkdir(dirname(destinationPath), { recursive: true });
34
+ await Bun.write(destinationPath, rendered);
35
+ }
36
+ }
37
+
38
+ async function ensureTargetDirectory(targetDir: string) {
39
+ try {
40
+ const entries = await readdir(targetDir);
41
+ if (entries.length > 0) {
42
+ throw new Error(`Target directory already exists and is not empty: ${targetDir}`);
43
+ }
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
46
+ await mkdir(targetDir, { recursive: true });
47
+ return;
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ async function collectTemplateFiles(root: string, relative = ""): Promise<string[]> {
54
+ const cwd = join(root, relative);
55
+ const entries = await readdir(cwd, { withFileTypes: true });
56
+ const files: string[] = [];
57
+
58
+ for (const entry of entries) {
59
+ const nextRelative = relative ? join(relative, entry.name) : entry.name;
60
+ if (entry.isDirectory()) {
61
+ files.push(...(await collectTemplateFiles(root, nextRelative)));
62
+ continue;
63
+ }
64
+ files.push(nextRelative);
65
+ }
66
+
67
+ return files.sort();
68
+ }
69
+
70
+ function buildReplacements(config: ScaffoldConfig) {
71
+ const [repoOwner = "anmho"] = config.githubRepo.split("/");
72
+ const serviceAccountBase = compactIdentifier(config.serviceName, 21);
73
+ const runtimeServiceAccount = `${serviceAccountBase}-runtime@${config.projectId}.iam.gserviceaccount.com`;
74
+ const deployerServiceAccount = `${serviceAccountBase}-deployer@${config.projectId}.iam.gserviceaccount.com`;
75
+ const vaultRoleIdSecret = `${config.serviceName}-vault-role-id`;
76
+ const vaultSecretIdSecret = `${config.serviceName}-vault-secret-id`;
77
+ const wifPoolId = "github";
78
+ const wifProviderId = compactIdentifier(config.serviceName, 32);
79
+
80
+ return {
81
+ SERVICE_NAME: config.serviceName,
82
+ MODULE_PATH: config.modulePath,
83
+ PROJECT_ID: config.projectId,
84
+ REGION: config.region,
85
+ GITHUB_REPO: config.githubRepo,
86
+ GITHUB_OWNER: repoOwner,
87
+ VAULT_ADDR: config.vaultAddr,
88
+ VAULT_SECRET_PATH: config.vaultSecretPath,
89
+ VAULT_SECRET_KEY: config.vaultSecretKey,
90
+ CLOUDFLARE_ZONE_ID: config.cloudflareZoneId,
91
+ BUF_MODULE: config.bufModule,
92
+ RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
93
+ DEPLOYER_SERVICE_ACCOUNT: deployerServiceAccount,
94
+ VAULT_ROLE_ID_SECRET: vaultRoleIdSecret,
95
+ VAULT_SECRET_ID_SECRET: vaultSecretIdSecret,
96
+ WIF_POOL_ID: wifPoolId,
97
+ WIF_PROVIDER_ID: wifProviderId,
98
+ };
99
+ }
100
+
101
+ function renderTemplate(input: string, replacements: Record<string, string>) {
102
+ return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key: string) => {
103
+ const replacement = replacements[key];
104
+ if (replacement === undefined) {
105
+ throw new Error(`Missing template replacement for ${key}`);
106
+ }
107
+ return replacement;
108
+ });
109
+ }
110
+
111
+ function compactIdentifier(value: string, maxLength: number) {
112
+ const normalized = value
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9-]+/g, "-")
115
+ .replace(/^-+|-+$/g, "");
116
+
117
+ if (normalized.length <= maxLength) {
118
+ return normalized || "service";
119
+ }
120
+
121
+ const hash = shortHash(normalized);
122
+ const head = normalized.slice(0, Math.max(1, maxLength - hash.length - 1)).replace(/-+$/g, "");
123
+ return `${head}-${hash}`;
124
+ }
125
+
126
+ function shortHash(value: string) {
127
+ let hash = 2166136261;
128
+ for (let i = 0; i < value.length; i += 1) {
129
+ hash ^= value.charCodeAt(i);
130
+ hash = Math.imul(hash, 16777619);
131
+ }
132
+ return (hash >>> 0).toString(16).slice(0, 8);
133
+ }
@@ -0,0 +1,19 @@
1
+ name: buf-publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - "protos/**"
9
+
10
+ jobs:
11
+ publish:
12
+ if: ${{ vars.BUF_MODULE != '' }}
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: bufbuild/buf-setup-action@v1
17
+ - run: buf push
18
+ env:
19
+ BUF_TOKEN: ${{ secrets.BUF_TOKEN }}
@@ -0,0 +1,26 @@
1
+ name: ci
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-go@v5
15
+ with:
16
+ go-version: '1.25.4'
17
+ - uses: oven-sh/setup-bun@v2
18
+ - uses: bufbuild/buf-setup-action@v1
19
+ - name: Install proto plugins
20
+ run: |
21
+ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.10
22
+ go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.1
23
+ echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
24
+ - run: bun gen
25
+ - run: bun lint
26
+ - run: bun test
@@ -0,0 +1,22 @@
1
+ name: deploy
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: oven-sh/setup-bun@v2
17
+ - uses: google-github-actions/auth@v3
18
+ with:
19
+ workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
20
+ service_account: ${{ vars.GCP_DEPLOYER_SERVICE_ACCOUNT }}
21
+ - uses: google-github-actions/setup-gcloud@v2
22
+ - run: bun deploy -- --ci
@@ -0,0 +1,23 @@
1
+ FROM golang:1.25.4 AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ COPY go.mod ./
6
+ COPY gen ./gen
7
+ COPY internal ./internal
8
+ COPY cmd ./cmd
9
+
10
+ RUN go mod download
11
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
12
+
13
+ FROM gcr.io/distroless/base-debian12
14
+
15
+ WORKDIR /app
16
+
17
+ COPY --from=builder /out/server /app/server
18
+
19
+ ENV PORT=8080
20
+
21
+ EXPOSE 8080
22
+
23
+ ENTRYPOINT ["/app/server"]