From eec26a09a332b213e548fb51a13112ec4be81263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Apr 2026 14:52:54 -0400 Subject: [PATCH] fix(release): execute d1 migrations without shell --- scripts/release-public-cloudflare.mjs | 21 +--- scripts/release/lib/d1-migrations.mjs | 129 ++++++++++++++++++++++++ scripts/resume-release-public.mjs | 15 +-- tests/unit/d1-migrations.test.ts | 136 ++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 31 deletions(-) create mode 100644 scripts/release/lib/d1-migrations.mjs create mode 100644 tests/unit/d1-migrations.test.ts diff --git a/scripts/release-public-cloudflare.mjs b/scripts/release-public-cloudflare.mjs index 14b3796d..f64aa789 100644 --- a/scripts/release-public-cloudflare.mjs +++ b/scripts/release-public-cloudflare.mjs @@ -7,6 +7,7 @@ import { loadEnvFile } from "./release/lib/dotenv.mjs"; import { createRunner } from "./release/lib/process.mjs"; import { createWorkflowHelpers } from "./release/lib/workflow.mjs"; import { createSnapshotHelpers } from "./release/lib/snapshot.mjs"; +import { applyD1Migrations } from "./release/lib/d1-migrations.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); @@ -221,25 +222,7 @@ async function uploadWinAndLinuxToR2() { } async function applyLandingMigrations() { - const migrationsDir = join(landingDir, "migrations"); - if (!existsSync(migrationsDir)) { - return; - } - - const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); - const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/); - if (!match) { - return; - } - const databaseName = match[1]; - const migrationFiles = (await readdir(migrationsDir)) - .filter((fileName) => fileName.endsWith(".sql")) - .sort(); - - for (const fileName of migrationFiles) { - const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`; - await run("bash", ["-lc", command], { cwd: landingDir }); - } + await applyD1Migrations({ landingDir, run }); } async function buildAndDeployLanding() { diff --git a/scripts/release/lib/d1-migrations.mjs b/scripts/release/lib/d1-migrations.mjs new file mode 100644 index 00000000..a4d3d8dc --- /dev/null +++ b/scripts/release/lib/d1-migrations.mjs @@ -0,0 +1,129 @@ +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +const databaseNamePattern = /^[A-Za-z0-9_-]+$/; +const migrationFilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\.sql$/; + +function stripTomlComment(line) { + let inString = false; + let escaped = false; + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "\"") { + inString = !inString; + continue; + } + if (char === "#" && !inString) { + return line.slice(0, i); + } + } + return line; +} + +function parseTomlString(value, key) { + const trimmed = value.trim(); + if (!trimmed.startsWith("\"") || !trimmed.endsWith("\"")) { + throw new Error(`wrangler.toml key "${key}" must be a quoted string.`); + } + try { + return JSON.parse(trimmed); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`wrangler.toml key "${key}" has invalid string syntax: ${detail}`); + } +} + +function parseAssignment(line) { + const equals = line.indexOf("="); + if (equals === -1) return null; + const key = line.slice(0, equals).trim(); + const value = line.slice(equals + 1).trim(); + if (!key) return null; + return { key, value }; +} + +export function validateD1DatabaseName(databaseName) { + if (!databaseNamePattern.test(databaseName)) { + throw new Error( + `Invalid Cloudflare D1 database_name "${databaseName}". Use only letters, numbers, underscores, and hyphens.`, + ); + } + return databaseName; +} + +export function validateD1MigrationFileName(fileName) { + if (!migrationFilePattern.test(fileName)) { + throw new Error( + `Invalid D1 migration file name "${fileName}". Use a plain .sql file name with letters, numbers, dots, underscores, and hyphens.`, + ); + } + return fileName; +} + +export function parseD1DatabaseName(wranglerToml) { + let inD1Database = false; + for (const rawLine of wranglerToml.split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line) continue; + if (line.startsWith("[[")) { + inD1Database = line === "[[d1_databases]]"; + continue; + } + if (line.startsWith("[")) { + inD1Database = false; + continue; + } + if (!inD1Database) continue; + const assignment = parseAssignment(line); + if (!assignment || assignment.key !== "database_name") continue; + return validateD1DatabaseName( + parseTomlString(assignment.value, assignment.key), + ); + } + throw new Error("landing/wrangler.toml does not define [[d1_databases]].database_name."); +} + +export async function listD1MigrationFiles(migrationsDir) { + return (await readdir(migrationsDir)) + .filter((fileName) => fileName.endsWith(".sql")) + .map(validateD1MigrationFileName) + .sort(); +} + +export async function applyD1Migrations({ landingDir, run }) { + const migrationsDir = join(landingDir, "migrations"); + if (!existsSync(migrationsDir)) { + return { databaseName: null, applied: 0 }; + } + + const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); + const databaseName = parseD1DatabaseName(wranglerToml); + const migrationFiles = await listD1MigrationFiles(migrationsDir); + + for (const fileName of migrationFiles) { + await run( + "npx", + [ + "wrangler", + "d1", + "execute", + databaseName, + "--remote", + "--file", + `migrations/${fileName}`, + ], + { cwd: landingDir, input: "y\n" }, + ); + } + + return { databaseName, applied: migrationFiles.length }; +} diff --git a/scripts/resume-release-public.mjs b/scripts/resume-release-public.mjs index be9ea65e..6867ceed 100644 --- a/scripts/resume-release-public.mjs +++ b/scripts/resume-release-public.mjs @@ -1,11 +1,12 @@ import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs"; import { loadEnvFile } from "./release/lib/dotenv.mjs"; import { createRunner } from "./release/lib/process.mjs"; import { createWorkflowHelpers } from "./release/lib/workflow.mjs"; +import { applyD1Migrations } from "./release/lib/d1-migrations.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, ".."); @@ -37,17 +38,7 @@ async function uploadWinAndLinuxToR2() { } async function applyLandingMigrations() { - const migrationsDir = join(landingDir, "migrations"); - if (!existsSync(migrationsDir)) return; - const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); - const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/); - if (!match) return; - const databaseName = match[1]; - const migrationFiles = (await readdir(migrationsDir)).filter((n) => n.endsWith(".sql")).sort(); - for (const fileName of migrationFiles) { - const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`; - await run("bash", ["-lc", command], { cwd: landingDir }); - } + await applyD1Migrations({ landingDir, run }); } async function buildAndDeployLanding() { diff --git a/tests/unit/d1-migrations.test.ts b/tests/unit/d1-migrations.test.ts new file mode 100644 index 00000000..99e13562 --- /dev/null +++ b/tests/unit/d1-migrations.test.ts @@ -0,0 +1,136 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +interface D1MigrationModule { + applyD1Migrations(input: { + landingDir: string; + run: ( + command: string, + args: string[], + options: { cwd: string; input: string }, + ) => Promise; + }): Promise<{ databaseName: string | null; applied: number }>; + parseD1DatabaseName(source: string): string; + validateD1MigrationFileName(fileName: string): string; +} + +const tempDirs: string[] = []; + +async function loadModule(): Promise { + const moduleUrl = pathToFileURL( + join(process.cwd(), "scripts/release/lib/d1-migrations.mjs"), + ).href; + return await import(moduleUrl) as D1MigrationModule; +} + +async function createLandingFixture() { + const landingDir = await mkdtemp(join(tmpdir(), "agentcompany-d1-test-")); + tempDirs.push(landingDir); + await mkdir(join(landingDir, "migrations"), { recursive: true }); + await writeFile( + join(landingDir, "wrangler.toml"), + [ + "name = \"agentcompany\"", + "", + "[[d1_databases]]", + "binding = \"DOWNLOAD_COUNTER_DB\"", + "database_name = \"agentcompany-landing-downloads\"", + "database_id = \"dfa3f0f3-009b-4436-b519-5b50ae6b1cf9\"", + "", + ].join("\n"), + "utf8", + ); + return landingDir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +describe("Cloudflare D1 migration release helper", () => { + it("parses the D1 database name from the wrangler D1 table", async () => { + const { parseD1DatabaseName } = await loadModule(); + + expect(parseD1DatabaseName([ + "[vars]", + "DOWNLOAD_COUNTER_SALT = \"salt\"", + "", + "[[d1_databases]]", + "binding = \"DOWNLOAD_COUNTER_DB\"", + "database_name = \"agentcompany-landing-downloads\" # production", + ].join("\n"))).toBe("agentcompany-landing-downloads"); + }); + + it("rejects shell-shaped database names", async () => { + const { parseD1DatabaseName } = await loadModule(); + + expect(() => parseD1DatabaseName([ + "[[d1_databases]]", + "database_name = \"prod; rm -rf /\"", + ].join("\n"))).toThrow("Invalid Cloudflare D1 database_name"); + }); + + it("rejects path-shaped migration file names", async () => { + const { validateD1MigrationFileName } = await loadModule(); + + expect(() => validateD1MigrationFileName("../0001.sql")).toThrow( + "Invalid D1 migration file name", + ); + expect(() => validateD1MigrationFileName("0001;touch.sql")).toThrow( + "Invalid D1 migration file name", + ); + }); + + it("executes wrangler through argv and confirms through stdin", async () => { + const { applyD1Migrations } = await loadModule(); + const landingDir = await createLandingFixture(); + await writeFile( + join(landingDir, "migrations", "0001_download_counter.sql"), + "SELECT 1;", + "utf8", + ); + await writeFile( + join(landingDir, "migrations", "notes.txt"), + "ignored", + "utf8", + ); + const calls: Array<{ + command: string; + args: string[]; + options: { cwd: string; input: string }; + }> = []; + + const result = await applyD1Migrations({ + landingDir, + run: async (command, args, options) => { + calls.push({ command, args, options }); + return {}; + }, + }); + + expect(result).toEqual({ + databaseName: "agentcompany-landing-downloads", + applied: 1, + }); + expect(calls).toEqual([ + { + command: "npx", + args: [ + "wrangler", + "d1", + "execute", + "agentcompany-landing-downloads", + "--remote", + "--file", + "migrations/0001_download_counter.sql", + ], + options: { cwd: landingDir, input: "y\n" }, + }, + ]); + }); +});