create-svc 0.1.12 → 0.1.14

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
@@ -23,6 +23,13 @@ npm: <https://www.npmjs.com/package/create-svc>
23
23
  service create my-service
24
24
  ```
25
25
 
26
+ That creates `./my-service` by default. To write somewhere else while keeping
27
+ the service id as `my-service`, pass `--dir`:
28
+
29
+ ```bash
30
+ service create my-service --dir /Users/andrewho/repos/projects/my-service
31
+ ```
32
+
26
33
  Inside a generated service repo, the same command operates that repo:
27
34
 
28
35
  ```bash
@@ -34,7 +41,7 @@ service deploy
34
41
  To install from npm:
35
42
 
36
43
  ```bash
37
- bun add -g create-svc
44
+ npm install -g create-svc
38
45
  ```
39
46
 
40
47
  For the strict one-command production path:
@@ -43,14 +50,19 @@ For the strict one-command production path:
43
50
  service create my-service --yes
44
51
  ```
45
52
 
53
+ By default, that scaffolds the repo, installs dependencies, runs the generated
54
+ repo's `service create`, and then runs `service deploy`. Pass
55
+ `--no-auto-deploy` for scaffold-only generation.
56
+
46
57
  `--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
47
58
 
48
59
  By default, a standalone generated service is initialized as a git repository,
49
60
  committed with `Initial commit`, created as a private GitHub repository at
50
- `anmho/<service-name>`, and pushed to `origin/main`. If the target directory is
51
- inside an existing git worktree, `service` skips git and GitHub setup so the
52
- parent repository remains in control. Pass `--no-git` to skip all git and GitHub
53
- side effects.
61
+ `https://github.com/anmho/<service_id>`, and pushed to `origin/main`. Go
62
+ services also default their module path to `github.com/anmho/<service_id>`.
63
+ If the target directory is inside an existing git worktree, `service` skips git
64
+ and GitHub setup so the parent repository remains in control. Pass `--no-git`
65
+ to skip all git and GitHub side effects.
54
66
 
55
67
  ## Local Testing
56
68
 
@@ -59,7 +71,8 @@ Without publishing to npm:
59
71
  ```bash
60
72
  bun install
61
73
  npm pack
62
- bunx ./create-svc-*.tgz create my-service
74
+ npm install -g ./create-svc-*.tgz
75
+ service create my-service
63
76
  ```
64
77
 
65
78
  For faster iteration against your working tree:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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/cli.test.ts CHANGED
@@ -2,8 +2,10 @@ import { expect, test } from "bun:test";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import {
4
4
  assertDiscoveryReady,
5
+ formatScaffoldHelp,
5
6
  normalizeValidationResult,
6
7
  parseArgs,
8
+ resolveAutoDeploy,
7
9
  validateTargetRuntimeFramework,
8
10
  validateServiceNameInput,
9
11
  } from "./cli";
@@ -32,12 +34,12 @@ test("assertDiscoveryReady no longer blocks scaffold when remote discovery is un
32
34
 
33
35
  test("parseArgs defaults to microservice and cloudrun target", () => {
34
36
  expect(parseArgs(["launch-api", "--yes"])).toMatchObject({
35
- directory: "launch-api",
37
+ serviceName: "launch-api",
36
38
  profile: "microservice",
37
39
  yes: true,
38
40
  });
39
41
  expect(parseArgs(["launch-api", "--target", "workers", "--yes"])).toMatchObject({
40
- directory: "launch-api",
42
+ serviceName: "launch-api",
41
43
  target: "workers",
42
44
  yes: true,
43
45
  });
@@ -47,6 +49,33 @@ test("parseArgs defaults to microservice and cloudrun target", () => {
47
49
  expect(() => parseArgs(["launch-api", "--profile", "microservice", "--bootstrap"])).toThrow("Unknown argument");
48
50
  });
49
51
 
52
+ test("resolveAutoDeploy defaults to one-shot create and deploy", () => {
53
+ expect(resolveAutoDeploy(undefined)).toBeTrue();
54
+ expect(resolveAutoDeploy(true)).toBeTrue();
55
+ expect(resolveAutoDeploy(false)).toBeFalse();
56
+ });
57
+
58
+ test("parseArgs supports an explicit output directory", () => {
59
+ expect(parseArgs(["launch-api", "--dir", "/tmp/generated-launch-api", "--yes"])).toMatchObject({
60
+ serviceName: "launch-api",
61
+ directory: "/tmp/generated-launch-api",
62
+ yes: true,
63
+ });
64
+ expect(parseArgs(["--dir=/tmp/generated-launch-api", "--yes"])).toMatchObject({
65
+ directory: "/tmp/generated-launch-api",
66
+ yes: true,
67
+ });
68
+ });
69
+
70
+ test("formatScaffoldHelp is compact and starts at usage", () => {
71
+ const help = formatScaffoldHelp();
72
+ expect(help.startsWith("Usage:\n")).toBeTrue();
73
+ expect(help).not.toContain("\n\n\n");
74
+ expect(help).not.toContain("│");
75
+ expect(help).toContain("service create <service_id> [options]");
76
+ expect(help).toContain("--dir <path>");
77
+ });
78
+
50
79
  test("parseArgs rejects the removed app profile", () => {
51
80
  expect(() => parseArgs(["tracker", "--profile=app", "--yes"])).toThrow("app profile has moved");
52
81
  });
package/src/cli.ts CHANGED
@@ -15,8 +15,13 @@ import pc from "picocolors";
15
15
  import { readdirSync } from "node:fs";
16
16
  import { basename, dirname, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { runPostScaffoldFlow } from "./post-scaffold";
19
- import { bootstrapGitHubRepository, buildGitBootstrapConfig, commitAndPushGeneratedArtifacts } from "./git-bootstrap";
18
+ import { buildDeploymentVerificationCommands, runPostScaffoldFlow } from "./post-scaffold";
19
+ import {
20
+ bootstrapGitHubRepository,
21
+ buildGitBootstrapConfig,
22
+ commitAndPushGeneratedArtifacts,
23
+ type GitBootstrapResult,
24
+ } from "./git-bootstrap";
20
25
  import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
21
26
  import {
22
27
  BILLING_ACCOUNT_DEFAULT,
@@ -39,6 +44,7 @@ import {
39
44
  } from "./scaffold";
40
45
 
41
46
  type ParsedArgs = {
47
+ serviceName?: string;
42
48
  directory?: string;
43
49
  target?: DeployTarget;
44
50
  runtime?: Runtime;
@@ -50,8 +56,6 @@ type ParsedArgs = {
50
56
  billingAccount?: string;
51
57
  quotaProjectId?: string;
52
58
  autoDeploy?: boolean;
53
- autoUpdate?: boolean;
54
- noUpdateCheck?: boolean;
55
59
  noGit?: boolean;
56
60
  profile: Profile;
57
61
  yes: boolean;
@@ -74,8 +78,6 @@ export async function run(argv: string[]) {
74
78
  return;
75
79
  }
76
80
 
77
- await maybeCheckForUpdate(args);
78
-
79
81
  intro(`${pc.bold("service")} ${pc.dim("microservice bootstrap")}`);
80
82
 
81
83
  const config = await resolveConfig(args);
@@ -130,27 +132,75 @@ export async function run(argv: string[]) {
130
132
  }
131
133
  }
132
134
 
133
- const isBun = config.runtime === "bun";
134
- outro(
135
- [
136
- `Next: ${pc.cyan(`cd ${config.directory}`)}`,
137
- `Local DB: ${pc.cyan("started by local dev command")}`,
138
- `Migrate: ${pc.cyan(isBun ? "bun run migrate" : "make migrate")}`,
139
- `Local dev: ${pc.cyan(isBun ? "bun run dev" : "make dev")}`,
140
- `Create: ${pc.cyan("service create")}`,
141
- `Deploy: ${pc.cyan("service deploy")}`,
142
- config.git.enabled ? `Repository: ${pc.cyan(`https://github.com/anmho/${config.git.repository}`)}` : undefined,
143
- `Personal env: ${pc.cyan(
144
- `service deploy --environment personal --name ${config.serviceName}`
145
- )}`,
146
- `Production API: ${pc.cyan(`https://${config.apiHostname}`)}`,
147
- ].filter(Boolean).join("\n")
148
- );
135
+ outro(config.autoDeploy ? "Created and deployed" : "Created");
136
+ console.log(formatCompletionSummary(config, targetDir, gitResult));
149
137
  } catch (error) {
150
138
  handleCliError(error);
151
139
  }
152
140
  }
153
141
 
142
+ function formatCompletionSummary(config: ScaffoldConfig, targetDir: string, gitResult: GitBootstrapResult) {
143
+ const isBun = config.runtime === "bun";
144
+ const devCommand = isBun ? "bun run dev" : "make dev";
145
+ const migrateCommand = isBun ? "bun run migrate" : "make migrate";
146
+ const lifecycleCommands: Array<[string, string]> = config.autoDeploy
147
+ ? [
148
+ ["service deploy", "Deploys later changes."],
149
+ [`service deploy --environment personal --name ${config.serviceName}`, "Deploys your personal environment."],
150
+ ]
151
+ : [
152
+ ["service create", "Provisions auth, database, migrations, and the first deploy."],
153
+ ["service deploy", "Deploys later changes."],
154
+ ];
155
+ const repository =
156
+ gitResult.status === "created"
157
+ ? gitResult.url
158
+ : config.git.enabled
159
+ ? `https://github.com/${config.git.owner}/${config.git.repository}`
160
+ : undefined;
161
+
162
+ return [
163
+ "",
164
+ `Success! Created ${config.serviceName} at ${targetDir}`,
165
+ "",
166
+ "Inside that directory, you can run:",
167
+ formatCommand(devCommand, "Starts local development."),
168
+ formatCommand(migrateCommand, "Applies local database migrations."),
169
+ ...lifecycleCommands.map(([command, description]) => formatCommand(command, description)),
170
+ "",
171
+ "Control-plane defaults:",
172
+ ` Auth issuer: https://auth.anmho.com/api/auth`,
173
+ ` Auth resource: api://${config.serviceName}`,
174
+ ` Auth token URL: https://auth.anmho.com/api/auth/oauth2/token`,
175
+ ` Temporal: disabled by default`,
176
+ ` Temporal address: localhost:7233`,
177
+ ` Temporal task queue: ${config.serviceName}`,
178
+ ` Temporal API key secret: ${config.serviceName}-temporal-api-key`,
179
+ config.runtime === "go" ? ` Go module: ${config.modulePath}` : undefined,
180
+ "",
181
+ config.autoDeploy ? "Verified after deploy:" : "After deploy, verify with:",
182
+ ...buildDeploymentVerificationCommands(config).map(formatShellCommand),
183
+ "",
184
+ "We suggest that you begin by typing:",
185
+ "",
186
+ ` cd ${config.directory}`,
187
+ ` ${devCommand}`,
188
+ "",
189
+ repository ? `Repository: ${repository}` : undefined,
190
+ `Production API: https://${config.apiHostname}`,
191
+ ]
192
+ .filter(Boolean)
193
+ .join("\n");
194
+ }
195
+
196
+ function formatCommand(command: string, description: string) {
197
+ return [` ${command}`, ` ${description}`].join("\n");
198
+ }
199
+
200
+ function formatShellCommand(command: { command: string; args: string[] }) {
201
+ return ` ${[command.command, ...command.args].join(" ")}`;
202
+ }
203
+
154
204
  export function parseArgs(argv: string[]): ParsedArgs {
155
205
  const parsed: ParsedArgs = {
156
206
  profile: "microservice",
@@ -164,8 +214,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
164
214
  continue;
165
215
  }
166
216
 
167
- if (!token.startsWith("-") && !parsed.directory) {
168
- parsed.directory = token;
217
+ if (!token.startsWith("-") && !parsed.serviceName) {
218
+ parsed.serviceName = token;
169
219
  continue;
170
220
  }
171
221
 
@@ -188,23 +238,23 @@ export function parseArgs(argv: string[]): ParsedArgs {
188
238
  continue;
189
239
  }
190
240
 
191
- if (token === "--auto-update") {
192
- parsed.autoUpdate = true;
241
+ if (token === "--no-git") {
242
+ parsed.noGit = true;
193
243
  continue;
194
244
  }
195
245
 
196
- if (token === "--no-update-check") {
197
- parsed.noUpdateCheck = true;
246
+ if (token === "--runtime") {
247
+ parsed.runtime = readValue() as Runtime;
198
248
  continue;
199
249
  }
200
250
 
201
- if (token === "--no-git") {
202
- parsed.noGit = true;
251
+ if (token === "--dir") {
252
+ parsed.directory = readValue();
203
253
  continue;
204
254
  }
205
255
 
206
- if (token === "--runtime") {
207
- parsed.runtime = readValue() as Runtime;
256
+ if (token.startsWith("--dir=")) {
257
+ parsed.directory = token.slice("--dir=".length);
208
258
  continue;
209
259
  }
210
260
 
@@ -324,71 +374,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
324
374
  return parsed;
325
375
  }
326
376
 
327
- const CURRENT_VERSION = "0.1.9";
328
- const PACKAGE_NAME = "create-svc";
329
-
330
- async function maybeCheckForUpdate(args: ParsedArgs) {
331
- if (args.noUpdateCheck || shouldSkipUpdateCheck()) {
332
- return;
333
- }
334
-
335
- const latest = await resolveLatestVersion().catch(() => "");
336
- if (!latest || !isVersionGreater(latest, CURRENT_VERSION)) {
337
- return;
338
- }
339
-
340
- const command = `bunx ${PACKAGE_NAME}@latest ${Bun.argv.slice(2).filter((arg) => arg !== "--auto-update").join(" ")}`.trim();
341
- if (!args.autoUpdate) {
342
- log.info(`A newer ${PACKAGE_NAME} is available: ${CURRENT_VERSION} -> ${latest}. Run ${command}`);
343
- return;
344
- }
345
-
346
- const result = Bun.spawnSync(["bunx", `${PACKAGE_NAME}@latest`, ...Bun.argv.slice(2).filter((arg) => arg !== "--auto-update")], {
347
- stdin: "inherit",
348
- stdout: "inherit",
349
- stderr: "inherit",
350
- env: {
351
- ...process.env,
352
- CREATE_SERVICE_NO_UPDATE_CHECK: "1",
353
- },
354
- });
355
- process.exit(result.exitCode);
356
- }
357
-
358
- function shouldSkipUpdateCheck() {
359
- return Boolean(
360
- process.env.CI ||
361
- process.env.CODEX_CI ||
362
- process.env.CREATE_SERVICE_NO_UPDATE_CHECK ||
363
- process.env.BUN_TEST ||
364
- process.env.npm_lifecycle_event
365
- );
366
- }
367
-
368
- async function resolveLatestVersion() {
369
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
370
- signal: AbortSignal.timeout(1_500),
371
- });
372
- if (!response.ok) {
373
- return "";
374
- }
375
- const payload = (await response.json()) as { version?: string };
376
- return payload.version?.trim() ?? "";
377
- }
378
-
379
- function isVersionGreater(left: string, right: string) {
380
- const parse = (value: string) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
381
- const [leftMajor = 0, leftMinor = 0, leftPatch = 0] = parse(left);
382
- const [rightMajor = 0, rightMinor = 0, rightPatch = 0] = parse(right);
383
- return (
384
- leftMajor > rightMajor ||
385
- (leftMajor === rightMajor && leftMinor > rightMinor) ||
386
- (leftMajor === rightMajor && leftMinor === rightMinor && leftPatch > rightPatch)
387
- );
388
- }
389
-
390
377
  export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
391
- const inferredName = slugify(basename(args.directory ?? "my-service"));
378
+ const inferredName = slugify(args.serviceName ?? basename(args.directory ?? "my-service"));
392
379
  const serviceName = args.yes
393
380
  ? inferredName
394
381
  : await promptText("Service name", inferredName, (value) => validateServiceNameInput(value, args.directory));
@@ -704,11 +691,11 @@ function chooseBillingAccount(input: string | undefined, accounts: BillingAccoun
704
691
  return accounts[0]?.name ?? BILLING_ACCOUNT_DEFAULT;
705
692
  }
706
693
 
707
- function resolveAutoDeploy(value: boolean | undefined) {
694
+ export function resolveAutoDeploy(value: boolean | undefined) {
708
695
  if (value !== undefined) {
709
696
  return value;
710
697
  }
711
- return false;
698
+ return true;
712
699
  }
713
700
 
714
701
  async function promptText(
@@ -823,29 +810,37 @@ export function validateServiceNameInput(rawValue: string, directoryOverride?: s
823
810
  }
824
811
 
825
812
  function printHelp() {
826
- log.message(`
827
- Usage:
828
- service create [service_id] [options]
829
-
830
- Options:
831
- --target <cloudrun|workers> Deploy target for the generated service
832
- --profile <microservice> Compatibility no-op; app workspaces moved out
833
- --runtime <go|bun> Runtime scaffold to generate
834
- --framework <name> Framework for the selected runtime
835
- --module-path <path> Go module path for generated Go scaffolds
836
- --project-mode <mode> create_new or use_existing
837
- --project-id <id> GCP project id
838
- --billing-account <name> Billing account resource name
839
- --quota-project <id> Billing quota project for gcloud calls
840
- --region <region> Cloud Run region
841
- --auto-deploy Run service create and service deploy after scaffold
842
- --no-auto-deploy Scaffold only
843
- --no-git Skip git init, initial commit, GitHub repo creation, and push
844
- --auto-update Re-run through create-svc@latest when a newer version exists
845
- --no-update-check Skip the best-effort npm update check
846
- --yes, -y Accept defaults without prompts
847
- --help, -h Show this message
848
- `);
813
+ console.log(formatScaffoldHelp());
814
+ }
815
+
816
+ export function formatScaffoldHelp() {
817
+ return [
818
+ "Usage:",
819
+ " service create <service_id> [options]",
820
+ "",
821
+ "Examples:",
822
+ " service create waitlist-api --target cloudrun --runtime bun --framework hono",
823
+ " service create waitlist-api --auto-deploy",
824
+ "",
825
+ "Options:",
826
+ " --dir <path> Output directory; defaults to ./<service_id>",
827
+ " --target <cloudrun|workers> Deploy target for the generated service",
828
+ " --runtime <go|bun> Runtime scaffold to generate",
829
+ " --framework <name> Framework for the selected runtime",
830
+ " --module-path <path> Go module path for generated Go scaffolds",
831
+ " --project-mode <mode> create_new or use_existing",
832
+ " --project-id <id> GCP project id",
833
+ " --billing-account <name> Billing account resource name",
834
+ " --quota-project <id> Billing quota project for gcloud calls",
835
+ " --region <region> Cloud Run region",
836
+ " --auto-deploy Scaffold, run service create, then service deploy (default)",
837
+ " --no-auto-deploy Scaffold only",
838
+ " --no-git Skip default private GitHub repo: anmho/<service_id>",
839
+ " --yes, -y Accept defaults without prompts",
840
+ " --help, -h Show this message",
841
+ "",
842
+ "Inside a generated service repo, run service --help for create, deploy, doctor, auth, and sdk commands.",
843
+ ].join("\n");
849
844
  }
850
845
 
851
846
  function matchesProject(project: GcpProject, query: string) {
@@ -30,19 +30,21 @@ export async function bootstrapGitHubRepository(targetDir: string, config: GitBo
30
30
  return { status: "skipped-existing-worktree", root: existingRoot };
31
31
  }
32
32
 
33
- run(["git", "--version"], targetDir, "git is required to initialize the generated repository");
34
- run(["gh", "--version"], targetDir, "GitHub CLI `gh` is required to create the generated repository");
35
- run(["gh", "auth", "status"], targetDir, "Authenticate GitHub CLI with `gh auth login` before creating the repository");
33
+ run(["git", "--version"], targetDir, "git is required to initialize the generated repository", { quiet: true });
34
+ run(["gh", "--version"], targetDir, "GitHub CLI `gh` is required to create the generated repository", { quiet: true });
35
+ run(["gh", "auth", "status"], targetDir, "Authenticate GitHub CLI with `gh auth login` before creating the repository", { quiet: true });
36
36
 
37
- run(["git", "init", "-b", "main"], targetDir);
37
+ run(["git", "init", "-b", "main", "--quiet"], targetDir);
38
38
  run(["git", "add", "."], targetDir);
39
39
 
40
40
  if (hasStagedChanges(targetDir)) {
41
- run(["git", "commit", "-m", "Initial commit"], targetDir);
41
+ run(["git", "commit", "--quiet", "-m", "Initial commit"], targetDir);
42
42
  }
43
43
 
44
44
  const repository = `${config.owner}/${config.repository}`;
45
- run(["gh", "repo", "create", repository, "--private", "--source", ".", "--remote", "origin", "--push"], targetDir);
45
+ run(["gh", "repo", "create", repository, "--private", "--source", ".", "--remote", "origin", "--push"], targetDir, undefined, {
46
+ quiet: true,
47
+ });
46
48
 
47
49
  return {
48
50
  status: "created",
@@ -55,8 +57,8 @@ export function commitAndPushGeneratedArtifacts(targetDir: string, message: stri
55
57
  if (!hasStagedChanges(targetDir)) {
56
58
  return { committed: false };
57
59
  }
58
- run(["git", "commit", "-m", message], targetDir);
59
- run(["git", "push"], targetDir);
60
+ run(["git", "commit", "--quiet", "-m", message], targetDir);
61
+ run(["git", "push", "--quiet"], targetDir, undefined, { quiet: true });
60
62
  return { committed: true };
61
63
  }
62
64
 
@@ -94,17 +96,18 @@ function hasStagedChanges(cwd: string) {
94
96
  return result.exitCode === 1;
95
97
  }
96
98
 
97
- function run(command: string[], cwd: string, message?: string) {
99
+ function run(command: string[], cwd: string, message?: string, options: { quiet?: boolean } = {}) {
98
100
  const result = Bun.spawnSync(command, {
99
101
  cwd,
100
102
  stdin: "inherit",
101
- stdout: "inherit",
103
+ stdout: options.quiet ? "pipe" : "inherit",
102
104
  stderr: "pipe",
103
105
  });
104
106
  if (result.exitCode === 0) {
105
107
  return;
106
108
  }
107
109
 
110
+ const output = result.stdout?.toString().trim() ?? "";
108
111
  const detail = result.stderr.toString().trim();
109
- throw new Error([message, `Command failed: ${command.join(" ")}`, detail].filter(Boolean).join("\n"));
112
+ throw new Error([message, `Command failed: ${command.join(" ")}`, output, detail].filter(Boolean).join("\n"));
110
113
  }
@@ -11,7 +11,7 @@ test("deriveDefaults uses the service name for project, repo, and database namin
11
11
  neonDatabaseName: "edge_api",
12
12
  localDatabasePort: deriveLocalPostgresPort("edge-api"),
13
13
  apiHostname: "api.edge-api.anmho.com",
14
- modulePath: "example.com/edge-api",
14
+ modulePath: "github.com/anmho/edge-api",
15
15
  });
16
16
  });
17
17
 
package/src/naming.ts CHANGED
@@ -94,7 +94,7 @@ export function deriveDefaults(serviceName: string) {
94
94
  neonDatabaseName: compactDatabaseName(normalizedServiceName),
95
95
  localDatabasePort: deriveLocalPostgresPort(normalizedServiceName),
96
96
  apiHostname: `api.${normalizedServiceName}.anmho.com`,
97
- modulePath: `example.com/${normalizedServiceName}`,
97
+ modulePath: `github.com/anmho/${normalizedServiceName}`,
98
98
  };
99
99
  }
100
100
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildPostScaffoldCommands } from "./post-scaffold";
2
+ import { buildDeploymentVerificationCommands, buildPostScaffoldCommands } from "./post-scaffold";
3
3
 
4
4
  describe("buildPostScaffoldCommands", () => {
5
5
  test("runs create and deploy for HTTP services", () => {
@@ -17,3 +17,19 @@ describe("buildPostScaffoldCommands", () => {
17
17
  ]);
18
18
  });
19
19
  });
20
+
21
+ describe("buildDeploymentVerificationCommands", () => {
22
+ test("uses curl health checks for HTTP services", () => {
23
+ expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
24
+ { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/healthz"] },
25
+ { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/readyz"] },
26
+ ]);
27
+ });
28
+
29
+ test("uses grpcurl for Go ConnectRPC services", () => {
30
+ expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "connectrpc", runtime: "go" })).toContainEqual({
31
+ command: "grpcurl",
32
+ args: ["api.launch.anmho.com:443", "list"],
33
+ });
34
+ });
35
+ });
@@ -4,6 +4,7 @@ type CommandOptions = {
4
4
  cwd: string;
5
5
  allowFailure?: boolean;
6
6
  input?: string;
7
+ quiet?: boolean;
7
8
  };
8
9
 
9
10
  type CommandResult = {
@@ -26,12 +27,32 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
26
27
  for (const command of buildPostScaffoldCommands(config)) {
27
28
  run(command.command, command.args, { cwd });
28
29
  }
29
- return { message: "Dependencies installed, service created, and service deployed" };
30
+ for (const command of buildDeploymentVerificationCommands(config)) {
31
+ run(command.command, command.args, { cwd, quiet: true });
32
+ }
33
+ return { message: "Dependencies installed, service created, service deployed, and production health verified" };
30
34
  }
31
35
 
32
36
  return { message: "Backend package generated" };
33
37
  }
34
38
 
39
+ export function buildDeploymentVerificationCommands(
40
+ config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">
41
+ ): PostScaffoldCommand[] {
42
+ const origin = `https://${config.apiHostname}`;
43
+ return [
44
+ { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/healthz`] },
45
+ { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/readyz`] },
46
+ ...(config.framework === "connectrpc"
47
+ ? [
48
+ config.runtime === "go"
49
+ ? { command: "grpcurl", args: [`${config.apiHostname}:443`, "list"] }
50
+ : { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/debug/connectrpc`] },
51
+ ]
52
+ : []),
53
+ ];
54
+ }
55
+
35
56
  export function buildPostScaffoldCommands(config: Pick<ScaffoldConfig, "framework">): PostScaffoldCommand[] {
36
57
  return [
37
58
  ...(config.framework === "connectrpc" ? [{ command: "bun", args: ["run", "service", "--", "sdk", "build"] }] : []),
@@ -56,8 +77,8 @@ function run(command: string, args: string[], options: CommandOptions): CommandR
56
77
  cwd: options.cwd,
57
78
  env: process.env,
58
79
  stdin: options.input === undefined ? undefined : encoder.encode(options.input),
59
- stdout: options.allowFailure ? "pipe" : "inherit",
60
- stderr: options.allowFailure ? "pipe" : "inherit",
80
+ stdout: options.allowFailure || options.quiet ? "pipe" : "inherit",
81
+ stderr: options.allowFailure || options.quiet ? "pipe" : "inherit",
61
82
  });
62
83
 
63
84
  const stdout = result.stdout ? decoder.decode(result.stdout).trim() : "";
@@ -9,7 +9,7 @@ function baseConfig(overrides: Partial<ScaffoldConfig> = {}): ScaffoldConfig {
9
9
  return {
10
10
  directory: "svc",
11
11
  serviceName: "dns-api",
12
- modulePath: "example.com/dns-api",
12
+ modulePath: "github.com/anmho/dns-api",
13
13
  target: "cloudrun",
14
14
  runtime: "bun",
15
15
  framework: "hono",
@@ -156,11 +156,17 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
156
156
 
157
157
  if (variant.runtime === "go") {
158
158
  const goMod = await Bun.file(join(generatedRoot, "go.mod")).text();
159
- expect(goMod).toContain("module example.com/dns-api");
160
- expect(goMod).not.toContain("module github.com/anmho/dns-api");
159
+ const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
160
+ expect(goMod).toContain("module github.com/anmho/dns-api");
161
+ expect(goMod).not.toContain("module example.com/dns-api");
162
+ expect(packageJson).toContain('"dev": "make dev"');
163
+ expect(packageJson).toContain('"migrate": "make migrate"');
164
+ expect(packageJson).toContain('"create": "bun run ./scripts/cloudrun/cli.ts create"');
165
+ expect(packageJson).toContain('"deploy": "bun run ./scripts/cloudrun/cli.ts deploy"');
166
+ expect(packageJson).toContain('"destroy": "bun run ./scripts/cloudrun/cli.ts destroy"');
161
167
 
162
168
  const mainGo = await Bun.file(join(generatedRoot, "cmd", "server", "main.go")).text();
163
- expect(mainGo).toContain("example.com/dns-api");
169
+ expect(mainGo).toContain("github.com/anmho/dns-api");
164
170
  if (variant.framework === "connectrpc") {
165
171
  expect(goMod).toContain("connectrpc.com/connect");
166
172
  expect(mainGo).toContain("NewWaitlistService");
@@ -202,7 +208,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
202
208
  expect(packageJson).toContain('"auth": "bun run ./scripts/cloudrun/cli.ts auth"');
203
209
  expect(packageJson).toContain('"destroy": "bun run ./scripts/cloudrun/cli.ts destroy"');
204
210
  const serviceCli = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cli.ts")).text();
205
- expect(serviceCli).toContain("service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk>");
211
+ expect(serviceCli).toContain("service <command> [args]");
212
+ expect(serviceCli).toContain("Provision auth, database, migrations, and first deploy");
206
213
  expect(serviceCli).toContain("assertServiceNameAvailable(config.serviceName)");
207
214
  expect(serviceCli).toContain("ensureAuthResourceServer");
208
215
  expect(serviceCli).toContain('["resources", "push", "--path", "./grafana"]');
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { findGeneratedServiceRoot, normalizeScaffoldArgs } from "./service";
5
+ import { findGeneratedServiceRoot, generatedDependenciesInstalled, normalizeScaffoldArgs } from "./service";
6
6
 
7
7
  test("normalizeScaffoldArgs treats service create as the scaffold command outside a service repo", () => {
8
8
  expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
@@ -28,3 +28,14 @@ test("findGeneratedServiceRoot detects generated service context from nested dir
28
28
  expect(findGeneratedServiceRoot(nested)).toBe(serviceRoot);
29
29
  expect(findGeneratedServiceRoot(root)).toBeUndefined();
30
30
  });
31
+
32
+ test("generatedDependenciesInstalled requires node_modules when package.json exists", async () => {
33
+ const root = await mkdtemp(join(tmpdir(), "create-svc-generated-deps-"));
34
+ expect(generatedDependenciesInstalled(root)).toBeTrue();
35
+
36
+ await writeFile(join(root, "package.json"), "{}");
37
+ expect(generatedDependenciesInstalled(root)).toBeFalse();
38
+
39
+ await mkdir(join(root, "node_modules"));
40
+ expect(generatedDependenciesInstalled(root)).toBeTrue();
41
+ });
package/src/service.ts CHANGED
@@ -48,6 +48,8 @@ function isGeneratedServiceRoot(path: string) {
48
48
  }
49
49
 
50
50
  function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
51
+ ensureGeneratedDependencies(serviceRoot);
52
+
51
53
  const cliPath = existsSync(join(serviceRoot, "scripts", "cloudrun", "cli.ts"))
52
54
  ? "./scripts/cloudrun/cli.ts"
53
55
  : "./scripts/workers/cli.ts";
@@ -63,3 +65,27 @@ function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
63
65
  process.exit(result.exitCode || 1);
64
66
  }
65
67
  }
68
+
69
+ export function generatedDependenciesInstalled(serviceRoot: string) {
70
+ return !existsSync(join(serviceRoot, "package.json")) || existsSync(join(serviceRoot, "node_modules"));
71
+ }
72
+
73
+ function ensureGeneratedDependencies(serviceRoot: string) {
74
+ if (generatedDependenciesInstalled(serviceRoot)) {
75
+ return;
76
+ }
77
+
78
+ const result = Bun.spawnSync(["bun", "install", "--silent"], {
79
+ cwd: serviceRoot,
80
+ env: process.env,
81
+ stdin: "inherit",
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ });
85
+
86
+ if (!result.success) {
87
+ const output = [result.stdout.toString().trim(), result.stderr.toString().trim()].filter(Boolean).join("\n");
88
+ console.error(["Failed to install generated service dependencies with bun install --silent", output].filter(Boolean).join("\n"));
89
+ process.exit(result.exitCode || 1);
90
+ }
91
+ }
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, upsertVaultSecretFields } from "./vault";
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 field = options.field?.trim() ?? "value";
38
- const payload = await readVaultSecretData(options);
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 normalizedMount = mount.replace(/^\/+|\/+$/g, "");
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
- export async function upsertVaultSecretFields(options: VaultWriteOptions) {
56
- const connection = await resolveVaultConnection(options);
57
- const url = vaultKv2Url(connection);
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 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
- });
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
- "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,
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
- 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");
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
- 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()]));
65
+ return value;
128
66
  }
129
67
 
130
68
  async function resolveVaultToken() {
@@ -27,7 +27,7 @@ async function main(argv = Bun.argv.slice(2)) {
27
27
  const [command, ...rest] = argv;
28
28
 
29
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]");
30
+ console.log(formatHelp());
31
31
  return;
32
32
  }
33
33
 
@@ -92,7 +92,26 @@ async function main(argv = Bun.argv.slice(2)) {
92
92
  return;
93
93
  }
94
94
 
95
- throw new Error("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
95
+ throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
96
+ }
97
+
98
+ function formatHelp() {
99
+ return [
100
+ "Usage:",
101
+ " service <command> [args]",
102
+ "",
103
+ "Commands:",
104
+ " create Provision auth, database, migrations, and first deploy",
105
+ " deploy Deploy the current service",
106
+ " migrate Apply database migrations",
107
+ " seed Run the seed script when configured",
108
+ " doctor Check local tools and cloud access",
109
+ " auth Manage auth resource server and clients",
110
+ " sdk Build or publish generated SDK artifacts",
111
+ " dns Repair or inspect DNS mappings",
112
+ " dashboards Publish Grafana resources",
113
+ " destroy Remove service-managed cloud resources",
114
+ ].join("\n");
96
115
  }
97
116
 
98
117
  function runLanguageTask(task: "migrate", env?: Record<string, string | undefined>) {
@@ -18,7 +18,7 @@ async function main(argv = Bun.argv.slice(2)) {
18
18
  const [command, ...rest] = argv;
19
19
 
20
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]");
21
+ console.log(formatHelp());
22
22
  return;
23
23
  }
24
24
 
@@ -87,7 +87,25 @@ async function main(argv = Bun.argv.slice(2)) {
87
87
  throw new Error("SDK commands are only available for ConnectRPC services");
88
88
  }
89
89
 
90
- throw new Error("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
90
+ throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
91
+ }
92
+
93
+ function formatHelp() {
94
+ return [
95
+ "Usage:",
96
+ " service <command> [args]",
97
+ "",
98
+ "Commands:",
99
+ " create Provision auth, database, Hyperdrive, and first deploy",
100
+ " deploy Deploy the Worker",
101
+ " migrate Apply database schema",
102
+ " seed Report seed status",
103
+ " doctor Check local tools and cloud access",
104
+ " auth Manage auth resource server and clients",
105
+ " dns Show Workers custom-domain configuration",
106
+ " dashboards Publish Grafana resources",
107
+ " destroy Remove service-managed Worker resources",
108
+ ].join("\n");
91
109
  }
92
110
 
93
111
  function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
@@ -6,9 +6,17 @@
6
6
  "service": "./scripts/cloudrun/cli.ts"
7
7
  },
8
8
  "scripts": {
9
+ "dev": "make dev",
9
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
+ "migrate": "make migrate",
12
+ "gen": "make gen",
13
+ "lint": "make lint",
14
+ "test": "make test",
15
+ "create": "bun run ./scripts/cloudrun/cli.ts create",
16
+ "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
10
17
  "auth": "bun run ./scripts/cloudrun/cli.ts auth",
11
- "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards"
18
+ "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards",
19
+ "destroy": "bun run ./scripts/cloudrun/cli.ts destroy"
12
20
  },
13
21
  "dependencies": {
14
22
  "@anmho/authctl": "0.1.1",
@@ -6,9 +6,17 @@
6
6
  "service": "./scripts/cloudrun/cli.ts"
7
7
  },
8
8
  "scripts": {
9
+ "dev": "make dev",
9
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
+ "migrate": "make migrate",
12
+ "gen": "make gen",
13
+ "lint": "make lint",
14
+ "test": "make test",
15
+ "create": "bun run ./scripts/cloudrun/cli.ts create",
16
+ "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
10
17
  "auth": "bun run ./scripts/cloudrun/cli.ts auth",
11
- "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards"
18
+ "dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards",
19
+ "destroy": "bun run ./scripts/cloudrun/cli.ts destroy"
12
20
  },
13
21
  "dependencies": {
14
22
  "@anmho/authctl": "0.1.1",