create-svc 0.1.9 → 0.1.11
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 +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -1
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
package/src/scaffold.ts
CHANGED
|
@@ -2,30 +2,34 @@ 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,
|
|
7
|
+
type DeployTarget,
|
|
5
8
|
type Framework,
|
|
6
9
|
type GcpProjectMode,
|
|
7
10
|
type Runtime,
|
|
8
11
|
} from "./naming";
|
|
12
|
+
import { exampleForProfile, type Profile } from "./profiles";
|
|
13
|
+
import type { GitBootstrapConfig } from "./git-bootstrap";
|
|
9
14
|
|
|
10
15
|
export type ScaffoldConfig = {
|
|
11
16
|
directory: string;
|
|
12
17
|
serviceName: string;
|
|
18
|
+
modulePath: string;
|
|
19
|
+
target: DeployTarget;
|
|
13
20
|
runtime: Runtime;
|
|
14
21
|
framework: Framework;
|
|
22
|
+
profile: Profile;
|
|
15
23
|
region: string;
|
|
16
24
|
gcpProjectMode: GcpProjectMode;
|
|
17
25
|
gcpProject: string;
|
|
18
26
|
gcpProjectName: string;
|
|
19
27
|
billingAccount: string;
|
|
20
28
|
quotaProjectId: string;
|
|
21
|
-
githubRepo: string;
|
|
22
|
-
githubVisibility: "public" | "private";
|
|
23
|
-
createGithubRepo: boolean;
|
|
24
29
|
autoDeploy: boolean;
|
|
25
|
-
|
|
26
|
-
neonBaseBranchId: string;
|
|
27
|
-
neonBaseBranchName: string;
|
|
30
|
+
git: GitBootstrapConfig;
|
|
28
31
|
neonDatabaseName: string;
|
|
32
|
+
apiHostname: string;
|
|
29
33
|
generatorRoot: string;
|
|
30
34
|
};
|
|
31
35
|
|
|
@@ -46,14 +50,20 @@ export async function scaffoldProject(config: ScaffoldConfig) {
|
|
|
46
50
|
await ensureTargetDirectory(targetDir);
|
|
47
51
|
|
|
48
52
|
const replacements = buildReplacements(config);
|
|
49
|
-
const
|
|
50
|
-
|
|
53
|
+
const templateRoots = [
|
|
54
|
+
{ kind: "shared" as const, root: resolve(config.generatorRoot, "templates", "shared") },
|
|
55
|
+
{ kind: "variant" as const, root: resolve(config.generatorRoot, "templates", "variants", `${config.runtime}-${config.framework}`) },
|
|
56
|
+
{ kind: "target" as const, root: resolve(config.generatorRoot, "templates", "targets", config.target) },
|
|
57
|
+
];
|
|
51
58
|
|
|
52
|
-
for (const
|
|
53
|
-
const files = await collectTemplateFiles(
|
|
59
|
+
for (const template of templateRoots) {
|
|
60
|
+
const files = await collectTemplateFiles(template.root);
|
|
54
61
|
|
|
55
62
|
for (const relativePath of files) {
|
|
56
|
-
|
|
63
|
+
if (shouldSkipForTarget(config.target, template.kind, relativePath)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const sourcePath = join(template.root, relativePath);
|
|
57
67
|
const destinationPath = join(targetDir, relativePath);
|
|
58
68
|
const raw = await Bun.file(sourcePath).text();
|
|
59
69
|
const rendered = renderTemplate(raw, replacements);
|
|
@@ -62,6 +72,45 @@ export async function scaffoldProject(config: ScaffoldConfig) {
|
|
|
62
72
|
await Bun.write(destinationPath, rendered);
|
|
63
73
|
}
|
|
64
74
|
}
|
|
75
|
+
|
|
76
|
+
await writeLocalEnvFile(targetDir, replacements);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function shouldSkipForTarget(target: DeployTarget, templateKind: "shared" | "variant" | "target", relativePath: string) {
|
|
80
|
+
if (target === "workers") {
|
|
81
|
+
if (templateKind === "target") {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (relativePath === "Dockerfile" || relativePath === "docker-compose.yml") {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (templateKind === "shared") {
|
|
90
|
+
return (
|
|
91
|
+
relativePath === "service.yaml" ||
|
|
92
|
+
relativePath === "scripts/dev.ts" ||
|
|
93
|
+
relativePath === "scripts/ensure-local-db.ts" ||
|
|
94
|
+
relativePath === "scripts/local-docker.ts" ||
|
|
95
|
+
relativePath === "scripts/local-env.ts" ||
|
|
96
|
+
relativePath === "scripts/seed.ts" ||
|
|
97
|
+
relativePath === "scripts/wait-for-db.ts" ||
|
|
98
|
+
relativePath.startsWith("scripts/cloudrun/")
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
relativePath.startsWith("src/db/") ||
|
|
104
|
+
relativePath.startsWith("src/temporal/") ||
|
|
105
|
+
relativePath.startsWith("src/waitlist/") ||
|
|
106
|
+
relativePath.startsWith("test/") ||
|
|
107
|
+
relativePath.startsWith("migrations/") ||
|
|
108
|
+
relativePath === "scripts/codegen.ts" ||
|
|
109
|
+
relativePath === "scripts/migrate.ts"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return relativePath.startsWith("scripts/workers/") || relativePath === "wrangler.toml";
|
|
65
114
|
}
|
|
66
115
|
|
|
67
116
|
async function ensureTargetDirectory(targetDir: string) {
|
|
@@ -85,12 +134,23 @@ export async function assertTargetDirectoryIsEmpty(targetDir: string) {
|
|
|
85
134
|
|
|
86
135
|
async function collectTemplateFiles(root: string, relative = ""): Promise<string[]> {
|
|
87
136
|
const cwd = join(root, relative);
|
|
88
|
-
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = await readdir(cwd, { withFileTypes: true });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
89
146
|
const files: string[] = [];
|
|
90
147
|
|
|
91
148
|
for (const entry of entries) {
|
|
92
149
|
const nextRelative = relative ? join(relative, entry.name) : entry.name;
|
|
93
150
|
if (entry.isDirectory()) {
|
|
151
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
94
154
|
files.push(...(await collectTemplateFiles(root, nextRelative)));
|
|
95
155
|
continue;
|
|
96
156
|
}
|
|
@@ -101,19 +161,22 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
|
|
|
101
161
|
}
|
|
102
162
|
|
|
103
163
|
function buildReplacements(config: ScaffoldConfig) {
|
|
104
|
-
const
|
|
105
|
-
const modulePath = `github.com/${config.githubRepo}`;
|
|
164
|
+
const example = exampleForProfile(config.profile);
|
|
106
165
|
const serviceAccountBase = compactIdentifier(config.serviceName, 21);
|
|
107
166
|
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
167
|
const previewBranchPrefix = `${config.serviceName}-pr`;
|
|
112
168
|
const personalBranchPrefix = `${config.serviceName}-dev`;
|
|
169
|
+
const localDatabaseName = compactDatabaseName(config.serviceName);
|
|
170
|
+
const localDatabasePort = deriveLocalPostgresPort(config.serviceName);
|
|
171
|
+
const authIssuer = "https://auth.anmho.com/api/auth";
|
|
172
|
+
const authAudience = `api://${config.serviceName}`;
|
|
173
|
+
const authJwksUrl = `${authIssuer}/jwks`;
|
|
113
174
|
|
|
114
175
|
return {
|
|
115
176
|
SERVICE_NAME: config.serviceName,
|
|
116
|
-
|
|
177
|
+
SERVICE_ID: config.serviceName,
|
|
178
|
+
MODULE_PATH: config.modulePath,
|
|
179
|
+
TARGET: config.target,
|
|
117
180
|
PROJECT_ID: config.gcpProject,
|
|
118
181
|
PROJECT_NAME: config.gcpProjectName,
|
|
119
182
|
REGION: config.region,
|
|
@@ -121,28 +184,91 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
121
184
|
PROJECT_CREATE_IF_MISSING: String(config.gcpProjectMode === "create_new"),
|
|
122
185
|
BILLING_ACCOUNT: config.billingAccount,
|
|
123
186
|
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
187
|
AUTO_DEPLOY: String(config.autoDeploy),
|
|
129
188
|
RUNTIME: config.runtime,
|
|
130
189
|
FRAMEWORK: config.framework,
|
|
190
|
+
PROFILE: config.profile,
|
|
191
|
+
EXAMPLE_KIND: example.kind,
|
|
192
|
+
EXAMPLE_DOMAIN: example.domain,
|
|
193
|
+
EXAMPLE_LABEL: example.label,
|
|
131
194
|
CLOUD_RUN_SERVICE: config.serviceName,
|
|
132
|
-
NEON_PROJECT_ID:
|
|
133
|
-
NEON_BASE_BRANCH_ID:
|
|
134
|
-
NEON_BASE_BRANCH_NAME:
|
|
195
|
+
NEON_PROJECT_ID: "",
|
|
196
|
+
NEON_BASE_BRANCH_ID: "",
|
|
197
|
+
NEON_BASE_BRANCH_NAME: "main",
|
|
135
198
|
NEON_DATABASE_NAME: config.neonDatabaseName,
|
|
136
199
|
NEON_ROLE_NAME: "neondb_owner",
|
|
137
200
|
NEON_PREVIEW_BRANCH_PREFIX: previewBranchPrefix,
|
|
138
201
|
NEON_PERSONAL_BRANCH_PREFIX: personalBranchPrefix,
|
|
139
202
|
RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
203
|
+
API_HOSTNAME: config.apiHostname,
|
|
204
|
+
API_BASE_DOMAIN: "anmho.com",
|
|
205
|
+
AUTH_ISSUER: authIssuer,
|
|
206
|
+
AUTH_AUDIENCE: authAudience,
|
|
207
|
+
AUTH_JWKS_URL: authJwksUrl,
|
|
208
|
+
LOCAL_DATABASE_NAME: localDatabaseName,
|
|
209
|
+
LOCAL_DATABASE_PORT: localDatabasePort,
|
|
210
|
+
LOCAL_DATABASE_USER: "postgres",
|
|
211
|
+
LOCAL_DATABASE_PASSWORD: "postgres",
|
|
212
|
+
COMMAND_DEV: config.runtime === "bun" ? "bun run dev" : "make dev",
|
|
213
|
+
COMMAND_MIGRATE: config.runtime === "bun" ? "bun run migrate" : "make migrate",
|
|
214
|
+
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
215
|
+
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
216
|
+
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
217
|
+
COMMAND_BOOTSTRAP: config.runtime === "bun" ? "bun run create" : "make create",
|
|
218
|
+
COMMAND_DEPLOY: config.runtime === "bun" ? "bun run deploy" : "make deploy",
|
|
219
|
+
COMMAND_AUTH_RESOURCE:
|
|
220
|
+
config.runtime === "bun" ? "bun run auth -- resource-server" : 'make auth ARGS="resource-server"',
|
|
221
|
+
COMMAND_AUTH_CLIENT:
|
|
222
|
+
config.runtime === "bun"
|
|
223
|
+
? "bun run auth -- client create"
|
|
224
|
+
: 'make auth ARGS="client create"',
|
|
225
|
+
COMMAND_DEPLOY_PERSONAL:
|
|
226
|
+
config.runtime === "bun"
|
|
227
|
+
? 'bun run deploy -- --environment personal --name <slug>'
|
|
228
|
+
: 'make deploy ARGS="--environment personal --name <slug>"',
|
|
229
|
+
COMMAND_DEPLOY_DESTROY:
|
|
230
|
+
config.runtime === "bun"
|
|
231
|
+
? 'bun run destroy -- --environment personal --name <slug>'
|
|
232
|
+
: 'make destroy ARGS="--environment personal --name <slug>"',
|
|
233
|
+
COMMAND_CLEANUP: config.runtime === "bun" ? "bun run destroy" : "make destroy",
|
|
234
|
+
COMMAND_CLEANUP_PROJECT: config.runtime === "bun" ? "bun run destroy -- --project" : 'make destroy ARGS="--project"',
|
|
235
|
+
GITIGNORE_EXTRA: "",
|
|
236
|
+
LOCAL_INTROSPECTION_NOTE:
|
|
237
|
+
config.framework === "connectrpc"
|
|
238
|
+
? [
|
|
239
|
+
"",
|
|
240
|
+
"## Local introspection",
|
|
241
|
+
"",
|
|
242
|
+
"When running locally, ConnectRPC variants expose introspection by default.",
|
|
243
|
+
"",
|
|
244
|
+
"- `go + connectrpc`: standard gRPC reflection for tools like `grpcurl list localhost:<port>`",
|
|
245
|
+
"- `bun + connectrpc`: JSON introspection at `/debug/connectrpc`",
|
|
246
|
+
"- override with `ENABLE_RPC_INTROSPECTION=true|false`",
|
|
247
|
+
].join("\n")
|
|
248
|
+
: "",
|
|
143
249
|
};
|
|
144
250
|
}
|
|
145
251
|
|
|
252
|
+
async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
|
|
253
|
+
const envPath = join(targetDir, ".env.local");
|
|
254
|
+
if (await Bun.file(envPath).exists()) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const rendered = renderTemplate(
|
|
259
|
+
[
|
|
260
|
+
"# Generated local development defaults for create-service.",
|
|
261
|
+
"# This file is user-owned after scaffold and is gitignored.",
|
|
262
|
+
"",
|
|
263
|
+
"DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable",
|
|
264
|
+
"",
|
|
265
|
+
].join("\n"),
|
|
266
|
+
replacements
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await Bun.write(envPath, rendered);
|
|
270
|
+
}
|
|
271
|
+
|
|
146
272
|
function renderTemplate(input: string, replacements: Record<string, string>) {
|
|
147
273
|
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key: string) => {
|
|
148
274
|
const replacement = replacements[key];
|
package/src/vault.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, expect, mock, test } from "bun:test";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import { readVaultSecret, resolveNeonApiKey } from "./vault";
|
|
3
|
+
import { readVaultSecret, resolveNeonApiKey, upsertVaultSecretFields } from "./vault";
|
|
4
4
|
|
|
5
5
|
const originalEnv = { ...process.env };
|
|
6
6
|
|
|
@@ -14,17 +14,41 @@ test("resolveNeonApiKey prefers NEON_API_KEY from env", async () => {
|
|
|
14
14
|
await expect(resolveNeonApiKey()).resolves.toBe("direct-token");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
test("resolveNeonApiKey reads the purpose-named Neon provider secret by default", async () => {
|
|
18
|
+
delete process.env.NEON_API_KEY;
|
|
19
|
+
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
20
|
+
process.env.VAULT_TOKEN = "token-123";
|
|
21
|
+
|
|
22
|
+
const fetchMock = mock(async (input: string | URL | Request) => {
|
|
23
|
+
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
data: {
|
|
27
|
+
data: {
|
|
28
|
+
api_key: "vault-token",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
{ status: 200 }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
37
|
+
|
|
38
|
+
await expect(resolveNeonApiKey()).resolves.toBe("vault-token");
|
|
39
|
+
});
|
|
40
|
+
|
|
17
41
|
test("readVaultSecret reads KV v2 secret data using existing vault login env", async () => {
|
|
18
42
|
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
19
43
|
process.env.VAULT_TOKEN = "token-123";
|
|
20
44
|
|
|
21
45
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
|
22
|
-
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/
|
|
46
|
+
expect(String(input)).toBe("https://vault.example.com/v1/secret/data/prod/providers/neon");
|
|
23
47
|
return new Response(
|
|
24
48
|
JSON.stringify({
|
|
25
49
|
data: {
|
|
26
50
|
data: {
|
|
27
|
-
|
|
51
|
+
api_key: "vault-token",
|
|
28
52
|
},
|
|
29
53
|
},
|
|
30
54
|
}),
|
|
@@ -32,12 +56,12 @@ test("readVaultSecret reads KV v2 secret data using existing vault login env", a
|
|
|
32
56
|
);
|
|
33
57
|
});
|
|
34
58
|
|
|
35
|
-
globalThis.fetch = fetchMock as typeof fetch;
|
|
59
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
36
60
|
|
|
37
61
|
await expect(
|
|
38
62
|
readVaultSecret({
|
|
39
|
-
path: "
|
|
40
|
-
field: "
|
|
63
|
+
path: "prod/providers/neon",
|
|
64
|
+
field: "api_key",
|
|
41
65
|
})
|
|
42
66
|
).resolves.toBe("vault-token");
|
|
43
67
|
});
|
|
@@ -56,7 +80,7 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
|
56
80
|
JSON.stringify({
|
|
57
81
|
data: {
|
|
58
82
|
data: {
|
|
59
|
-
|
|
83
|
+
api_key: "vault-token",
|
|
60
84
|
},
|
|
61
85
|
},
|
|
62
86
|
}),
|
|
@@ -64,12 +88,72 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
|
64
88
|
);
|
|
65
89
|
});
|
|
66
90
|
|
|
67
|
-
globalThis.fetch = fetchMock as typeof fetch;
|
|
91
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
68
92
|
|
|
69
93
|
await expect(
|
|
70
94
|
readVaultSecret({
|
|
71
|
-
path: "
|
|
72
|
-
field: "
|
|
95
|
+
path: "prod/providers/neon",
|
|
96
|
+
field: "api_key",
|
|
73
97
|
})
|
|
74
98
|
).resolves.toBe("vault-token");
|
|
75
99
|
});
|
|
100
|
+
|
|
101
|
+
test("upsertVaultSecretFields writes merged KV v2 data", async () => {
|
|
102
|
+
process.env.VAULT_ADDR = "https://vault.example.com";
|
|
103
|
+
process.env.VAULT_TOKEN = "token-123";
|
|
104
|
+
|
|
105
|
+
const requests: Array<{ method: string; url: string; body?: unknown }> = [];
|
|
106
|
+
const fetchMock = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
107
|
+
const url = String(input);
|
|
108
|
+
requests.push({
|
|
109
|
+
method: init?.method ?? "GET",
|
|
110
|
+
url,
|
|
111
|
+
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if ((init?.method ?? "GET") === "GET") {
|
|
115
|
+
return new Response(
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
data: {
|
|
118
|
+
data: {
|
|
119
|
+
existing_field: "keep-me",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
{ status: 200 }
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new Response(JSON.stringify({}), { status: 200 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
131
|
+
|
|
132
|
+
await upsertVaultSecretFields({
|
|
133
|
+
path: "prod/providers/clerk",
|
|
134
|
+
fields: {
|
|
135
|
+
publishable_key: "pk_live_example",
|
|
136
|
+
secret_key: "sk_live_example",
|
|
137
|
+
webhook_secret: "whsec_example",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(requests).toEqual([
|
|
142
|
+
{
|
|
143
|
+
method: "GET",
|
|
144
|
+
url: "https://vault.example.com/v1/secret/data/prod/providers/clerk",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
method: "POST",
|
|
148
|
+
url: "https://vault.example.com/v1/secret/data/prod/providers/clerk",
|
|
149
|
+
body: {
|
|
150
|
+
data: {
|
|
151
|
+
existing_field: "keep-me",
|
|
152
|
+
publishable_key: "pk_live_example",
|
|
153
|
+
secret_key: "sk_live_example",
|
|
154
|
+
webhook_secret: "whsec_example",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
});
|
package/src/vault.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_VAULT_SECRET_MOUNT = "secret";
|
|
5
|
-
const DEFAULT_NEON_API_KEY_PATH = "
|
|
6
|
-
const DEFAULT_NEON_API_KEY_FIELD = "
|
|
5
|
+
const DEFAULT_NEON_API_KEY_PATH = "prod/providers/neon";
|
|
6
|
+
const DEFAULT_NEON_API_KEY_FIELD = "api_key";
|
|
7
7
|
|
|
8
8
|
type VaultSecretOptions = {
|
|
9
9
|
addr?: string;
|
|
@@ -13,6 +13,14 @@ type VaultSecretOptions = {
|
|
|
13
13
|
field?: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
type VaultWriteOptions = {
|
|
17
|
+
addr?: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
mount?: string;
|
|
20
|
+
path: string;
|
|
21
|
+
fields: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
16
24
|
export async function resolveNeonApiKey() {
|
|
17
25
|
const direct = process.env.NEON_API_KEY?.trim();
|
|
18
26
|
if (direct) {
|
|
@@ -26,24 +34,59 @@ export async function resolveNeonApiKey() {
|
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export async function readVaultSecret(options: VaultSecretOptions = {}) {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
37
|
+
const field = options.field?.trim() ?? "value";
|
|
38
|
+
const payload = await readVaultSecretData(options);
|
|
31
39
|
const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
|
|
32
40
|
const path = options.path?.trim() ?? "";
|
|
33
|
-
const field = options.field?.trim() ?? "value";
|
|
34
|
-
|
|
35
|
-
if (!addr || !token || !path) {
|
|
36
|
-
throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const normalizedAddr = addr.replace(/\/+$/g, "");
|
|
40
41
|
const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
|
|
41
42
|
const normalizedPath = path.replace(/^\/+/g, "");
|
|
42
|
-
const
|
|
43
|
+
const value = payload[field]?.trim();
|
|
44
|
+
if (!value) {
|
|
45
|
+
throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readVaultSecretFields(options: VaultSecretOptions = {}) {
|
|
52
|
+
return readVaultSecretData(options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function upsertVaultSecretFields(options: VaultWriteOptions) {
|
|
56
|
+
const connection = await resolveVaultConnection(options);
|
|
57
|
+
const url = vaultKv2Url(connection);
|
|
58
|
+
|
|
59
|
+
const existing = await readVaultSecretData({ ...options, path: connection.normalizedPath }).catch((error) => {
|
|
60
|
+
if (error instanceof Error && error.message.startsWith("Vault read failed: 404")) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
43
65
|
|
|
44
66
|
const response = await fetch(url, {
|
|
67
|
+
method: "POST",
|
|
45
68
|
headers: {
|
|
46
|
-
"
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"X-Vault-Token": connection.token,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
data: {
|
|
74
|
+
...existing,
|
|
75
|
+
...trimFields(options.fields),
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`Vault write failed: ${response.status} ${response.statusText}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readVaultSecretData(options: VaultSecretOptions = {}) {
|
|
86
|
+
const connection = await resolveVaultConnection(options);
|
|
87
|
+
const response = await fetch(vaultKv2Url(connection), {
|
|
88
|
+
headers: {
|
|
89
|
+
"X-Vault-Token": connection.token,
|
|
47
90
|
},
|
|
48
91
|
});
|
|
49
92
|
|
|
@@ -57,12 +100,31 @@ export async function readVaultSecret(options: VaultSecretOptions = {}) {
|
|
|
57
100
|
};
|
|
58
101
|
};
|
|
59
102
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
103
|
+
return payload.data?.data ?? {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveVaultConnection(options: Omit<VaultWriteOptions, "fields"> | VaultSecretOptions) {
|
|
107
|
+
const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
|
|
108
|
+
const token = options.token ?? (await resolveVaultToken());
|
|
109
|
+
const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
|
|
110
|
+
const path = options.path?.trim() ?? "";
|
|
111
|
+
|
|
112
|
+
if (!addr || !token || !path) {
|
|
113
|
+
throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
|
|
63
114
|
}
|
|
64
115
|
|
|
65
|
-
|
|
116
|
+
const normalizedAddr = addr.replace(/\/+$/g, "");
|
|
117
|
+
const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
|
|
118
|
+
const normalizedPath = path.replace(/^\/+/g, "");
|
|
119
|
+
return { normalizedAddr, normalizedMount, normalizedPath, token };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function vaultKv2Url(connection: Awaited<ReturnType<typeof resolveVaultConnection>>) {
|
|
123
|
+
return `${connection.normalizedAddr}/v1/${connection.normalizedMount}/data/${connection.normalizedPath}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trimFields(fields: Record<string, string>) {
|
|
127
|
+
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, value.trim()]));
|
|
66
128
|
}
|
|
67
129
|
|
|
68
130
|
async function resolveVaultToken() {
|
|
@@ -71,7 +133,8 @@ async function resolveVaultToken() {
|
|
|
71
133
|
return direct;
|
|
72
134
|
}
|
|
73
135
|
|
|
74
|
-
const
|
|
136
|
+
const home = process.env.HOME?.trim() || homedir();
|
|
137
|
+
const tokenFile = process.env.VAULT_TOKEN_FILE?.trim() || join(home, ".vault-token");
|
|
75
138
|
|
|
76
139
|
try {
|
|
77
140
|
const value = (await Bun.file(tokenFile).text()).trim();
|