create-svc 0.1.33 → 0.1.35

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.33",
3
+ "version": "0.1.35",
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
@@ -1191,7 +1191,7 @@ export function formatScaffoldHelp() {
1191
1191
  " --billing-account <name> Billing account resource name",
1192
1192
  " --quota-project <id> Billing quota project for gcloud calls",
1193
1193
  " --region <region> Cloud Run region",
1194
- " --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)",
1195
1195
  " --no-auto-deploy Scaffold only",
1196
1196
  " --no-git Skip default private GitHub repo: anmho/<service_id>",
1197
1197
  " --yes, -y Accept defaults without prompts",
@@ -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
 
package/src/scaffold.ts CHANGED
@@ -224,6 +224,7 @@ function buildReplacements(config: ScaffoldConfig) {
224
224
  COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
225
225
  COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
226
226
  COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
227
+ COMMAND_DEV_DOWN: "service dev down",
227
228
  COMMAND_BOOTSTRAP: "service create",
228
229
  COMMAND_DEPLOY: "service deploy",
229
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,9 +1,11 @@
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 {
6
7
  assertOwnedResource,
8
+ deleteArtifactImage,
7
9
  deleteProject,
8
10
  deleteProductionDomainMapping,
9
11
  deleteSecret,
@@ -14,6 +16,7 @@ import {
14
16
  describeSecret,
15
17
  formatError,
16
18
  listCloudRunServices,
19
+ listArtifactImages,
17
20
  listSecrets,
18
21
  parseCleanupArgs,
19
22
  requireCommand,
@@ -49,6 +52,7 @@ type DestroyPlan = {
49
52
  hasProductionDomainMapping: boolean;
50
53
  serviceNames: string[];
51
54
  secretNames: string[];
55
+ artifactImages: string[];
52
56
  neon?: {
53
57
  projectId: string;
54
58
  baseBranchId: string;
@@ -70,6 +74,8 @@ export async function cleanup(args = Bun.argv.slice(2)) {
70
74
 
71
75
  await requireDestroyConfirmation(options.force);
72
76
 
77
+ await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
78
+
73
79
  if (plan.githubRepository) {
74
80
  await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
75
81
  }
@@ -88,6 +94,13 @@ export async function cleanup(args = Bun.argv.slice(2)) {
88
94
  }
89
95
  });
90
96
 
97
+ const artifactImages = plan.artifactImages;
98
+ await runStep("Deleting Artifact Registry images", () => {
99
+ for (const image of artifactImages) {
100
+ deleteArtifactImage(image);
101
+ }
102
+ });
103
+
91
104
  const secretNames = plan.secretNames;
92
105
  await runStep("Deleting service secrets", () => {
93
106
  for (const secretName of secretNames) {
@@ -134,11 +147,14 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
134
147
  hasProductionDomainMapping: false,
135
148
  serviceNames: [],
136
149
  secretNames: [],
150
+ artifactImages: [],
137
151
  };
138
152
 
139
153
  planGitHubRepository(plan);
154
+ await planLocalDev(plan);
140
155
  planProductionDomainMapping(plan);
141
156
  planCloudRunServices(plan);
157
+ planArtifactImages(plan);
142
158
  planSecrets(plan);
143
159
  await planNeon(plan);
144
160
  await planGrafana(plan);
@@ -150,6 +166,16 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
150
166
  return plan;
151
167
  }
152
168
 
169
+ async function planLocalDev(plan: DestroyPlan) {
170
+ const localDev = await buildLocalDevCleanupPlan({ dockerCompose: true });
171
+ for (const resource of localDev.resources) {
172
+ plan.resources.push({ label: resource, detail: "local" });
173
+ }
174
+ for (const skipped of localDev.skipped) {
175
+ plan.skipped.push({ label: skipped, detail: "local" });
176
+ }
177
+ }
178
+
153
179
  function planGitHubRepository(plan: DestroyPlan) {
154
180
  const repository = `${config.git.owner}/${config.git.repository}`;
155
181
  if (!config.git.deleteOnDestroy) {
@@ -222,6 +248,21 @@ function planCloudRunServices(plan: DestroyPlan) {
222
248
  }
223
249
  }
224
250
 
251
+ function planArtifactImages(plan: DestroyPlan) {
252
+ try {
253
+ plan.artifactImages = listArtifactImages();
254
+ if (plan.artifactImages.length === 0) {
255
+ plan.skipped.push({ label: `Artifact Registry images for ${config.serviceName}`, detail: "none matched" });
256
+ return;
257
+ }
258
+ for (const image of plan.artifactImages) {
259
+ plan.resources.push({ label: `Artifact Registry image ${image}`, detail: `${config.project.id}/${config.region}` });
260
+ }
261
+ } catch (error) {
262
+ plan.blockers.push(`Artifact Registry images for ${config.serviceName}: ${formatError(error)}`);
263
+ }
264
+ }
265
+
225
266
  function planSecrets(plan: DestroyPlan) {
226
267
  try {
227
268
  plan.secretNames = listSecrets().filter(matchesSecretResource);
@@ -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");
@@ -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",
@@ -302,6 +415,32 @@ export function ensureArtifactRepository() {
302
415
  ]);
303
416
  }
304
417
 
418
+ export function artifactImageBase() {
419
+ return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}`;
420
+ }
421
+
422
+ export function listArtifactImages() {
423
+ const result = gcloud(
424
+ ["artifacts", "docker", "images", "list", artifactImageBase(), "--include-tags", "--project", config.project.id, "--format=json"],
425
+ { allowFailure: true }
426
+ );
427
+ if (!result.success || !result.stdout) {
428
+ return [];
429
+ }
430
+
431
+ try {
432
+ return (JSON.parse(result.stdout) as Array<{ package?: string; version?: string }>)
433
+ .map((image) => (image.package && image.version ? `${image.package}@${image.version}` : ""))
434
+ .filter(Boolean);
435
+ } catch {
436
+ throw new Error(`Unable to parse Artifact Registry images for ${artifactImageBase()}`);
437
+ }
438
+ }
439
+
440
+ export function deleteArtifactImage(image: string) {
441
+ gcloud(["artifacts", "docker", "images", "delete", image, "--delete-tags", "--project", config.project.id, "--quiet"], { allowFailure: true });
442
+ }
443
+
305
444
  export function projectNumber() {
306
445
  return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
307
446
  }
@@ -312,7 +451,7 @@ export function imageTag() {
312
451
  }
313
452
 
314
453
  export function imageUrl(tag = imageTag()) {
315
- return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}:${tag}`;
454
+ return `${artifactImageBase()}:${tag}`;
316
455
  }
317
456
 
318
457
  export function parseDeployArgs(argv: string[]): DeployArgs {
@@ -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 = {
@@ -71,6 +72,13 @@ export async function main(argv = Bun.argv.slice(2)) {
71
72
  return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
72
73
  }
73
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
+
74
82
  if (command === "doctor") {
75
83
  return runMain("Doctor", () => runDoctor());
76
84
  }
@@ -87,6 +95,7 @@ export async function main(argv = Bun.argv.slice(2)) {
87
95
  return runMain("Destroy", async () => {
88
96
  await requireDestroyConfirmation(rest.includes("--force"));
89
97
  const wranglerArgs = rest.filter((arg) => arg !== "--force");
98
+ await stopLocalDev({ dockerCompose: false });
90
99
  deleteGitHubRepositoryIfOwned();
91
100
  await deleteHyperdrive();
92
101
  run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
@@ -116,6 +125,7 @@ function formatHelp() {
116
125
  " doctor Check local tools and cloud access",
117
126
  " auth Manage auth resource server and clients",
118
127
  " auth token Mint a bearer token for protected API checks",
128
+ " dev down Stop local dev",
119
129
  " dns Show Workers custom-domain configuration",
120
130
  " dashboards Publish Grafana resources",
121
131
  " destroy Remove service-managed Worker resources",
@@ -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