create-svc 0.1.41 → 0.1.43
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 +1 -1
- package/src/post-scaffold.test.ts +8 -0
- package/src/post-scaffold.ts +7 -1
- package/src/service-runtime/workers/cli.ts +59 -9
- package/src/service-runtime/workers/lib.test.ts +32 -0
- package/src/service-runtime/workers/lib.ts +24 -0
- package/templates/targets/workers/README.md +3 -0
package/package.json
CHANGED
|
@@ -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([
|
package/src/post-scaffold.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -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, isMissingDatabaseError, resolveCommandPath } from "./lib";
|
|
10
11
|
|
|
11
12
|
const config = {
|
|
12
13
|
serviceName: serviceConfig.service_id,
|
|
@@ -35,8 +36,8 @@ 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
|
-
await
|
|
39
|
+
const databaseUrl = await resolveDatabaseUrl({ preferRemote: true });
|
|
40
|
+
await applySchemaWithRetries(databaseUrl);
|
|
40
41
|
await ensureHyperdrive(databaseUrl);
|
|
41
42
|
run("wrangler", ["deploy"]);
|
|
42
43
|
return `Created https://${config.hostname}`;
|
|
@@ -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
|
-
|
|
200
|
+
const resolvedCommand = resolveCommandPath(command);
|
|
201
|
+
if (!resolvedCommand) {
|
|
200
202
|
throw new Error(`missing required command: ${command}`);
|
|
201
203
|
}
|
|
202
|
-
const result = Bun.spawnSync([
|
|
204
|
+
const result = Bun.spawnSync([resolvedCommand, ...args], {
|
|
203
205
|
cwd: process.cwd(),
|
|
204
206
|
env: process.env,
|
|
205
207
|
stdin: "inherit",
|
|
@@ -254,15 +256,20 @@ 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();
|
|
261
|
+
const apiKey = resolveNeonApiKey();
|
|
259
262
|
if (direct) {
|
|
260
|
-
|
|
263
|
+
if (!options.preferRemote || !isLocalDatabaseUrl(direct)) {
|
|
264
|
+
return direct;
|
|
265
|
+
}
|
|
266
|
+
if (!apiKey) {
|
|
267
|
+
throw new Error("NEON_API_KEY or readable Vault Neon provider path is required for Workers production create; ignoring local DATABASE_URL");
|
|
268
|
+
}
|
|
261
269
|
}
|
|
262
270
|
|
|
263
|
-
const apiKey = Bun.env.NEON_API_KEY?.trim();
|
|
264
271
|
if (!apiKey) {
|
|
265
|
-
throw new Error("
|
|
272
|
+
throw new Error("NEON_API_KEY, readable Vault Neon provider path, or non-local DATABASE_URL is required to provision the Hyperdrive binding");
|
|
266
273
|
}
|
|
267
274
|
|
|
268
275
|
const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
|
|
@@ -295,6 +302,32 @@ async function resolveDatabaseUrl() {
|
|
|
295
302
|
return uri;
|
|
296
303
|
}
|
|
297
304
|
|
|
305
|
+
function resolveNeonApiKey() {
|
|
306
|
+
const direct = Bun.env.NEON_API_KEY?.trim();
|
|
307
|
+
if (direct) {
|
|
308
|
+
return direct;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const vault = resolveCommandPath("vault");
|
|
312
|
+
if (!vault) {
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const mount = Bun.env.VAULT_SECRET_MOUNT?.trim() || "secret";
|
|
317
|
+
const path = Bun.env.VAULT_NEON_API_KEY_PATH?.trim() || "prod/providers/neon";
|
|
318
|
+
const field = Bun.env.VAULT_NEON_API_KEY_FIELD?.trim() || "api_key";
|
|
319
|
+
const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
|
|
320
|
+
cwd: process.cwd(),
|
|
321
|
+
env: process.env,
|
|
322
|
+
stdout: "pipe",
|
|
323
|
+
stderr: "pipe",
|
|
324
|
+
});
|
|
325
|
+
if (!result.success || !result.stdout) {
|
|
326
|
+
return "";
|
|
327
|
+
}
|
|
328
|
+
return new TextDecoder().decode(result.stdout).trim();
|
|
329
|
+
}
|
|
330
|
+
|
|
298
331
|
async function resolveNeonTarget(apiKey: string) {
|
|
299
332
|
const neon = createApiClient({ apiKey });
|
|
300
333
|
const projectsPayload = await neon.listProjects({ limit: 100 });
|
|
@@ -388,6 +421,23 @@ create index if not exists waitlist_triggers_status_created_idx
|
|
|
388
421
|
}
|
|
389
422
|
}
|
|
390
423
|
|
|
424
|
+
async function applySchemaWithRetries(databaseUrl: string) {
|
|
425
|
+
let lastError: unknown;
|
|
426
|
+
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
|
427
|
+
try {
|
|
428
|
+
await applySchema(databaseUrl);
|
|
429
|
+
return;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
lastError = error;
|
|
432
|
+
if (!isMissingDatabaseError(error) || attempt === 10) {
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
await Bun.sleep(2_000);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw lastError;
|
|
439
|
+
}
|
|
440
|
+
|
|
391
441
|
async function runDoctor() {
|
|
392
442
|
const results: Array<{ name: string; status: DoctorStatus; detail: string }> = [];
|
|
393
443
|
|
|
@@ -462,7 +512,7 @@ async function record(
|
|
|
462
512
|
}
|
|
463
513
|
|
|
464
514
|
function checkCommand(name: string) {
|
|
465
|
-
const path =
|
|
515
|
+
const path = resolveCommandPath(name);
|
|
466
516
|
if (!path) {
|
|
467
517
|
throw new Error(`${name} is not installed`);
|
|
468
518
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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, isMissingDatabaseError, 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
|
+
|
|
29
|
+
test("isMissingDatabaseError detects Neon database propagation failures", () => {
|
|
30
|
+
expect(isMissingDatabaseError(new Error('database "codex_workers" does not exist'))).toBe(true);
|
|
31
|
+
expect(isMissingDatabaseError(new Error("password authentication failed"))).toBe(false);
|
|
32
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
|
|
21
|
+
export function isMissingDatabaseError(error: unknown) {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
return /database ".+" does not exist/.test(message);
|
|
24
|
+
}
|
|
@@ -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
|
|