create-svc 0.1.11 → 0.1.13
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 +24 -23
- package/index.ts +2 -2
- package/package.json +3 -5
- package/src/cli.ts +5 -7
- package/src/scaffold.test.ts +3 -3
- package/src/scaffold.ts +8 -18
- package/src/service.test.ts +30 -0
- package/src/service.ts +65 -0
- package/src/vault.test.ts +1 -61
- package/src/vault.ts +15 -77
- package/templates/shared/README.md +5 -5
- package/templates/shared/scripts/cloudrun/cli.ts +5 -0
- package/templates/targets/workers/README.md +6 -6
- package/templates/targets/workers/scripts/workers/cli.ts +5 -0
- package/bin/create-svc.mjs +0 -2
- /package/bin/{create-service.mjs → service.mjs} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# service
|
|
2
2
|
|
|
3
|
-
`
|
|
3
|
+
`service` is a local microservice CLI for generating standalone API services and operating them after generation with the same command name.
|
|
4
4
|
|
|
5
5
|
- a single `microservice` generation path
|
|
6
6
|
- explicit deploy target selection: Cloud Run or Cloudflare Workers
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- HTTP frameworks (`chi` or `hono`) and ConnectRPC variants
|
|
9
9
|
- standalone package output that does not assume repo bootstrap
|
|
10
10
|
- a generated `service.config.ts` manifest
|
|
11
|
-
-
|
|
11
|
+
- one `service` CLI for scaffold, create, deploy, migrate, seed, dashboards, doctor, and destroy
|
|
12
12
|
- local Docker Compose Postgres for first-run development
|
|
13
13
|
- Neon-backed remote environments
|
|
14
14
|
- a production API origin at `https://api.<service_id>.anmho.com`
|
|
@@ -20,25 +20,27 @@ npm: <https://www.npmjs.com/package/create-svc>
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
|
|
23
|
+
service create my-service
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Inside a generated service repo, the same command operates that repo:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
|
|
29
|
+
cd my-service
|
|
30
|
+
service create
|
|
31
|
+
service deploy
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
To install from npm:
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
|
-
|
|
37
|
+
bun add -g create-svc
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
For the strict one-command production path:
|
|
39
41
|
|
|
40
42
|
```bash
|
|
41
|
-
|
|
43
|
+
service create my-service --yes
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
`--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
|
|
@@ -46,7 +48,7 @@ bun create svc my-service --yes
|
|
|
46
48
|
By default, a standalone generated service is initialized as a git repository,
|
|
47
49
|
committed with `Initial commit`, created as a private GitHub repository at
|
|
48
50
|
`anmho/<service-name>`, and pushed to `origin/main`. If the target directory is
|
|
49
|
-
inside an existing git worktree,
|
|
51
|
+
inside an existing git worktree, `service` skips git and GitHub setup so the
|
|
50
52
|
parent repository remains in control. Pass `--no-git` to skip all git and GitHub
|
|
51
53
|
side effects.
|
|
52
54
|
|
|
@@ -57,15 +59,14 @@ Without publishing to npm:
|
|
|
57
59
|
```bash
|
|
58
60
|
bun install
|
|
59
61
|
npm pack
|
|
60
|
-
bunx ./create-svc-*.tgz my-service
|
|
62
|
+
bunx ./create-svc-*.tgz create my-service
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
For faster iteration against your working tree:
|
|
64
66
|
|
|
65
67
|
```bash
|
|
66
68
|
bun link
|
|
67
|
-
|
|
68
|
-
create-service my-service
|
|
69
|
+
service create my-service
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
During scaffold, the generator can discover:
|
|
@@ -96,9 +97,9 @@ bun run dev
|
|
|
96
97
|
bun run gen
|
|
97
98
|
bun run lint
|
|
98
99
|
bun run test
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
service create
|
|
101
|
+
service deploy
|
|
102
|
+
service destroy
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
For Go variants:
|
|
@@ -109,9 +110,9 @@ make dev
|
|
|
109
110
|
make gen
|
|
110
111
|
make lint
|
|
111
112
|
make test
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
service create
|
|
114
|
+
service deploy
|
|
115
|
+
service destroy
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
|
|
@@ -125,7 +126,7 @@ The generated microservice domain is a small waitlist/launch service example wit
|
|
|
125
126
|
```bash
|
|
126
127
|
bun install
|
|
127
128
|
bun test src scripts
|
|
128
|
-
bun run index.ts my-service
|
|
129
|
+
bun run index.ts create my-service
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
Validate the generated app matrix against local Docker Compose Postgres:
|
|
@@ -140,7 +141,7 @@ The validation harness scaffolds generated services into ignored `bin/generated/
|
|
|
140
141
|
|
|
141
142
|
## npm Trusted Publishing
|
|
142
143
|
|
|
143
|
-
`create-
|
|
144
|
+
`create-svc` is set up for npm trusted publishing from GitHub Actions, so there is no long-lived npm publish token to store in Vault.
|
|
144
145
|
|
|
145
146
|
Repository workflow:
|
|
146
147
|
|
|
@@ -150,12 +151,12 @@ Repository workflow:
|
|
|
150
151
|
|
|
151
152
|
npm package setup still has to be configured once in the npm UI to trust this repository and workflow:
|
|
152
153
|
|
|
153
|
-
1. Open the `create-
|
|
154
|
+
1. Open the `create-svc` package settings on npm.
|
|
154
155
|
2. Go to `Settings` -> `Trusted Publisher`.
|
|
155
156
|
3. Select `GitHub Actions`.
|
|
156
157
|
4. Enter:
|
|
157
158
|
- Organization or user: `anmho`
|
|
158
|
-
- Repository: `create-
|
|
159
|
+
- Repository: `create-svc`
|
|
159
160
|
- Workflow filename: `publish.yml`
|
|
160
161
|
5. Save the trusted publisher.
|
|
161
162
|
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-svc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"bin": {
|
|
9
|
-
"
|
|
10
|
-
"create-svc": "bin/create-svc.mjs"
|
|
9
|
+
"service": "bin/service.mjs"
|
|
11
10
|
},
|
|
12
11
|
"files": [
|
|
13
|
-
"bin/
|
|
14
|
-
"bin/create-svc.mjs",
|
|
12
|
+
"bin/service.mjs",
|
|
15
13
|
"index.ts",
|
|
16
14
|
"src",
|
|
17
15
|
"templates",
|
package/src/cli.ts
CHANGED
|
@@ -76,7 +76,7 @@ export async function run(argv: string[]) {
|
|
|
76
76
|
|
|
77
77
|
await maybeCheckForUpdate(args);
|
|
78
78
|
|
|
79
|
-
intro(`${pc.bold("
|
|
79
|
+
intro(`${pc.bold("service")} ${pc.dim("microservice bootstrap")}`);
|
|
80
80
|
|
|
81
81
|
const config = await resolveConfig(args);
|
|
82
82
|
const targetDir = resolve(process.cwd(), config.directory);
|
|
@@ -137,13 +137,11 @@ export async function run(argv: string[]) {
|
|
|
137
137
|
`Local DB: ${pc.cyan("started by local dev command")}`,
|
|
138
138
|
`Migrate: ${pc.cyan(isBun ? "bun run migrate" : "make migrate")}`,
|
|
139
139
|
`Local dev: ${pc.cyan(isBun ? "bun run dev" : "make dev")}`,
|
|
140
|
-
`Create: ${pc.cyan(
|
|
141
|
-
`Deploy: ${pc.cyan(
|
|
140
|
+
`Create: ${pc.cyan("service create")}`,
|
|
141
|
+
`Deploy: ${pc.cyan("service deploy")}`,
|
|
142
142
|
config.git.enabled ? `Repository: ${pc.cyan(`https://github.com/anmho/${config.git.repository}`)}` : undefined,
|
|
143
143
|
`Personal env: ${pc.cyan(
|
|
144
|
-
|
|
145
|
-
? `bun run deploy -- --environment personal --name ${config.serviceName}`
|
|
146
|
-
: `make deploy ARGS="--environment personal --name ${config.serviceName}"`
|
|
144
|
+
`service deploy --environment personal --name ${config.serviceName}`
|
|
147
145
|
)}`,
|
|
148
146
|
`Production API: ${pc.cyan(`https://${config.apiHostname}`)}`,
|
|
149
147
|
].filter(Boolean).join("\n")
|
|
@@ -827,7 +825,7 @@ export function validateServiceNameInput(rawValue: string, directoryOverride?: s
|
|
|
827
825
|
function printHelp() {
|
|
828
826
|
log.message(`
|
|
829
827
|
Usage:
|
|
830
|
-
|
|
828
|
+
service create [service_id] [options]
|
|
831
829
|
|
|
832
830
|
Options:
|
|
833
831
|
--target <cloudrun|workers> Deploy target for the generated service
|
package/src/scaffold.test.ts
CHANGED
|
@@ -258,7 +258,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
258
258
|
expect(readme).toContain("AUTH_ENABLED=true");
|
|
259
259
|
expect(readme).toContain("verifies JWT bearer tokens");
|
|
260
260
|
expect(readme).toContain("prod/apps/auth/authctl/cloudflare-access");
|
|
261
|
-
expect(readme).toContain(
|
|
261
|
+
expect(readme).toContain("service auth resource-server");
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
const deployWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).text();
|
|
@@ -285,8 +285,8 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
|
|
|
285
285
|
expect(readme).toContain("local Postgres service in `docker-compose.yml`");
|
|
286
286
|
expect(readme).toContain("gcloud auth login");
|
|
287
287
|
expect(readme).toContain("known-good CLIs");
|
|
288
|
-
expect(readme).toContain("
|
|
289
|
-
expect(readme).toContain("
|
|
288
|
+
expect(readme).toContain("service create");
|
|
289
|
+
expect(readme).toContain("service deploy");
|
|
290
290
|
expect(readme).toContain("one-command production create");
|
|
291
291
|
expect(readme).toContain("waitlist/launch service");
|
|
292
292
|
expect(readme).toContain("Terraform is optional");
|
package/src/scaffold.ts
CHANGED
|
@@ -214,24 +214,14 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
214
214
|
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
215
215
|
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
216
216
|
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
217
|
-
COMMAND_BOOTSTRAP:
|
|
218
|
-
COMMAND_DEPLOY:
|
|
219
|
-
COMMAND_AUTH_RESOURCE:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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"',
|
|
217
|
+
COMMAND_BOOTSTRAP: "service create",
|
|
218
|
+
COMMAND_DEPLOY: "service deploy",
|
|
219
|
+
COMMAND_AUTH_RESOURCE: "service auth resource-server",
|
|
220
|
+
COMMAND_AUTH_CLIENT: "service auth client create",
|
|
221
|
+
COMMAND_DEPLOY_PERSONAL: "service deploy --environment personal --name <name>",
|
|
222
|
+
COMMAND_DEPLOY_DESTROY: "service destroy --environment personal --name <name>",
|
|
223
|
+
COMMAND_CLEANUP: "service destroy",
|
|
224
|
+
COMMAND_CLEANUP_PROJECT: "service destroy --project",
|
|
235
225
|
GITIGNORE_EXTRA: "",
|
|
236
226
|
LOCAL_INTROSPECTION_NOTE:
|
|
237
227
|
config.framework === "connectrpc"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { findGeneratedServiceRoot, normalizeScaffoldArgs } from "./service";
|
|
6
|
+
|
|
7
|
+
test("normalizeScaffoldArgs treats service create as the scaffold command outside a service repo", () => {
|
|
8
|
+
expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
|
|
9
|
+
expect(normalizeScaffoldArgs(["new", "launch-api"])).toEqual(["launch-api"]);
|
|
10
|
+
expect(normalizeScaffoldArgs(["init", "launch-api"])).toEqual(["launch-api"]);
|
|
11
|
+
expect(normalizeScaffoldArgs(["launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("normalizeScaffoldArgs maps service help to generator help outside a service repo", () => {
|
|
15
|
+
expect(normalizeScaffoldArgs(["help"])).toEqual(["--help"]);
|
|
16
|
+
expect(normalizeScaffoldArgs(["help", "--verbose"])).toEqual(["--help", "--verbose"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("findGeneratedServiceRoot detects generated service context from nested directories", async () => {
|
|
20
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-service-root-"));
|
|
21
|
+
const serviceRoot = join(root, "generated-api");
|
|
22
|
+
const nested = join(serviceRoot, "src", "waitlist");
|
|
23
|
+
await mkdir(join(serviceRoot, "scripts", "cloudrun"), { recursive: true });
|
|
24
|
+
await mkdir(nested, { recursive: true });
|
|
25
|
+
await writeFile(join(serviceRoot, "service.config.ts"), "export default {}");
|
|
26
|
+
await writeFile(join(serviceRoot, "scripts", "cloudrun", "cli.ts"), "");
|
|
27
|
+
|
|
28
|
+
expect(findGeneratedServiceRoot(nested)).toBe(serviceRoot);
|
|
29
|
+
expect(findGeneratedServiceRoot(root)).toBeUndefined();
|
|
30
|
+
});
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { run as runScaffoldCli } from "./cli";
|
|
4
|
+
|
|
5
|
+
const SCAFFOLD_COMMANDS = new Set(["create", "new", "init"]);
|
|
6
|
+
|
|
7
|
+
export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
|
|
8
|
+
const serviceRoot = findGeneratedServiceRoot(cwd);
|
|
9
|
+
if (serviceRoot) {
|
|
10
|
+
delegateToGeneratedService(serviceRoot, argv);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await runScaffoldCli(normalizeScaffoldArgs(argv));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeScaffoldArgs(argv: string[]) {
|
|
18
|
+
const [command, ...rest] = argv;
|
|
19
|
+
if (command && SCAFFOLD_COMMANDS.has(command)) {
|
|
20
|
+
return rest;
|
|
21
|
+
}
|
|
22
|
+
if (command === "help") {
|
|
23
|
+
return ["--help", ...rest];
|
|
24
|
+
}
|
|
25
|
+
return argv;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findGeneratedServiceRoot(start: string): string | undefined {
|
|
29
|
+
let current = start;
|
|
30
|
+
while (true) {
|
|
31
|
+
if (isGeneratedServiceRoot(current)) {
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parent = dirname(current);
|
|
36
|
+
if (parent === current) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isGeneratedServiceRoot(path: string) {
|
|
44
|
+
return (
|
|
45
|
+
existsSync(join(path, "service.config.ts")) &&
|
|
46
|
+
(existsSync(join(path, "scripts", "cloudrun", "cli.ts")) || existsSync(join(path, "scripts", "workers", "cli.ts")))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
|
|
51
|
+
const cliPath = existsSync(join(serviceRoot, "scripts", "cloudrun", "cli.ts"))
|
|
52
|
+
? "./scripts/cloudrun/cli.ts"
|
|
53
|
+
: "./scripts/workers/cli.ts";
|
|
54
|
+
const result = Bun.spawnSync(["bun", "run", cliPath, ...argv], {
|
|
55
|
+
cwd: serviceRoot,
|
|
56
|
+
env: process.env,
|
|
57
|
+
stdin: "inherit",
|
|
58
|
+
stdout: "inherit",
|
|
59
|
+
stderr: "inherit",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
process.exit(result.exitCode || 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
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
|
|
3
|
+
import { readVaultSecret, resolveNeonApiKey } from "./vault";
|
|
4
4
|
|
|
5
5
|
const originalEnv = { ...process.env };
|
|
6
6
|
|
|
@@ -97,63 +97,3 @@ test("readVaultSecret falls back to ~/.vault-token", async () => {
|
|
|
97
97
|
})
|
|
98
98
|
).resolves.toBe("vault-token");
|
|
99
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
|
@@ -13,14 +13,6 @@ 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
|
-
|
|
24
16
|
export async function resolveNeonApiKey() {
|
|
25
17
|
const direct = process.env.NEON_API_KEY?.trim();
|
|
26
18
|
if (direct) {
|
|
@@ -34,59 +26,24 @@ export async function resolveNeonApiKey() {
|
|
|
34
26
|
}
|
|
35
27
|
|
|
36
28
|
export async function readVaultSecret(options: VaultSecretOptions = {}) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
29
|
+
const addr = options.addr ?? process.env.VAULT_ADDR?.trim() ?? "";
|
|
30
|
+
const token = options.token ?? (await resolveVaultToken());
|
|
39
31
|
const mount = options.mount ?? process.env.VAULT_SECRET_MOUNT?.trim() ?? DEFAULT_VAULT_SECRET_MOUNT;
|
|
40
32
|
const path = options.path?.trim() ?? "";
|
|
41
|
-
const
|
|
42
|
-
const normalizedPath = path.replace(/^\/+/g, "");
|
|
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
|
-
}
|
|
33
|
+
const field = options.field?.trim() ?? "value";
|
|
54
34
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
35
|
+
if (!addr || !token || !path) {
|
|
36
|
+
throw new Error("Vault secret resolution requires VAULT_ADDR, a Vault token, and a secret path");
|
|
37
|
+
}
|
|
58
38
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
throw error;
|
|
64
|
-
});
|
|
39
|
+
const normalizedAddr = addr.replace(/\/+$/g, "");
|
|
40
|
+
const normalizedMount = mount.replace(/^\/+|\/+$/g, "");
|
|
41
|
+
const normalizedPath = path.replace(/^\/+/g, "");
|
|
42
|
+
const url = `${normalizedAddr}/v1/${normalizedMount}/data/${normalizedPath}`;
|
|
65
43
|
|
|
66
44
|
const response = await fetch(url, {
|
|
67
|
-
method: "POST",
|
|
68
45
|
headers: {
|
|
69
|
-
"
|
|
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,
|
|
46
|
+
"X-Vault-Token": token,
|
|
90
47
|
},
|
|
91
48
|
});
|
|
92
49
|
|
|
@@ -100,31 +57,12 @@ async function readVaultSecretData(options: VaultSecretOptions = {}) {
|
|
|
100
57
|
};
|
|
101
58
|
};
|
|
102
59
|
|
|
103
|
-
|
|
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");
|
|
60
|
+
const value = payload.data?.data?.[field]?.trim();
|
|
61
|
+
if (!value) {
|
|
62
|
+
throw new Error(`Vault secret field ${field} is empty at ${normalizedMount}/${normalizedPath}`);
|
|
114
63
|
}
|
|
115
64
|
|
|
116
|
-
|
|
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()]));
|
|
65
|
+
return value;
|
|
128
66
|
}
|
|
129
67
|
|
|
130
68
|
async function resolveVaultToken() {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# {{SERVICE_NAME}}
|
|
2
2
|
|
|
3
|
-
Generated by `create
|
|
3
|
+
Generated by `service create`.
|
|
4
4
|
|
|
5
5
|
This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run with:
|
|
6
6
|
|
|
7
7
|
- one generated `service.yaml` manifest
|
|
8
8
|
- a lightweight `{{EXAMPLE_LABEL}}` example surface
|
|
9
9
|
- local Docker Compose Postgres for first-run development
|
|
10
|
-
-
|
|
10
|
+
- the `service` CLI for create, deploy, doctor, dashboards, and destroy
|
|
11
11
|
- GCP project create with billing and quota-project-aware `gcloud` calls
|
|
12
12
|
- Neon-backed remote database provisioning during create and deploy
|
|
13
13
|
- Better Auth client-credentials resource-server registration through `authctl`
|
|
@@ -66,7 +66,7 @@ Create, deploy, and destroy use:
|
|
|
66
66
|
- known-good CLIs first, especially `gcloud`
|
|
67
67
|
- `gcloud`
|
|
68
68
|
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
|
|
69
|
-
- the
|
|
69
|
+
- the repo-aware `service` CLI from this package
|
|
70
70
|
|
|
71
71
|
Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
|
|
72
72
|
|
|
@@ -199,10 +199,10 @@ secrets only when you add a provider adapter. A generic adapter can honor:
|
|
|
199
199
|
|
|
200
200
|
The one-command production create path is designed for a fresh standalone service.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
The intended one-command flow is:
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
|
-
|
|
205
|
+
service create {{SERVICE_NAME}} --yes
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
That command scaffolds this package, runs `service create`, deploys the production
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
async function main(argv = Bun.argv.slice(2)) {
|
|
27
27
|
const [command, ...rest] = argv;
|
|
28
28
|
|
|
29
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
30
|
+
console.log("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
if (command === "create") {
|
|
30
35
|
await runMain("Create", async () => {
|
|
31
36
|
assertServiceNameAvailable(config.serviceName);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# {{SERVICE_NAME}}
|
|
2
2
|
|
|
3
|
-
Generated by `create
|
|
3
|
+
Generated by `service create`.
|
|
4
4
|
|
|
5
5
|
This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloudflare Workers with:
|
|
6
6
|
|
|
7
7
|
- one `wrangler.toml`
|
|
8
8
|
- a lightweight waitlist/launch API
|
|
9
|
-
-
|
|
9
|
+
- the `service` CLI for create, deploy, doctor, dashboards, DNS, and destroy
|
|
10
10
|
- Cron Trigger wiring for scheduled follow-up work
|
|
11
11
|
- a Hyperdrive binding for Neon-backed Postgres persistence
|
|
12
12
|
- a production API origin at `https://{{API_HOSTNAME}}`
|
|
@@ -18,8 +18,8 @@ wrangler dev
|
|
|
18
18
|
bun run test
|
|
19
19
|
bun run lint
|
|
20
20
|
bun run migrate
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
service create
|
|
22
|
+
service deploy
|
|
23
23
|
bun run dashboards
|
|
24
24
|
bun run doctor
|
|
25
25
|
bun run destroy
|
|
@@ -50,7 +50,7 @@ small waitlist/trigger schema on first use.
|
|
|
50
50
|
## Production
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
|
|
53
|
+
service create
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
`service create` deploys the Worker through Wrangler. The custom domain is
|
|
@@ -60,7 +60,7 @@ configured in `wrangler.toml`:
|
|
|
60
60
|
https://{{API_HOSTNAME}}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
Use `
|
|
63
|
+
Use `service doctor` after create to verify Wrangler auth, route config, Cron,
|
|
64
64
|
Hyperdrive, dashboard tooling, auth tooling, and deployed health.
|
|
65
65
|
|
|
66
66
|
If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
|
|
@@ -17,6 +17,11 @@ type DoctorStatus = "pass" | "warn" | "fail";
|
|
|
17
17
|
async function main(argv = Bun.argv.slice(2)) {
|
|
18
18
|
const [command, ...rest] = argv;
|
|
19
19
|
|
|
20
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
21
|
+
console.log("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
if (command === "create") {
|
|
21
26
|
return runMain("Create", async () => {
|
|
22
27
|
ensureAuthResourceServer();
|
package/bin/create-svc.mjs
DELETED
|
File without changes
|