create-svc 0.1.32 → 0.1.34

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
@@ -41,7 +41,7 @@ service deploy
41
41
  To install from npm:
42
42
 
43
43
  ```bash
44
- bun install -g create-svc
44
+ npm install -g create-svc@latest
45
45
  ```
46
46
 
47
47
  For the strict one-command production path:
@@ -51,8 +51,8 @@ service create my-service --yes
51
51
  ```
52
52
 
53
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.
54
+ repo's `service create`, deploys once, verifies production, starts local dev,
55
+ and verifies local. Pass `--no-auto-deploy` for scaffold-only generation.
56
56
 
57
57
  `--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
58
58
 
@@ -111,6 +111,7 @@ bun run lint
111
111
  bun run test
112
112
  service create
113
113
  service deploy
114
+ service dev down
114
115
  service destroy
115
116
  ```
116
117
 
@@ -124,10 +125,12 @@ make lint
124
125
  make test
125
126
  service create
126
127
  service deploy
128
+ service dev down
127
129
  service destroy
128
130
  ```
129
131
 
130
132
  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.
133
+ `service destroy --force` also stops local dev and runs Docker Compose cleanup for generated Cloud Run services.
131
134
 
132
135
  After `service create` has provisioned auth, the generated repo can mint a
133
136
  client-credentials bearer token for smoke checks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
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.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  bootstrapGitHubRepository,
9
9
  buildGitBootstrapConfig,
10
10
  commitAndPushGeneratedArtifacts,
11
+ markGitHubRepositoryDeleteOnDestroy,
11
12
  type GitBootstrapResult,
12
13
  } from "./git-bootstrap";
13
14
  import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
@@ -111,6 +112,7 @@ export async function run(argv: string[]) {
111
112
  gitSpinner.start("Preparing git repository");
112
113
  const gitResult = await bootstrapGitHubRepository(targetDir, config.git);
113
114
  if (gitResult.status === "created") {
115
+ await markGitHubRepositoryDeleteOnDestroy(targetDir);
114
116
  gitSpinner.stop(`GitHub repository created: ${gitResult.url}`);
115
117
  } else if (gitResult.status === "skipped-existing-worktree") {
116
118
  gitSpinner.stop(`Existing git worktree detected: ${gitResult.root}`);
@@ -136,6 +138,11 @@ export async function run(argv: string[]) {
136
138
  const result = commitAndPushGeneratedArtifacts(targetDir, "Record generated deployment artifacts");
137
139
  publishSpinner.stop(result.committed ? "Generated artifacts committed and pushed" : "Generated artifacts already committed");
138
140
  }
141
+ } else if (gitResult.status === "created") {
142
+ const publishSpinner = spinner();
143
+ publishSpinner.start("Publishing generated git ownership");
144
+ const result = commitAndPushGeneratedArtifacts(targetDir, "Record generated GitHub ownership");
145
+ publishSpinner.stop(result.committed ? "GitHub ownership committed and pushed" : "GitHub ownership already committed");
139
146
  }
140
147
 
141
148
  outro(config.autoDeploy ? "Created and deployed" : "Created");
@@ -1184,7 +1191,7 @@ export function formatScaffoldHelp() {
1184
1191
  " --billing-account <name> Billing account resource name",
1185
1192
  " --quota-project <id> Billing quota project for gcloud calls",
1186
1193
  " --region <region> Cloud Run region",
1187
- " --auto-deploy Scaffold, run service create, then service deploy (default)",
1194
+ " --auto-deploy Scaffold, run service create, verify prod/local, and start local dev (default)",
1188
1195
  " --no-auto-deploy Scaffold only",
1189
1196
  " --no-git Skip default private GitHub repo: anmho/<service_id>",
1190
1197
  " --yes, -y Accept defaults without prompts",
@@ -1,8 +1,8 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, realpath } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { buildGitBootstrapConfig, findExistingGitWorktree } from "./git-bootstrap";
5
+ import { buildGitBootstrapConfig, findExistingGitWorktree, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
6
6
 
7
7
  test("buildGitBootstrapConfig defaults to anmho private repo creation", () => {
8
8
  expect(buildGitBootstrapConfig("launch-api", undefined)).toEqual({
@@ -28,6 +28,15 @@ test("findExistingGitWorktree detects parent repositories", async () => {
28
28
  expect(findExistingGitWorktree(join(root, "apps", "launch-api"))).toBe(await realpath(root));
29
29
  });
30
30
 
31
+ test("markGitHubRepositoryDeleteOnDestroy records generated repo ownership", async () => {
32
+ const root = await mkdtemp(join(tmpdir(), "create-svc-git-"));
33
+ await writeFile(join(root, "service.jsonc"), '{\n "git": { "delete_on_destroy": false }\n}\n');
34
+
35
+ await markGitHubRepositoryDeleteOnDestroy(root);
36
+
37
+ expect(await Bun.file(join(root, "service.jsonc")).text()).toContain('"delete_on_destroy": true');
38
+ });
39
+
31
40
  function run(command: string[], cwd: string) {
32
41
  const result = Bun.spawnSync(command, {
33
42
  cwd,
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
4
 
4
5
  export type GitBootstrapConfig = {
@@ -62,6 +63,16 @@ export function commitAndPushGeneratedArtifacts(targetDir: string, message: stri
62
63
  return { committed: true };
63
64
  }
64
65
 
66
+ export async function markGitHubRepositoryDeleteOnDestroy(targetDir: string) {
67
+ const path = `${targetDir}/service.jsonc`;
68
+ const text = await readFile(path, "utf8");
69
+ const updated = text.replace('"delete_on_destroy": false', '"delete_on_destroy": true');
70
+ if (updated === text) {
71
+ throw new Error("service.jsonc does not contain a delete_on_destroy marker");
72
+ }
73
+ await writeFile(path, updated);
74
+ }
75
+
65
76
  export function findExistingGitWorktree(targetDir: string) {
66
77
  const cwd = existingPath(targetDir);
67
78
  const result = Bun.spawnSync(["git", "-C", cwd, "rev-parse", "--show-toplevel"], {
@@ -2,25 +2,22 @@ import { describe, expect, test } from "bun:test";
2
2
  import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, buildPostScaffoldCommands } from "./post-scaffold";
3
3
 
4
4
  describe("buildPostScaffoldCommands", () => {
5
- test("runs create and deploy for HTTP services", () => {
5
+ test("runs create for HTTP services", () => {
6
6
  expect(buildPostScaffoldCommands({ framework: "hono" })).toEqual([
7
7
  { command: "service", args: ["create"] },
8
- { command: "service", args: ["deploy"] },
9
8
  ]);
10
9
  });
11
10
 
12
- test("builds SDK artifacts before create and deploy for ConnectRPC services", () => {
11
+ test("builds SDK artifacts before create for ConnectRPC services", () => {
13
12
  expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
14
13
  { command: "service", args: ["sdk", "build"] },
15
14
  { command: "service", args: ["create"] },
16
- { command: "service", args: ["deploy"] },
17
15
  ]);
18
16
  });
19
17
 
20
18
  test("uses the workers service CLI for workers services", () => {
21
19
  expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
22
20
  { command: "service", args: ["create"] },
23
- { command: "service", args: ["deploy"] },
24
21
  ]);
25
22
  });
26
23
  });
@@ -38,7 +38,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
38
38
  for (const command of buildLocalVerificationCommands(config)) {
39
39
  runWithRetries(command, { cwd, quiet: true }, 18, 5_000);
40
40
  }
41
- return { message: "Dependencies installed, service created, service deployed, production verified, and local dev started" };
41
+ return { message: "Dependencies installed, service created, production verified, and local dev started" };
42
42
  }
43
43
 
44
44
  return { message: "Backend package generated" };
@@ -225,7 +225,6 @@ export function buildPostScaffoldCommands(
225
225
  return [
226
226
  ...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "service", args: ["sdk", "build"] }] : []),
227
227
  { command: "service", args: ["create"] },
228
- { command: "service", args: ["deploy"] },
229
228
  ];
230
229
  }
231
230
 
@@ -71,6 +71,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
71
71
  expect(serviceConfig).toContain('"project_mode": "create_new"');
72
72
  expect(serviceConfig).toContain('"quota_project_id": "anmho-infra-prod"');
73
73
  expect(serviceConfig).toContain('"jwks_url": "https://auth.anmho.com/api/auth/jwks"');
74
+ expect(serviceConfig).toContain('"git": {');
75
+ expect(serviceConfig).toContain('"repository": "dns-api"');
76
+ expect(serviceConfig).toContain('"delete_on_destroy": false');
74
77
  expect(serviceConfig).toContain('"project_id": ""');
75
78
  expect(serviceConfig).toContain('"base_branch_id": ""');
76
79
  expect(serviceConfig).toContain('"base_branch_name": "main"');
package/src/scaffold.ts CHANGED
@@ -209,6 +209,9 @@ function buildReplacements(config: ScaffoldConfig) {
209
209
  RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
210
210
  API_HOSTNAME: config.apiHostname,
211
211
  API_BASE_DOMAIN: "anmho.com",
212
+ GIT_ENABLED: String(config.git.enabled),
213
+ GIT_OWNER: config.git.owner,
214
+ GIT_REPOSITORY: config.git.repository,
212
215
  AUTH_ISSUER: authIssuer,
213
216
  AUTH_AUDIENCE: authAudience,
214
217
  AUTH_JWKS_URL: authJwksUrl,
@@ -221,6 +224,7 @@ function buildReplacements(config: ScaffoldConfig) {
221
224
  COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
222
225
  COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
223
226
  COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
227
+ COMMAND_DEV_DOWN: "service dev down",
224
228
  COMMAND_BOOTSTRAP: "service create",
225
229
  COMMAND_DEPLOY: "service deploy",
226
230
  COMMAND_AUTH_RESOURCE: "service auth resource-server",
@@ -1,22 +1,30 @@
1
1
  import { config } from "./config";
2
- import { ensureDatabase, getConnectionUri, resolveNeonConfig } from "./neon";
2
+ import { ensureDatabase, getConnectionUri, resolveNeonConfig, type ResolvedNeonConfig } from "./neon";
3
3
  import {
4
4
  addSecretVersion,
5
5
  attachBilling,
6
6
  ensureArtifactRepository,
7
7
  ensureProject,
8
8
  ensureProjectRole,
9
+ ensureRequiredApis,
9
10
  ensureSecretAccessor,
10
11
  ensureServiceAccount,
11
- gcloud,
12
12
  requireCommand,
13
13
  requireGcloudAuth,
14
14
  resolveDeploymentTarget,
15
15
  resolveTemporalRuntimeConfig,
16
16
  runMain,
17
17
  runStep,
18
+ type DeploymentTarget,
18
19
  } from "./lib";
19
20
 
21
+ export type BootstrapResult = {
22
+ target: DeploymentTarget;
23
+ neon: ResolvedNeonConfig;
24
+ databaseUrl: string;
25
+ artifactRepositoryReady: boolean;
26
+ };
27
+
20
28
  export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
21
29
  requireCommand("gcloud");
22
30
  requireGcloudAuth();
@@ -39,7 +47,7 @@ export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
39
47
  const target = resolveDeploymentTarget("main");
40
48
  await runStep("Ensuring Neon database", () => ensureDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
41
49
 
42
- await runStep("Publishing database secret", async () => {
50
+ const databaseUrl = await runStep("Publishing database secret", async () => {
43
51
  const connectionUri = await getConnectionUri(
44
52
  neon.projectId,
45
53
  neon.baseBranchId,
@@ -48,15 +56,25 @@ export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
48
56
  );
49
57
  addSecretVersion(target.databaseSecretName, connectionUri);
50
58
  ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
59
+ return connectionUri;
51
60
  });
52
61
 
53
- await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
62
+ if (shouldPublishTemporalSecrets()) {
63
+ await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
64
+ }
65
+
66
+ return {
67
+ target,
68
+ neon,
69
+ databaseUrl,
70
+ artifactRepositoryReady: true,
71
+ } satisfies BootstrapResult;
54
72
  }
55
73
 
56
74
  export async function prepareGcpProject() {
57
75
  await runStep("Ensuring GCP project", () => ensureProject());
58
76
  await runStep("Attaching billing", () => attachBilling());
59
- await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
77
+ await runStep("Enabling required GCP APIs", () => ensureRequiredApis());
60
78
  }
61
79
 
62
80
  function publishTemporalSecrets() {
@@ -71,6 +89,11 @@ function publishTemporalSecrets() {
71
89
  return temporal.apiKeySecretName;
72
90
  }
73
91
 
92
+ function shouldPublishTemporalSecrets() {
93
+ const temporal = resolveTemporalRuntimeConfig();
94
+ return Boolean(process.env.TEMPORAL_API_KEY?.trim() && temporal.apiKeySecretName);
95
+ }
96
+
74
97
  if (import.meta.main) {
75
98
  await runMain("Bootstrap", async () => {
76
99
  await bootstrap();
@@ -1,5 +1,6 @@
1
1
  import { confirm, isCancel, log } from "@clack/prompts";
2
2
  import { deleteAuthResourceServer } from "../authctl";
3
+ import { buildLocalDevCleanupPlan, stopLocalDev } from "../local-dev";
3
4
  import { config } from "./config";
4
5
  import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
5
6
  import {
@@ -45,6 +46,7 @@ type DestroyPlan = {
45
46
  resources: PlannedResource[];
46
47
  skipped: PlannedResource[];
47
48
  blockers: string[];
49
+ githubRepository?: string;
48
50
  hasProductionDomainMapping: boolean;
49
51
  serviceNames: string[];
50
52
  secretNames: string[];
@@ -69,6 +71,12 @@ export async function cleanup(args = Bun.argv.slice(2)) {
69
71
 
70
72
  await requireDestroyConfirmation(options.force);
71
73
 
74
+ await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
75
+
76
+ if (plan.githubRepository) {
77
+ await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
78
+ }
79
+
72
80
  await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
73
81
 
74
82
  if (plan.hasProductionDomainMapping) {
@@ -125,11 +133,14 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
125
133
  ],
126
134
  skipped: [],
127
135
  blockers: [],
136
+ githubRepository: undefined,
128
137
  hasProductionDomainMapping: false,
129
138
  serviceNames: [],
130
139
  secretNames: [],
131
140
  };
132
141
 
142
+ planGitHubRepository(plan);
143
+ await planLocalDev(plan);
133
144
  planProductionDomainMapping(plan);
134
145
  planCloudRunServices(plan);
135
146
  planSecrets(plan);
@@ -143,6 +154,51 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
143
154
  return plan;
144
155
  }
145
156
 
157
+ async function planLocalDev(plan: DestroyPlan) {
158
+ const localDev = await buildLocalDevCleanupPlan({ dockerCompose: true });
159
+ for (const resource of localDev.resources) {
160
+ plan.resources.push({ label: resource, detail: "local" });
161
+ }
162
+ for (const skipped of localDev.skipped) {
163
+ plan.skipped.push({ label: skipped, detail: "local" });
164
+ }
165
+ }
166
+
167
+ function planGitHubRepository(plan: DestroyPlan) {
168
+ const repository = `${config.git.owner}/${config.git.repository}`;
169
+ if (!config.git.deleteOnDestroy) {
170
+ plan.skipped.push({
171
+ label: `GitHub repository ${repository}`,
172
+ detail: config.git.enabled ? "not created by this service CLI run" : "git disabled",
173
+ });
174
+ return;
175
+ }
176
+
177
+ if (!Bun.which("gh")) {
178
+ plan.blockers.push(`GitHub repository ${repository}: missing required command gh`);
179
+ return;
180
+ }
181
+
182
+ const auth = run("gh", ["auth", "status"], { allowFailure: true });
183
+ if (!auth.success) {
184
+ plan.blockers.push(`GitHub repository ${repository}: authenticate GitHub CLI with gh auth login`);
185
+ return;
186
+ }
187
+
188
+ const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true });
189
+ if (!view.success) {
190
+ plan.skipped.push({ label: `GitHub repository ${repository}`, detail: "not found" });
191
+ return;
192
+ }
193
+
194
+ plan.githubRepository = repository;
195
+ plan.resources.push({ label: `GitHub repository ${repository}`, detail: "private generated repo" });
196
+ }
197
+
198
+ function deleteGitHubRepository(repository: string) {
199
+ run("gh", ["repo", "delete", repository, "--yes"], { allowFailure: true });
200
+ }
201
+
146
202
  function planProductionDomainMapping(plan: DestroyPlan) {
147
203
  try {
148
204
  const mapping = describeProductionDomainMapping();
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
+ import { stopLocalDev } from "../local-dev";
5
6
  import { bootstrap, prepareGcpProject } from "./bootstrap";
6
7
  import { cleanup } from "./cleanup";
7
8
  import { deploy } from "./deploy";
@@ -38,11 +39,10 @@ export async function main(argv = Bun.argv.slice(2)) {
38
39
  await prepareGcpProject();
39
40
  await runStep("Registering auth resource server", () => ensureAuthResourceServer());
40
41
  await runStep("Provisioning auth client", () => ensureAuthClient());
41
- await bootstrap({ skipProjectSetup: true });
42
- const target = resolveDeploymentTarget("main");
43
- const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
42
+ const bootstrapResult = await bootstrap({ skipProjectSetup: true });
43
+ const databaseUrl = bootstrapResult.databaseUrl;
44
44
  await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
45
- const origin = await deploy(["--ci"]);
45
+ const origin = await deploy(["--ci"], { bootstrapResult });
46
46
  await runOptionalBunScript("seed", { DATABASE_URL: databaseUrl });
47
47
  return `Created ${origin}`;
48
48
  });
@@ -69,6 +69,14 @@ export async function main(argv = Bun.argv.slice(2)) {
69
69
  return;
70
70
  }
71
71
 
72
+ if (command === "dev") {
73
+ if (rest[0] !== "down") {
74
+ throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
75
+ }
76
+ await runMain("Dev", () => stopLocalDev({ dockerCompose: true, removeVolumes: false }));
77
+ return;
78
+ }
79
+
72
80
  if (command === "dns") {
73
81
  await runMain("DNS", () => repairDns());
74
82
  return;
@@ -116,6 +124,7 @@ function formatHelp() {
116
124
  " auth token Mint a bearer token for protected API checks",
117
125
  " sdk Build or publish generated SDK artifacts",
118
126
  " dns Repair or inspect DNS mappings",
127
+ " dev down Stop local dev and Docker Compose containers",
119
128
  " dashboards Publish Grafana resources",
120
129
  " destroy Remove service-managed cloud resources",
121
130
  ].join("\n");
@@ -49,6 +49,12 @@ export const config = {
49
49
  previewBranchPrefix: neon.preview_branch_prefix,
50
50
  personalBranchPrefix: neon.personal_branch_prefix,
51
51
  },
52
+ git: {
53
+ enabled: Boolean(serviceConfig.git?.enabled),
54
+ owner: serviceConfig.git?.owner || "anmho",
55
+ repository: serviceConfig.git?.repository || serviceConfig.service_id,
56
+ deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
57
+ },
52
58
  requiredApis: cloudrun.required_apis,
53
59
  } as const;
54
60
 
@@ -1,5 +1,5 @@
1
1
  import { config } from "./config";
2
- import { bootstrap } from "./bootstrap";
2
+ import { bootstrap, type BootstrapResult } from "./bootstrap";
3
3
  import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches, resolveNeonConfig } from "./neon";
4
4
  import {
5
5
  addSecretVersion,
@@ -8,6 +8,7 @@ import {
8
8
  ensureProductionDomainMapping,
9
9
  ensureSecretAccessor,
10
10
  gcloud,
11
+ gcloudStreaming,
11
12
  gcloudWithRetry,
12
13
  imageUrl,
13
14
  parseDeployArgs,
@@ -19,17 +20,22 @@ import {
19
20
  writeRenderedManifest,
20
21
  } from "./lib";
21
22
 
22
- export async function deploy(args = Bun.argv.slice(2)) {
23
+ type DeployOptions = {
24
+ bootstrapResult?: BootstrapResult;
25
+ };
26
+
27
+ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOptions = {}) {
23
28
  requireCommand("gcloud");
24
29
  requireCommand("bun");
25
30
 
26
31
  const options = parseDeployArgs(args);
27
- if (!options.ci) {
28
- await bootstrap();
29
- }
32
+ const bootstrapResult = deployOptions.bootstrapResult ?? (!options.ci ? await bootstrap() : undefined);
30
33
 
31
- const target = resolveDeploymentTarget(options.environment, options.name);
32
- const neon = await runStep("Resolving Neon defaults", () => resolveNeonConfig());
34
+ const target =
35
+ bootstrapResult && options.environment === "main" && !options.name
36
+ ? bootstrapResult.target
37
+ : resolveDeploymentTarget(options.environment, options.name);
38
+ const neon = bootstrapResult?.neon ?? (await runStep("Resolving Neon defaults", () => resolveNeonConfig()));
33
39
 
34
40
  if (options.destroy) {
35
41
  if (options.environment === "main") {
@@ -47,7 +53,9 @@ export async function deploy(args = Bun.argv.slice(2)) {
47
53
  return `Destroyed ${target.serviceName}`;
48
54
  }
49
55
 
50
- await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
56
+ if (!bootstrapResult?.artifactRepositoryReady) {
57
+ await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
58
+ }
51
59
 
52
60
  let branchId: string = neon.baseBranchId;
53
61
  if (options.environment !== "main") {
@@ -57,15 +65,17 @@ export async function deploy(args = Bun.argv.slice(2)) {
57
65
  branchId = branch.id;
58
66
  }
59
67
 
60
- await runStep("Publishing environment database secret", async () => {
61
- await ensureDatabase(neon.projectId, branchId, neon.databaseName);
62
- const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
63
- addSecretVersion(target.databaseSecretName, connectionUri);
64
- ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
65
- });
68
+ if (!bootstrapResult || target.environment !== "main") {
69
+ await runStep("Publishing environment database secret", async () => {
70
+ await ensureDatabase(neon.projectId, branchId, neon.databaseName);
71
+ const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
72
+ addSecretVersion(target.databaseSecretName, connectionUri);
73
+ ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
74
+ });
75
+ }
66
76
  const image = imageUrl();
67
- await runStep("Building container image", () =>
68
- gcloud(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
77
+ await runStep("Building container image in Cloud Build", () =>
78
+ gcloudStreaming(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
69
79
  );
70
80
 
71
81
  const renderedManifestPath = await runStep("Rendering Cloud Run manifest", () => writeRenderedManifest(image, target));
@@ -117,6 +117,85 @@ export function gcloud(args: string[], options: CommandOptions = {}) {
117
117
  return run("gcloud", normalized, options);
118
118
  }
119
119
 
120
+ export async function gcloudStreaming(args: string[], options: CommandOptions = {}) {
121
+ const normalized = [...args];
122
+ if (config.project.quotaProjectId && !normalized.includes("--billing-project")) {
123
+ normalized.push("--billing-project", config.project.quotaProjectId);
124
+ }
125
+ return runStreaming("gcloud", normalized, options);
126
+ }
127
+
128
+ export async function runStreaming(command: string, args: string[], options: CommandOptions = {}): Promise<CommandResult> {
129
+ const child = Bun.spawn([command, ...args], {
130
+ cwd: process.cwd(),
131
+ env: { ...process.env, ...options.env },
132
+ stdin: options.input === undefined ? undefined : encoder.encode(options.input),
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+
137
+ const output = {
138
+ stdout: "",
139
+ stderr: "",
140
+ buildUrlPrinted: false,
141
+ };
142
+ await Promise.all([
143
+ captureStream(child.stdout, (chunk) => {
144
+ output.stdout += chunk;
145
+ printCloudBuildUrl(chunk, output);
146
+ }),
147
+ captureStream(child.stderr, (chunk) => {
148
+ output.stderr += chunk;
149
+ printCloudBuildUrl(chunk, output);
150
+ }),
151
+ ]);
152
+ const exitCode = await child.exited;
153
+ const result: CommandResult = {
154
+ success: exitCode === 0,
155
+ stdout: output.stdout.trim(),
156
+ stderr: output.stderr.trim(),
157
+ exitCode,
158
+ };
159
+
160
+ if (!result.success && !options.allowFailure) {
161
+ throw new CommandError(command, args, result);
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ async function captureStream(stream: ReadableStream<Uint8Array> | null, onChunk: (chunk: string) => void) {
168
+ if (!stream) {
169
+ return;
170
+ }
171
+ const reader = stream.getReader();
172
+ const streamDecoder = new TextDecoder();
173
+ while (true) {
174
+ const { done, value } = await reader.read();
175
+ if (done) {
176
+ break;
177
+ }
178
+ onChunk(streamDecoder.decode(value, { stream: true }));
179
+ }
180
+ const remaining = streamDecoder.decode();
181
+ if (remaining) {
182
+ onChunk(remaining);
183
+ }
184
+ }
185
+
186
+ function printCloudBuildUrl(chunk: string, output: { stdout: string; stderr: string; buildUrlPrinted: boolean }) {
187
+ if (output.buildUrlPrinted) {
188
+ return;
189
+ }
190
+ const combined = `${output.stdout}\n${output.stderr}\n${chunk}`;
191
+ const match = combined.match(/https:\/\/console\.cloud\.google\.com\/cloud-build\/[^\s)]+/);
192
+ if (!match?.[0]) {
193
+ return;
194
+ }
195
+ output.buildUrlPrinted = true;
196
+ log.step(`Cloud Build logs: ${match[0]}`);
197
+ }
198
+
120
199
  export function gcloudWithRetry(args: string[], options: CommandOptions = {}) {
121
200
  let lastError: unknown;
122
201
  for (let attempt = 1; attempt <= 12; attempt += 1) {
@@ -187,6 +266,21 @@ export function attachBilling() {
187
266
  gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
188
267
  }
189
268
 
269
+ export function ensureRequiredApis() {
270
+ const enabled = new Set(
271
+ gcloud(["services", "list", "--enabled", "--project", config.project.id, "--format=value(config.name)"]).stdout
272
+ .split("\n")
273
+ .map((name) => name.trim())
274
+ .filter(Boolean)
275
+ );
276
+ const missing = config.requiredApis.filter((api: string) => !enabled.has(api));
277
+ if (missing.length === 0) {
278
+ return "Required GCP APIs are already enabled";
279
+ }
280
+ gcloud(["services", "enable", ...missing, "--project", config.project.id]);
281
+ return `Enabled ${missing.join(", ")}`;
282
+ }
283
+
190
284
  export function ensureServiceAccount(email: string) {
191
285
  if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.project.id], { allowFailure: true }).success) {
192
286
  return;
@@ -202,9 +296,28 @@ export function deleteServiceAccount(email: string) {
202
296
  }
203
297
 
204
298
  export function ensureProjectRole(member: string, role: string) {
299
+ if (projectHasRole(member, role)) {
300
+ return;
301
+ }
205
302
  gcloudWithRetry(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
206
303
  }
207
304
 
305
+ function projectHasRole(member: string, role: string) {
306
+ return gcloud(
307
+ [
308
+ "projects",
309
+ "get-iam-policy",
310
+ config.project.id,
311
+ "--flatten=bindings[].members",
312
+ `--filter=bindings.role=${role} AND bindings.members=${member}`,
313
+ "--format=value(bindings.role)",
314
+ ],
315
+ { allowFailure: true }
316
+ )
317
+ .stdout.split("\n")
318
+ .some((line) => line.trim() === role);
319
+ }
320
+
208
321
  export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
209
322
  gcloudWithRetry([
210
323
  "iam",
@@ -18,7 +18,7 @@ type NeonDatabase = {
18
18
  ownerName: string;
19
19
  };
20
20
 
21
- type ResolvedNeonConfig = {
21
+ export type ResolvedNeonConfig = {
22
22
  projectId: string;
23
23
  baseBranchId: string;
24
24
  baseBranchName: string;
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { buildLocalDevCleanupPlan, stopLocalDev } from "./local-dev";
6
+
7
+ const roots: string[] = [];
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
11
+ });
12
+
13
+ describe("local dev cleanup", () => {
14
+ test("is idempotent with a missing pid and missing compose file", async () => {
15
+ const root = await tempRoot();
16
+ const result = await stopLocalDev({ root, dockerCompose: true, removeVolumes: true });
17
+
18
+ expect(result).toContain("No local dev pid file found");
19
+ expect(await Bun.file(join(root, ".service", "local-dev.pid")).exists()).toBe(false);
20
+ });
21
+
22
+ test("removes a stale pid file", async () => {
23
+ const root = await tempRoot();
24
+ await mkdir(join(root, ".service"), { recursive: true });
25
+ await Bun.write(join(root, ".service", "local-dev.pid"), "999999\n");
26
+
27
+ const result = await stopLocalDev({ root, dockerCompose: false });
28
+
29
+ expect(result).toContain("Removed stale local dev pid file for 999999");
30
+ expect(await Bun.file(join(root, ".service", "local-dev.pid")).exists()).toBe(false);
31
+ });
32
+
33
+ test("plans Docker Compose cleanup when compose exists", async () => {
34
+ const root = await tempRoot();
35
+ await Bun.write(join(root, "docker-compose.yml"), "services: {}\n");
36
+
37
+ const plan = await buildLocalDevCleanupPlan({ root, dockerCompose: true });
38
+
39
+ expect(plan.resources).toContain("Docker Compose containers, networks, and volumes");
40
+ });
41
+ });
42
+
43
+ async function tempRoot() {
44
+ const root = await mkdtemp(join(tmpdir(), "create-svc-local-dev-"));
45
+ roots.push(root);
46
+ return root;
47
+ }
@@ -0,0 +1,138 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ type LocalDevOptions = {
5
+ root?: string;
6
+ dockerCompose?: boolean;
7
+ removeVolumes?: boolean;
8
+ };
9
+
10
+ export type LocalDevCleanupPlan = {
11
+ pidFile: string;
12
+ hasPidFile: boolean;
13
+ pid?: number;
14
+ hasDockerCompose: boolean;
15
+ resources: string[];
16
+ skipped: string[];
17
+ };
18
+
19
+ const decoder = new TextDecoder();
20
+
21
+ export async function buildLocalDevCleanupPlan(options: LocalDevOptions = {}): Promise<LocalDevCleanupPlan> {
22
+ const root = options.root ?? defaultServiceRoot();
23
+ const pidFile = join(root, ".service", "local-dev.pid");
24
+ const hasPidFile = await Bun.file(pidFile).exists();
25
+ const pid = hasPidFile ? parsePid(await Bun.file(pidFile).text()) : undefined;
26
+ const hasDockerCompose = Boolean(options.dockerCompose) && (await Bun.file(join(root, "docker-compose.yml")).exists());
27
+ const resources: string[] = [];
28
+ const skipped: string[] = [];
29
+
30
+ if (hasPidFile) {
31
+ resources.push(`Local dev process from ${pidFile}`);
32
+ } else {
33
+ skipped.push("Local dev process: no .service/local-dev.pid");
34
+ }
35
+
36
+ if (hasDockerCompose) {
37
+ resources.push("Docker Compose containers, networks, and volumes");
38
+ } else if (options.dockerCompose) {
39
+ skipped.push("Docker Compose: no docker-compose.yml");
40
+ }
41
+
42
+ return {
43
+ pidFile,
44
+ hasPidFile,
45
+ pid,
46
+ hasDockerCompose,
47
+ resources,
48
+ skipped,
49
+ };
50
+ }
51
+
52
+ export async function stopLocalDev(options: LocalDevOptions = {}) {
53
+ const root = options.root ?? defaultServiceRoot();
54
+ const plan = await buildLocalDevCleanupPlan({ ...options, root });
55
+ const messages: string[] = [];
56
+
57
+ if (plan.hasPidFile) {
58
+ if (plan.pid) {
59
+ messages.push(stopPid(plan.pid) ? `Stopped local dev process ${plan.pid}` : `Removed stale local dev pid file for ${plan.pid}`);
60
+ } else {
61
+ messages.push(`Removed invalid local dev pid file ${plan.pidFile}`);
62
+ }
63
+ await rm(plan.pidFile, { force: true });
64
+ } else {
65
+ messages.push("No local dev pid file found");
66
+ }
67
+
68
+ if (plan.hasDockerCompose) {
69
+ const result = runDockerComposeDown(root, Boolean(options.removeVolumes));
70
+ messages.push(result);
71
+ }
72
+
73
+ return messages.join("\n");
74
+ }
75
+
76
+ function defaultServiceRoot() {
77
+ return process.env.CREATE_SVC_SERVICE_ROOT?.trim() || process.cwd();
78
+ }
79
+
80
+ function parsePid(raw: string) {
81
+ const pid = Number.parseInt(raw.trim(), 10);
82
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined;
83
+ }
84
+
85
+ function stopPid(pid: number) {
86
+ const wasRunning = isRunning(pid);
87
+ tryKill(-pid, "SIGTERM") || tryKill(pid, "SIGTERM");
88
+ Bun.sleepSync(1_000);
89
+ if (isRunning(pid)) {
90
+ tryKill(-pid, "SIGKILL") || tryKill(pid, "SIGKILL");
91
+ }
92
+ return wasRunning;
93
+ }
94
+
95
+ function isRunning(pid: number) {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function tryKill(pid: number, signal: NodeJS.Signals) {
105
+ try {
106
+ process.kill(pid, signal);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function runDockerComposeDown(root: string, removeVolumes: boolean) {
114
+ if (!Bun.which("docker")) {
115
+ return "Docker is not installed; Docker Compose cleanup skipped";
116
+ }
117
+
118
+ const args = ["compose", "down", "--remove-orphans"];
119
+ if (removeVolumes) {
120
+ args.push("-v");
121
+ }
122
+ const result = Bun.spawnSync(["docker", ...args], {
123
+ cwd: root,
124
+ env: process.env,
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ });
128
+ if (!result.success) {
129
+ const output = [
130
+ result.stderr ? decoder.decode(result.stderr).trim() : "",
131
+ result.stdout ? decoder.decode(result.stdout).trim() : "",
132
+ ]
133
+ .filter(Boolean)
134
+ .join("\n");
135
+ return `Docker Compose cleanup failed: ${output || `exit ${result.exitCode}`}`;
136
+ }
137
+ return removeVolumes ? "Docker Compose containers and volumes removed" : "Docker Compose containers stopped";
138
+ }
@@ -4,6 +4,7 @@ import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
4
4
  import { createApiClient } from "@neondatabase/api-client";
5
5
  import { Client } from "pg";
6
6
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
7
+ import { stopLocalDev } from "../local-dev";
7
8
  import { serviceConfig } from "../runtime";
8
9
 
9
10
  const config = {
@@ -11,6 +12,12 @@ const config = {
11
12
  hostname: serviceConfig.dns.hostname,
12
13
  neonDatabaseName: serviceConfig.neon.database_name,
13
14
  neonRoleName: serviceConfig.neon.role_name,
15
+ git: {
16
+ enabled: Boolean(serviceConfig.git?.enabled),
17
+ owner: serviceConfig.git?.owner || "anmho",
18
+ repository: serviceConfig.git?.repository || serviceConfig.service_id,
19
+ deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
20
+ },
14
21
  };
15
22
 
16
23
  type DoctorStatus = "pass" | "warn" | "fail";
@@ -65,6 +72,13 @@ export async function main(argv = Bun.argv.slice(2)) {
65
72
  return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
66
73
  }
67
74
 
75
+ if (command === "dev") {
76
+ if (rest[0] !== "down") {
77
+ throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
78
+ }
79
+ return runMain("Dev", () => stopLocalDev({ dockerCompose: false }));
80
+ }
81
+
68
82
  if (command === "doctor") {
69
83
  return runMain("Doctor", () => runDoctor());
70
84
  }
@@ -81,6 +95,8 @@ export async function main(argv = Bun.argv.slice(2)) {
81
95
  return runMain("Destroy", async () => {
82
96
  await requireDestroyConfirmation(rest.includes("--force"));
83
97
  const wranglerArgs = rest.filter((arg) => arg !== "--force");
98
+ await stopLocalDev({ dockerCompose: false });
99
+ deleteGitHubRepositoryIfOwned();
84
100
  await deleteHyperdrive();
85
101
  run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
86
102
  await deleteNeonDatabase();
@@ -109,12 +125,28 @@ function formatHelp() {
109
125
  " doctor Check local tools and cloud access",
110
126
  " auth Manage auth resource server and clients",
111
127
  " auth token Mint a bearer token for protected API checks",
128
+ " dev down Stop local dev",
112
129
  " dns Show Workers custom-domain configuration",
113
130
  " dashboards Publish Grafana resources",
114
131
  " destroy Remove service-managed Worker resources",
115
132
  ].join("\n");
116
133
  }
117
134
 
135
+ function deleteGitHubRepositoryIfOwned() {
136
+ const repository = `${config.git.owner}/${config.git.repository}`;
137
+ if (!config.git.deleteOnDestroy) {
138
+ log.step(`Skipping GitHub repository ${repository}: ${config.git.enabled ? "not created by this service CLI run" : "git disabled"}`);
139
+ return;
140
+ }
141
+ run("gh", ["auth", "status"], { capture: true });
142
+ const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true, capture: true });
143
+ if (!view.success) {
144
+ log.step(`Skipping GitHub repository ${repository}: not found`);
145
+ return;
146
+ }
147
+ run("gh", ["repo", "delete", repository, "--yes"]);
148
+ }
149
+
118
150
  function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
119
151
  if (!Bun.which(command)) {
120
152
  throw new Error(`missing required command: ${command}`);
@@ -30,6 +30,7 @@ console to create and deploy.
30
30
  {{COMMAND_TEST}}
31
31
  {{COMMAND_BOOTSTRAP}}
32
32
  {{COMMAND_DEPLOY}}
33
+ {{COMMAND_DEV_DOWN}}
33
34
  {{COMMAND_AUTH_RESOURCE}}
34
35
  {{COMMAND_AUTH_CLIENT}}
35
36
  {{COMMAND_DEPLOY_PERSONAL}}
@@ -215,7 +216,8 @@ service create {{SERVICE_NAME}} --yes
215
216
  ```
216
217
 
217
218
  That command scaffolds this package, runs `service create`, deploys the production
218
- Cloud Run service through `service deploy`, and fails loudly with resumable
219
+ Cloud Run service once, verifies production and local endpoints, starts local dev,
220
+ and fails loudly with resumable
219
221
  instructions if a required cloud credential is missing. The generated package can also be run
220
222
  manually:
221
223
 
@@ -27,6 +27,15 @@
27
27
  "service_id": "{{SERVICE_ID}}"
28
28
  },
29
29
 
30
+ "git": {
31
+ "enabled": {{GIT_ENABLED}},
32
+ "owner": "{{GIT_OWNER}}",
33
+ "repository": "{{GIT_REPOSITORY}}",
34
+ // This flips to true only after `service create` actually creates the
35
+ // GitHub repository. Existing worktrees and --no-git stay false.
36
+ "delete_on_destroy": false
37
+ },
38
+
30
39
  "auth": {
31
40
  "issuer": "{{AUTH_ISSUER}}",
32
41
  "token_endpoint": "https://auth.anmho.com/api/auth/oauth2/token",