create-svc 0.1.25 → 0.1.27

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  - Go or Bun runtime choices where the target supports them
8
8
  - HTTP frameworks (`chi` or `hono`) and ConnectRPC variants
9
9
  - standalone package output that does not assume repo bootstrap
10
- - a generated `service.config.ts` manifest
10
+ - a generated `service.jsonc` 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
package/src/jsonc.ts ADDED
@@ -0,0 +1,63 @@
1
+ export function parseJsonc(text: string): unknown {
2
+ return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
3
+ }
4
+
5
+ function stripJsonComments(text: string) {
6
+ let output = "";
7
+ let inString = false;
8
+ let quote = "";
9
+ let escaped = false;
10
+
11
+ for (let index = 0; index < text.length; index += 1) {
12
+ const char = text[index] ?? "";
13
+ const next = text[index + 1] ?? "";
14
+
15
+ if (inString) {
16
+ output += char;
17
+ if (escaped) {
18
+ escaped = false;
19
+ } else if (char === "\\") {
20
+ escaped = true;
21
+ } else if (char === quote) {
22
+ inString = false;
23
+ quote = "";
24
+ }
25
+ continue;
26
+ }
27
+
28
+ if (char === '"' || char === "'") {
29
+ inString = true;
30
+ quote = char;
31
+ output += char;
32
+ continue;
33
+ }
34
+
35
+ if (char === "/" && next === "/") {
36
+ while (index < text.length && text[index] !== "\n") {
37
+ index += 1;
38
+ }
39
+ output += "\n";
40
+ continue;
41
+ }
42
+
43
+ if (char === "/" && next === "*") {
44
+ index += 2;
45
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
46
+ if (text[index] === "\n") {
47
+ output += "\n";
48
+ }
49
+ index += 1;
50
+ }
51
+ index += 1;
52
+ continue;
53
+ }
54
+
55
+ output += char;
56
+ }
57
+
58
+ return output;
59
+ }
60
+
61
+ function stripTrailingCommas(text: string) {
62
+ return text.replace(/,\s*([}\]])/g, "$1");
63
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ScaffoldConfig } from "./scaffold";
2
+ import { dirname } from "node:path";
2
3
 
3
4
  type CommandOptions = {
4
5
  cwd: string;
@@ -169,7 +170,7 @@ function requireCommand(name: string) {
169
170
  function run(command: string, args: string[], options: CommandOptions): CommandResult {
170
171
  const result = Bun.spawnSync([command, ...args], {
171
172
  cwd: options.cwd,
172
- env: process.env,
173
+ env: postScaffoldEnv(),
173
174
  stdin: options.input === undefined ? undefined : encoder.encode(options.input),
174
175
  stdout: options.allowFailure || options.quiet ? "pipe" : "inherit",
175
176
  stderr: options.allowFailure || options.quiet ? "pipe" : "inherit",
@@ -188,3 +189,11 @@ function run(command: string, args: string[], options: CommandOptions): CommandR
188
189
  stderr,
189
190
  };
190
191
  }
192
+
193
+ function postScaffoldEnv() {
194
+ const currentBinDir = dirname(Bun.argv[1] ?? "");
195
+ return {
196
+ ...process.env,
197
+ PATH: currentBinDir ? `${currentBinDir}:${process.env.PATH ?? ""}` : process.env.PATH,
198
+ };
199
+ }
@@ -54,28 +54,28 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
54
54
  })
55
55
  );
56
56
 
57
- const serviceConfig = await Bun.file(join(generatedRoot, "service.config.ts")).text();
58
- expect(serviceConfig).toContain('service_id: "dns-api"');
59
- expect(serviceConfig).toContain('target: "cloudrun"');
60
- expect(serviceConfig).toContain('profile: "microservice"');
61
- expect(serviceConfig).toContain('domain: "waitlist"');
62
- expect(serviceConfig).toContain('kind: "microservice"');
63
- expect(serviceConfig).toContain(`runtime: "${variant.runtime}"`);
64
- expect(serviceConfig).toContain(`framework: "${variant.framework}"`);
65
- expect(serviceConfig).toContain('module: "buf.build/anmho/dns-api"');
66
- expect(serviceConfig).toContain('cloudflare_vault_path: "prod/providers/cloudflare"');
67
- expect(serviceConfig).toContain('issuer: "https://auth.anmho.com/api/auth"');
68
- expect(serviceConfig).toContain('audience: "api://dns-api"');
69
- expect(serviceConfig).toContain('vault_path_prefix: "prod/apps/dns-api/server/oauth-clients"');
70
- expect(serviceConfig).toContain('api_key_secret_name: "dns-api-temporal-api-key"');
71
- expect(serviceConfig).toContain('project_mode: "create_new"');
72
- expect(serviceConfig).toContain('quota_project_id: "anmho-infra-prod"');
73
- expect(serviceConfig).toContain('jwks_url: "https://auth.anmho.com/api/auth/jwks"');
74
- expect(serviceConfig).toContain('project_id: ""');
75
- expect(serviceConfig).toContain('base_branch_id: ""');
76
- expect(serviceConfig).toContain('base_branch_name: "main"');
77
- expect(serviceConfig).toContain('preview_branch_prefix: "dns-api-pr"');
78
- expect(serviceConfig).toContain('hostname: "api.dns-api.anmho.com"');
57
+ const serviceConfig = await Bun.file(join(generatedRoot, "service.jsonc")).text();
58
+ expect(serviceConfig).toContain('"service_id": "dns-api"');
59
+ expect(serviceConfig).toContain('"target": "cloudrun"');
60
+ expect(serviceConfig).toContain('"profile": "microservice"');
61
+ expect(serviceConfig).toContain('"domain": "waitlist"');
62
+ expect(serviceConfig).toContain('"kind": "microservice"');
63
+ expect(serviceConfig).toContain(`"runtime": "${variant.runtime}"`);
64
+ expect(serviceConfig).toContain(`"framework": "${variant.framework}"`);
65
+ expect(serviceConfig).toContain('"module": "buf.build/anmho/dns-api"');
66
+ expect(serviceConfig).toContain('"cloudflare_vault_path": "prod/providers/cloudflare"');
67
+ expect(serviceConfig).toContain('"issuer": "https://auth.anmho.com/api/auth"');
68
+ expect(serviceConfig).toContain('"audience": "api://dns-api"');
69
+ expect(serviceConfig).toContain('"vault_path_prefix": "prod/apps/dns-api/server/oauth-clients"');
70
+ expect(serviceConfig).toContain('"api_key_secret_name": "dns-api-temporal-api-key"');
71
+ expect(serviceConfig).toContain('"project_mode": "create_new"');
72
+ expect(serviceConfig).toContain('"quota_project_id": "anmho-infra-prod"');
73
+ expect(serviceConfig).toContain('"jwks_url": "https://auth.anmho.com/api/auth/jwks"');
74
+ expect(serviceConfig).toContain('"project_id": ""');
75
+ expect(serviceConfig).toContain('"base_branch_id": ""');
76
+ expect(serviceConfig).toContain('"base_branch_name": "main"');
77
+ expect(serviceConfig).toContain('"preview_branch_prefix": "dns-api-pr"');
78
+ expect(serviceConfig).toContain('"hostname": "api.dns-api.anmho.com"');
79
79
  expect(serviceConfig).not.toContain("github:");
80
80
  expect(serviceConfig).not.toContain("attachmentBucket");
81
81
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "integrations.ts")).exists()).toBeFalse();
@@ -195,10 +195,10 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
195
195
  expect(packageJson).toContain('"destroy": "service destroy"');
196
196
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cli.ts")).exists()).toBeFalse();
197
197
  expect(await Bun.file(join(generatedRoot, "scripts", "authctl.ts")).exists()).toBeFalse();
198
- const serviceConfig = await Bun.file(join(generatedRoot, "service.config.ts")).text();
199
- expect(serviceConfig).toContain('service_id: "dns-api"');
200
- expect(serviceConfig).toContain('project_id: "anmho-dns-api"');
201
- expect(serviceConfig).toContain('database_name: "dns_api"');
198
+ const serviceConfig = await Bun.file(join(generatedRoot, "service.jsonc")).text();
199
+ expect(serviceConfig).toContain('"service_id": "dns-api"');
200
+ expect(serviceConfig).toContain('"project_id": "anmho-dns-api"');
201
+ expect(serviceConfig).toContain('"database_name": "dns_api"');
202
202
  const authScript = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
203
203
  expect(authScript).toContain('"Ed25519"');
204
204
 
@@ -324,10 +324,10 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
324
324
  expect(readme).toContain("Cloudflare Workers");
325
325
  expect(readme).toContain("Hyperdrive binding for Neon-backed Postgres persistence");
326
326
  expect(readme).not.toContain("Cloud Run");
327
- const serviceConfig = await Bun.file(join(generatedRoot, "service.config.ts")).text();
328
- expect(serviceConfig).toContain('target: "workers"');
329
- expect(serviceConfig).toContain('hostname: "api.dns-api.anmho.com"');
330
- expect(serviceConfig).toContain('database_name: "dns_api"');
327
+ const serviceConfig = await Bun.file(join(generatedRoot, "service.jsonc")).text();
328
+ expect(serviceConfig).toContain('"target": "workers"');
329
+ expect(serviceConfig).toContain('"hostname": "api.dns-api.anmho.com"');
330
+ expect(serviceConfig).toContain('"database_name": "dns_api"');
331
331
  const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
332
332
  expect(makefile).toContain('no generated code for workers');
333
333
  expect(makefile).toContain("auth:");
@@ -1,5 +1,7 @@
1
1
  import { intro, log, outro, spinner } from "@clack/prompts";
2
+ import { join } from "node:path";
2
3
  import { config } from "./config";
4
+ import { serviceRoot } from "../runtime";
3
5
 
4
6
  type CommandOptions = {
5
7
  allowFailure?: boolean;
@@ -422,7 +424,7 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
422
424
  }
423
425
 
424
426
  export async function renderManifest(image: string, target: DeploymentTarget) {
425
- const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
427
+ const template = await Bun.file(join(serviceRoot, "service.yaml")).text();
426
428
  const temporal = resolveTemporalRuntimeConfig();
427
429
  const values = {
428
430
  SERVICE_NAME: target.serviceName,
@@ -1,8 +1,15 @@
1
1
  import { join } from "node:path";
2
- import { pathToFileURL } from "node:url";
2
+ import { parseJsonc } from "../jsonc";
3
3
 
4
4
  export const serviceRoot = process.env.CREATE_SVC_SERVICE_ROOT?.trim() || process.cwd();
5
5
 
6
- export const serviceConfig = (
7
- await import(pathToFileURL(join(serviceRoot, "service.config.ts")).href)
8
- ).default;
6
+ export const serviceConfig = await readServiceConfig(serviceRoot);
7
+
8
+ async function readServiceConfig(root: string) {
9
+ const configPath = join(root, "service.jsonc");
10
+ const parsed = parseJsonc(await Bun.file(configPath).text());
11
+ if (!parsed || typeof parsed !== "object") {
12
+ throw new Error(`${configPath} must contain a JSON object`);
13
+ }
14
+ return parsed as any;
15
+ }
@@ -21,7 +21,7 @@ test("findGeneratedServiceRoot detects generated service context from nested dir
21
21
  const serviceRoot = join(root, "generated-api");
22
22
  const nested = join(serviceRoot, "src", "waitlist");
23
23
  await mkdir(nested, { recursive: true });
24
- await writeFile(join(serviceRoot, "service.config.ts"), "export default {}");
24
+ await writeFile(join(serviceRoot, "service.jsonc"), "{}");
25
25
 
26
26
  expect(findGeneratedServiceRoot(nested)).toBe(serviceRoot);
27
27
  expect(findGeneratedServiceRoot(root)).toBeUndefined();
package/src/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { run as runScaffoldCli } from "./cli";
4
+ import { parseJsonc } from "./jsonc";
4
5
 
5
6
  const SCAFFOLD_COMMANDS = new Set(["create", "new", "init"]);
6
7
 
@@ -41,7 +42,7 @@ export function findGeneratedServiceRoot(start: string): string | undefined {
41
42
  }
42
43
 
43
44
  function isGeneratedServiceRoot(path: string) {
44
- return existsSync(join(path, "service.config.ts"));
45
+ return existsSync(join(path, "service.jsonc"));
45
46
  }
46
47
 
47
48
  async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
@@ -49,7 +50,7 @@ async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
49
50
  process.chdir(serviceRoot);
50
51
  process.env.CREATE_SVC_SERVICE_ROOT = serviceRoot;
51
52
 
52
- const serviceConfig = (await import(`${serviceRoot}/service.config.ts`)).default;
53
+ const serviceConfig = parseJsonc(await Bun.file(join(serviceRoot, "service.jsonc")).text()) as { target?: string };
53
54
  if (serviceConfig.target === "workers") {
54
55
  const { main } = await import("./service-runtime/workers/cli");
55
56
  await main(argv);
@@ -59,7 +59,7 @@ No cloud credentials are required for local HTTP development after Docker and Po
59
59
 
60
60
  ## Remote provisioning
61
61
 
62
- The generated service config lives in [service.config.ts](service.config.ts).
62
+ The generated service config lives in [service.jsonc](service.jsonc).
63
63
 
64
64
  Create, deploy, and destroy use:
65
65
 
@@ -0,0 +1,106 @@
1
+ {
2
+ // The installed `service` CLI reads this file to choose Cloud Run vs Workers
3
+ // operations for this generated repo.
4
+ "service_id": "{{SERVICE_ID}}",
5
+ "target": "{{TARGET}}",
6
+ "runtime": "{{RUNTIME}}",
7
+ "framework": "{{FRAMEWORK}}",
8
+ "profile": "{{PROFILE}}",
9
+ "stage_default": "prod",
10
+
11
+ "example": {
12
+ "kind": "{{EXAMPLE_KIND}}",
13
+ "domain": "{{EXAMPLE_DOMAIN}}",
14
+ "label": "{{EXAMPLE_LABEL}}"
15
+ },
16
+
17
+ "dns": {
18
+ "hostname": "{{API_HOSTNAME}}",
19
+ "base_domain": "{{API_BASE_DOMAIN}}",
20
+ "cloudflare_api_base_url": "https://api.cloudflare.com/client/v4",
21
+ "cloudflare_vault_path": "prod/providers/cloudflare",
22
+ "cloudflare_vault_field": "api_token"
23
+ },
24
+
25
+ "ownership": {
26
+ "managed_by": "create-service",
27
+ "service_id": "{{SERVICE_ID}}"
28
+ },
29
+
30
+ "auth": {
31
+ "issuer": "{{AUTH_ISSUER}}",
32
+ "token_endpoint": "https://auth.anmho.com/api/auth/oauth2/token",
33
+ "jwks_url": "https://auth.anmho.com/api/auth/jwks",
34
+ "resource_server": {
35
+ "id": "{{SERVICE_ID}}",
36
+ "audience": "api://{{SERVICE_ID}}",
37
+ "default_scopes": ["{{SERVICE_ID}}:read", "{{SERVICE_ID}}:write"]
38
+ },
39
+ "client": {
40
+ "app_id": "{{SERVICE_ID}}",
41
+ "identity": "server",
42
+ "vault_path_prefix": "prod/apps/{{SERVICE_ID}}/server/oauth-clients"
43
+ }
44
+ },
45
+
46
+ "temporal": {
47
+ "enabled": false,
48
+ "address": "localhost:7233",
49
+ "namespace": "default",
50
+ "task_queue": "{{SERVICE_ID}}",
51
+ "api_key_secret_name": "{{SERVICE_ID}}-temporal-api-key"
52
+ },
53
+
54
+ "providers": {
55
+ "vault": {
56
+ "mount": "secret",
57
+ "neon_path": "prod/providers/neon",
58
+ "cloudflare_path": "prod/providers/cloudflare",
59
+ "grafana_path": "prod/providers/grafana",
60
+ "clerk_m2m_path": "prod/providers/clerk-m2m",
61
+ "temporal_path": "prod/providers/temporal"
62
+ }
63
+ },
64
+
65
+ "neon": {
66
+ "project_id": "{{NEON_PROJECT_ID}}",
67
+ "base_branch_id": "{{NEON_BASE_BRANCH_ID}}",
68
+ "base_branch_name": "{{NEON_BASE_BRANCH_NAME}}",
69
+ "database_name": "{{NEON_DATABASE_NAME}}",
70
+ "role_name": "{{NEON_ROLE_NAME}}",
71
+ "preview_branch_prefix": "{{NEON_PREVIEW_BRANCH_PREFIX}}",
72
+ "personal_branch_prefix": "{{NEON_PERSONAL_BRANCH_PREFIX}}"
73
+ },
74
+
75
+ "buf": {
76
+ "module": "buf.build/anmho/{{SERVICE_ID}}"
77
+ },
78
+
79
+ "cloudrun": {
80
+ "project_id": "{{PROJECT_ID}}",
81
+ "project_name": "{{PROJECT_NAME}}",
82
+ "project_mode": "{{GCP_PROJECT_MODE}}",
83
+ "create_if_missing": {{PROJECT_CREATE_IF_MISSING}},
84
+ "billing_account": "{{BILLING_ACCOUNT}}",
85
+ "quota_project_id": "{{QUOTA_PROJECT_ID}}",
86
+ "region": "{{REGION}}",
87
+ "artifact_repository": "cloud-run",
88
+ "service_account": "{{RUNTIME_SERVICE_ACCOUNT}}",
89
+ "required_apis": [
90
+ "run.googleapis.com",
91
+ "cloudbuild.googleapis.com",
92
+ "artifactregistry.googleapis.com",
93
+ "iam.googleapis.com",
94
+ "iamcredentials.googleapis.com",
95
+ "secretmanager.googleapis.com",
96
+ "serviceusage.googleapis.com",
97
+ "sts.googleapis.com"
98
+ ]
99
+ },
100
+
101
+ "workers": {
102
+ "script_name": "{{SERVICE_ID}}",
103
+ "hyperdrive_binding": "HYPERDRIVE",
104
+ "cron": "*/15 * * * *"
105
+ }
106
+ }
@@ -1,94 +0,0 @@
1
- export default {
2
- service_id: "{{SERVICE_ID}}",
3
- target: "{{TARGET}}",
4
- runtime: "{{RUNTIME}}",
5
- framework: "{{FRAMEWORK}}",
6
- profile: "{{PROFILE}}",
7
- stage_default: "prod",
8
- example: {
9
- kind: "{{EXAMPLE_KIND}}",
10
- domain: "{{EXAMPLE_DOMAIN}}",
11
- label: "{{EXAMPLE_LABEL}}",
12
- },
13
- dns: {
14
- hostname: "{{API_HOSTNAME}}",
15
- base_domain: "{{API_BASE_DOMAIN}}",
16
- cloudflare_api_base_url: "https://api.cloudflare.com/client/v4",
17
- cloudflare_vault_path: "prod/providers/cloudflare",
18
- cloudflare_vault_field: "api_token",
19
- },
20
- ownership: {
21
- managed_by: "create-service",
22
- service_id: "{{SERVICE_ID}}",
23
- },
24
- auth: {
25
- issuer: "{{AUTH_ISSUER}}",
26
- token_endpoint: "https://auth.anmho.com/api/auth/oauth2/token",
27
- jwks_url: "https://auth.anmho.com/api/auth/jwks",
28
- resource_server: {
29
- id: "{{SERVICE_ID}}",
30
- audience: "api://{{SERVICE_ID}}",
31
- default_scopes: ["{{SERVICE_ID}}:read", "{{SERVICE_ID}}:write"],
32
- },
33
- client: {
34
- app_id: "{{SERVICE_ID}}",
35
- identity: "server",
36
- vault_path_prefix: "prod/apps/{{SERVICE_ID}}/server/oauth-clients",
37
- },
38
- },
39
- temporal: {
40
- enabled: false,
41
- address: "localhost:7233",
42
- namespace: "default",
43
- task_queue: "{{SERVICE_ID}}",
44
- api_key_secret_name: "{{SERVICE_ID}}-temporal-api-key",
45
- },
46
- providers: {
47
- vault: {
48
- mount: "secret",
49
- neon_path: "prod/providers/neon",
50
- cloudflare_path: "prod/providers/cloudflare",
51
- grafana_path: "prod/providers/grafana",
52
- clerk_m2m_path: "prod/providers/clerk-m2m",
53
- temporal_path: "prod/providers/temporal",
54
- },
55
- },
56
- neon: {
57
- project_id: "{{NEON_PROJECT_ID}}",
58
- base_branch_id: "{{NEON_BASE_BRANCH_ID}}",
59
- base_branch_name: "{{NEON_BASE_BRANCH_NAME}}",
60
- database_name: "{{NEON_DATABASE_NAME}}",
61
- role_name: "{{NEON_ROLE_NAME}}",
62
- preview_branch_prefix: "{{NEON_PREVIEW_BRANCH_PREFIX}}",
63
- personal_branch_prefix: "{{NEON_PERSONAL_BRANCH_PREFIX}}",
64
- },
65
- buf: {
66
- module: "buf.build/anmho/{{SERVICE_ID}}",
67
- },
68
- cloudrun: {
69
- project_id: "{{PROJECT_ID}}",
70
- project_name: "{{PROJECT_NAME}}",
71
- project_mode: "{{GCP_PROJECT_MODE}}",
72
- create_if_missing: {{PROJECT_CREATE_IF_MISSING}},
73
- billing_account: "{{BILLING_ACCOUNT}}",
74
- quota_project_id: "{{QUOTA_PROJECT_ID}}",
75
- region: "{{REGION}}",
76
- artifact_repository: "cloud-run",
77
- service_account: "{{RUNTIME_SERVICE_ACCOUNT}}",
78
- required_apis: [
79
- "run.googleapis.com",
80
- "cloudbuild.googleapis.com",
81
- "artifactregistry.googleapis.com",
82
- "iam.googleapis.com",
83
- "iamcredentials.googleapis.com",
84
- "secretmanager.googleapis.com",
85
- "serviceusage.googleapis.com",
86
- "sts.googleapis.com",
87
- ],
88
- },
89
- workers: {
90
- script_name: "{{SERVICE_ID}}",
91
- hyperdrive_binding: "HYPERDRIVE",
92
- cron: "*/15 * * * *",
93
- },
94
- } as const;