create-svc 0.1.40 → 0.1.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
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",
@@ -4,6 +4,7 @@ import {
4
4
  buildLocalVerificationCommands,
5
5
  buildPostScaffoldCommands,
6
6
  buildPreGitBootstrapCommands,
7
+ shouldRunLocalMigrate,
7
8
  } from "./post-scaffold";
8
9
 
9
10
  describe("buildPostScaffoldCommands", () => {
@@ -37,6 +38,13 @@ describe("buildPreGitBootstrapCommands", () => {
37
38
  });
38
39
  });
39
40
 
41
+ describe("shouldRunLocalMigrate", () => {
42
+ test("skips local migrate before Workers dev", () => {
43
+ expect(shouldRunLocalMigrate({ target: "workers" })).toBe(false);
44
+ expect(shouldRunLocalMigrate({ target: "cloudrun" })).toBe(true);
45
+ });
46
+ });
47
+
40
48
  describe("buildLocalVerificationCommands", () => {
41
49
  test("uses local curl checks for Bun Hono services", () => {
42
50
  expect(buildLocalVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
@@ -58,7 +58,9 @@ export function runPreGitBootstrapFlow(config: Pick<ScaffoldConfig, "framework">
58
58
  }
59
59
 
60
60
  async function startLocalDevelopment(config: Pick<ScaffoldConfig, "target">, cwd: string) {
61
- run("bun", ["run", "migrate"], { cwd });
61
+ if (shouldRunLocalMigrate(config)) {
62
+ run("bun", ["run", "migrate"], { cwd });
63
+ }
62
64
  await mkdir(join(cwd, ".service"), { recursive: true });
63
65
  const child = Bun.spawn(["sh", "-c", "exec bun run dev > .service/local-dev.log 2>&1 < /dev/null"], {
64
66
  cwd,
@@ -72,6 +74,10 @@ async function startLocalDevelopment(config: Pick<ScaffoldConfig, "target">, cwd
72
74
  await Bun.write(join(cwd, ".service", "local-dev.pid"), `${child.pid}\n`);
73
75
  }
74
76
 
77
+ export function shouldRunLocalMigrate(config: Pick<ScaffoldConfig, "target">) {
78
+ return config.target !== "workers";
79
+ }
80
+
75
81
  function runWithRetries(command: PostScaffoldCommand, options: CommandOptions, attempts: number, delayMs: number) {
76
82
  let lastError: unknown;
77
83
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -6,6 +6,12 @@ export type DeployArgs = {
6
6
  name?: string;
7
7
  };
8
8
 
9
+ export const CLOUD_RUN_LOCAL_BUILD_PLATFORM = "linux/amd64";
10
+
11
+ export function localDockerBuildArgs(image: string) {
12
+ return ["build", "--platform", CLOUD_RUN_LOCAL_BUILD_PLATFORM, "-t", image, "."];
13
+ }
14
+
9
15
  export function parseDeployArgs(argv: string[]): DeployArgs {
10
16
  const parsed: DeployArgs = {
11
17
  build: parseBuildStrategy(process.env.SERVICE_BUILD_STRATEGY || process.env.SERVICE_BUILD),
@@ -87,4 +93,3 @@ function parseBuildStrategy(value: string | undefined): DeployArgs["build"] {
87
93
  }
88
94
  throw new Error(`Unknown build strategy: ${value}`);
89
95
  }
90
-
@@ -12,6 +12,7 @@ import {
12
12
  gcloudStreaming,
13
13
  gcloudWithRetry,
14
14
  imageUrl,
15
+ localDockerBuildArgs,
15
16
  parseDeployArgs,
16
17
  requireCommand,
17
18
  resolveDeploymentTarget,
@@ -84,7 +85,7 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
84
85
  await runStep("Authenticating Docker to Artifact Registry", () =>
85
86
  gcloud(["auth", "configure-docker", `${config.region}-docker.pkg.dev`, "--quiet"])
86
87
  );
87
- await runStep("Building container image locally", () => dockerStreaming(["build", "-t", image, "."]));
88
+ await runStep("Building container image locally", () => dockerStreaming(localDockerBuildArgs(image)));
88
89
  await runStep("Pushing container image to Artifact Registry", () => dockerStreaming(["push", image]));
89
90
  }
90
91
 
@@ -1,5 +1,5 @@
1
1
  import { afterEach, expect, test } from "bun:test";
2
- import { parseDeployArgs } from "./deploy-args";
2
+ import { localDockerBuildArgs, parseDeployArgs } from "./deploy-args";
3
3
 
4
4
  const originalBuild = process.env.SERVICE_BUILD;
5
5
  const originalBuildStrategy = process.env.SERVICE_BUILD_STRATEGY;
@@ -27,3 +27,14 @@ test("parseDeployArgs accepts build strategy from env", () => {
27
27
 
28
28
  expect(parseDeployArgs([]).build).toBe("cloudbuild");
29
29
  });
30
+
31
+ test("local Docker builds target Cloud Run's runtime platform", () => {
32
+ expect(localDockerBuildArgs("us-west1-docker.pkg.dev/example/services/api:latest")).toEqual([
33
+ "build",
34
+ "--platform",
35
+ "linux/amd64",
36
+ "-t",
37
+ "us-west1-docker.pkg.dev/example/services/api:latest",
38
+ ".",
39
+ ]);
40
+ });
@@ -2,7 +2,7 @@ import { intro, log, outro, spinner } from "@clack/prompts";
2
2
  import { join } from "node:path";
3
3
  import { config } from "./config";
4
4
  import { serviceRoot } from "../runtime";
5
- import { parseDeployArgs, type DeployArgs } from "./deploy-args";
5
+ import { localDockerBuildArgs, parseDeployArgs, type DeployArgs } from "./deploy-args";
6
6
 
7
7
  type CommandOptions = {
8
8
  allowFailure?: boolean;
@@ -452,7 +452,7 @@ export function imageUrl(tag = imageTag()) {
452
452
  return `${artifactImageBase()}:${tag}`;
453
453
  }
454
454
 
455
- export { parseDeployArgs };
455
+ export { localDockerBuildArgs, parseDeployArgs };
456
456
 
457
457
  export function parseCleanupArgs(argv: string[]): CleanupArgs {
458
458
  const parsed: CleanupArgs = {
@@ -7,6 +7,7 @@ import { manualGitHubDeleteCommand } from "../../git-bootstrap";
7
7
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
8
8
  import { stopLocalDev } from "../local-dev";
9
9
  import { serviceConfig } from "../runtime";
10
+ import { isLocalDatabaseUrl, resolveCommandPath } from "./lib";
10
11
 
11
12
  const config = {
12
13
  serviceName: serviceConfig.service_id,
@@ -35,7 +36,7 @@ export async function main(argv = Bun.argv.slice(2)) {
35
36
  return runMain("Create", async () => {
36
37
  ensureAuthResourceServer();
37
38
  ensureAuthClient();
38
- const databaseUrl = await resolveDatabaseUrl();
39
+ const databaseUrl = await resolveDatabaseUrl({ preferRemote: true });
39
40
  await applySchema(databaseUrl);
40
41
  await ensureHyperdrive(databaseUrl);
41
42
  run("wrangler", ["deploy"]);
@@ -196,10 +197,11 @@ async function confirmGitHubRepositoryDeletion(force: boolean) {
196
197
  }
197
198
 
198
199
  function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
199
- if (!Bun.which(command)) {
200
+ const resolvedCommand = resolveCommandPath(command);
201
+ if (!resolvedCommand) {
200
202
  throw new Error(`missing required command: ${command}`);
201
203
  }
202
- const result = Bun.spawnSync([command, ...args], {
204
+ const result = Bun.spawnSync([resolvedCommand, ...args], {
203
205
  cwd: process.cwd(),
204
206
  env: process.env,
205
207
  stdin: "inherit",
@@ -254,15 +256,15 @@ async function deleteHyperdrive() {
254
256
  run("wrangler", ["hyperdrive", "delete", id, "--force"], { allowFailure: true });
255
257
  }
256
258
 
257
- async function resolveDatabaseUrl() {
259
+ async function resolveDatabaseUrl(options: { preferRemote?: boolean } = {}) {
258
260
  const direct = Bun.env.DATABASE_URL?.trim();
259
- if (direct) {
261
+ const apiKey = resolveNeonApiKey();
262
+ if (direct && (!options.preferRemote || !isLocalDatabaseUrl(direct) || !apiKey)) {
260
263
  return direct;
261
264
  }
262
265
 
263
- const apiKey = Bun.env.NEON_API_KEY?.trim();
264
266
  if (!apiKey) {
265
- throw new Error("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
267
+ throw new Error("NEON_API_KEY, readable Vault Neon provider path, or non-local DATABASE_URL is required to provision the Hyperdrive binding");
266
268
  }
267
269
 
268
270
  const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
@@ -295,6 +297,32 @@ async function resolveDatabaseUrl() {
295
297
  return uri;
296
298
  }
297
299
 
300
+ function resolveNeonApiKey() {
301
+ const direct = Bun.env.NEON_API_KEY?.trim();
302
+ if (direct) {
303
+ return direct;
304
+ }
305
+
306
+ const vault = resolveCommandPath("vault");
307
+ if (!vault) {
308
+ return "";
309
+ }
310
+
311
+ const mount = Bun.env.VAULT_SECRET_MOUNT?.trim() || "secret";
312
+ const path = Bun.env.VAULT_NEON_API_KEY_PATH?.trim() || "prod/providers/neon";
313
+ const field = Bun.env.VAULT_NEON_API_KEY_FIELD?.trim() || "api_key";
314
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
315
+ cwd: process.cwd(),
316
+ env: process.env,
317
+ stdout: "pipe",
318
+ stderr: "pipe",
319
+ });
320
+ if (!result.success || !result.stdout) {
321
+ return "";
322
+ }
323
+ return new TextDecoder().decode(result.stdout).trim();
324
+ }
325
+
298
326
  async function resolveNeonTarget(apiKey: string) {
299
327
  const neon = createApiClient({ apiKey });
300
328
  const projectsPayload = await neon.listProjects({ limit: 100 });
@@ -462,7 +490,7 @@ async function record(
462
490
  }
463
491
 
464
492
  function checkCommand(name: string) {
465
- const path = Bun.which(name);
493
+ const path = resolveCommandPath(name);
466
494
  if (!path) {
467
495
  throw new Error(`${name} is not installed`);
468
496
  }
@@ -0,0 +1,28 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { mkdtempSync } from "node:fs";
6
+ import { isLocalDatabaseUrl, resolveCommandPath } from "./lib";
7
+
8
+ test("resolveCommandPath prefers repo-local bins", async () => {
9
+ const root = mkdtempSync(join(tmpdir(), "create-svc-workers-bin-"));
10
+ try {
11
+ const binDir = join(root, "node_modules", ".bin");
12
+ await mkdir(binDir, { recursive: true });
13
+ const wrangler = join(binDir, "wrangler");
14
+ await writeFile(wrangler, "#!/bin/sh\n");
15
+
16
+ expect(resolveCommandPath("wrangler", root)).toBe(wrangler);
17
+ } finally {
18
+ await rm(root, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ test("isLocalDatabaseUrl detects localhost database URLs", () => {
23
+ expect(isLocalDatabaseUrl("postgres://postgres:postgres@127.0.0.1:5432/app")).toBe(true);
24
+ expect(isLocalDatabaseUrl("postgres://postgres:postgres@localhost:5432/app")).toBe(true);
25
+ expect(isLocalDatabaseUrl("postgres://user:pass@ep-example.us-east-2.aws.neon.tech/app")).toBe(false);
26
+ expect(isLocalDatabaseUrl("not a url")).toBe(false);
27
+ });
28
+
@@ -0,0 +1,20 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export function resolveCommandPath(command: string, cwd = process.cwd()) {
5
+ const local = join(cwd, "node_modules", ".bin", command);
6
+ if (existsSync(local)) {
7
+ return local;
8
+ }
9
+ return Bun.which(command);
10
+ }
11
+
12
+ export function isLocalDatabaseUrl(value: string) {
13
+ try {
14
+ const parsed = new URL(value);
15
+ return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
@@ -74,6 +74,8 @@ Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cl
74
74
  Cloud Run deploys build and push the container image locally by default. Use
75
75
  `service deploy --build cloudbuild` only when you explicitly want Google Cloud
76
76
  Build to build the image remotely.
77
+ Local Docker builds target `linux/amd64` so images built on Apple Silicon run on
78
+ Cloud Run.
77
79
 
78
80
  Authenticate `gcloud` on the machine before running provisioning commands:
79
81
 
@@ -70,6 +70,9 @@ If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
70
70
  `NEON_API_KEY` to create/resolve the generated Neon database and connection URI,
71
71
  applies the waitlist schema, then runs `wrangler hyperdrive create` and writes
72
72
  the returned id back into `wrangler.toml` before deploy.
73
+ For production create, a generated local `DATABASE_URL` pointing at localhost is
74
+ ignored when `NEON_API_KEY` or the generated Vault Neon provider path is
75
+ available.
73
76
 
74
77
  You can also apply the schema manually:
75
78