diff --git a/docs/manual-deploy.md b/docs/manual-deploy.md index 7e86c3ca..a3dddc5a 100644 --- a/docs/manual-deploy.md +++ b/docs/manual-deploy.md @@ -69,7 +69,7 @@ Before running `bun run deploy`, copy the D1 `database_id` and R2 bucket name in `.env.local` is read only by the local EdgeEver scripts. It is not automatically uploaded to Cloudflare as a file, and it must never be uploaded manually or committed to Git. A build started directly from Cloudflare Dashboard does not automatically receive the D1, R2, or password settings in this file. Use `bun run deploy` for the first deployment, then use `bun run deploy:builds:setup` to copy the required settings securely into Workers Builds. -The deployment command now runs `deploy:doctor` first and stops when the D1 ID, R2 bucket, or login password is missing. Remote D1 migrations run automatically in non-interactive mode, so no confirmation is required and a cancelled Wrangler prompt cannot be mistaken for success. After deployment, `deploy:verify` checks the remote D1 schema and the Worker's authentication Secret; a missing requirement makes deployment exit as failed. The Worker also fails closed: when a production instance has neither a password Secret nor an enabled user, it returns `auth_not_configured` instead of opening an unauthenticated workspace. `/api/health` checks D1 and authentication initialization; the instance is ready only when it returns `200` with `"ok": true`. +The deployment command now runs `deploy:doctor` first and stops when the D1 ID, R2 bucket, or login password is missing. Remote D1 migrations run automatically in non-interactive mode, so no confirmation is required and a cancelled Wrangler prompt cannot be mistaken for success. Before invoking Wrangler, the migration script also verifies that local SQL migrations exist and uses a cross-platform absolute migration directory, preventing successful-looking Windows commands that migrate no tables. After deployment, `deploy:verify` checks the remote D1 schema and the Worker's authentication Secret; a missing requirement makes deployment exit as failed. The Worker also fails closed: when a production instance has neither a password Secret nor an enabled user, it returns `auth_not_configured` instead of opening an unauthenticated workspace. `/api/health` checks D1 and authentication initialization; the instance is ready only when it returns `200` with `"ok": true`. Existing installations do not need to migrate. If you intentionally switch from the hash setting to `EDGE_EVER_AUTH_PASSWORD`, remove the old `EDGE_EVER_AUTH_PASSWORD_HASH` from `.env.local`, Workers Builds, and the Worker's runtime Secrets; otherwise the legacy hash remains authoritative. diff --git a/docs/manual-deploy.zh-CN.md b/docs/manual-deploy.zh-CN.md index 05d6bac4..77a30509 100644 --- a/docs/manual-deploy.zh-CN.md +++ b/docs/manual-deploy.zh-CN.md @@ -69,7 +69,7 @@ bun run deploy `.env.local` 只供本机 EdgeEver 脚本读取,不会作为文件自动上传到 Cloudflare,也不应手工上传或提交到 Git。若从 Cloudflare Dashboard 直接触发构建,该文件中的 D1、R2 和密码配置不会自动生效;请使用 `bun run deploy` 完成首次部署,再使用 `bun run deploy:builds:setup` 将必要配置安全地写入 Workers Builds。 -部署命令会先自动执行 `deploy:doctor`,缺少 D1 ID、R2 bucket 或登录密码时会直接停止。远程 D1 migration 会以非交互模式自动执行,不需要用户确认,避免取消操作被 Wrangler 误报为成功。部署完成后还会执行 `deploy:verify`,检查远程 D1 的关键数据表和 Worker 鉴权 Secret;任一缺失都会让部署以失败退出。Worker 也采用安全关闭策略:生产实例既没有密码 Secret、又没有有效用户时,会返回 `auth_not_configured`,不会降级成免登录工作区。`/api/health` 会检查 D1 和鉴权初始化状态;只有返回 `200` 且包含 `"ok": true` 才表示实例真正可用。 +部署命令会先自动执行 `deploy:doctor`,缺少 D1 ID、R2 bucket 或登录密码时会直接停止。远程 D1 migration 会以非交互模式自动执行,不需要用户确认,避免取消操作被 Wrangler 误报为成功。迁移脚本还会在调用 Wrangler 前确认本地 SQL migration 存在,并使用跨平台的绝对迁移目录,避免 Windows 上出现命令成功但没有迁移任何表的情况。部署完成后还会执行 `deploy:verify`,检查远程 D1 的关键数据表和 Worker 鉴权 Secret;任一缺失都会让部署以失败退出。Worker 也采用安全关闭策略:生产实例既没有密码 Secret、又没有有效用户时,会返回 `auth_not_configured`,不会降级成免登录工作区。`/api/health` 会检查 D1 和鉴权初始化状态;只有返回 `200` 且包含 `"ok": true` 才表示实例真正可用。 已有实例无需迁移。如果确实要从哈希配置切换为 `EDGE_EVER_AUTH_PASSWORD`,需要同时从 `.env.local`、Workers Builds 和 Worker 运行时 Secrets 中移除旧的 `EDGE_EVER_AUTH_PASSWORD_HASH`,否则旧哈希仍会优先生效。 diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index fc729466..d432ad6c 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { runWranglerSync } from "./wrangler-runner.mjs"; const PLACEHOLDER_D1_ID = "00000000-0000-0000-0000-000000000000"; @@ -57,6 +57,7 @@ if (requestedInstance !== undefined) { } const baseConfigPath = resolve(process.env.WRANGLER_CONFIG ?? "wrangler.toml"); +const baseConfigDirectory = dirname(baseConfigPath); const instance = process.env.EDGE_EVER_INSTANCE?.trim(); const instanceKey = instance?.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase(); const generatedConfigPath = resolve( @@ -73,6 +74,27 @@ const generatedLocalDevEnvPath = resolve(".env.wrangler.generated.local"); let config = readFileSync(baseConfigPath, "utf8"); let changed = false; +const migrationCommand = + wranglerArgs[0] === "d1" + && wranglerArgs[1] === "migrations" + && ["apply", "list"].includes(wranglerArgs[2]); +const configuredMigrationsDirectory = config.match(/^migrations_dir\s*=\s*"([^"]+)"/m)?.[1] + ?? "migrations"; +const migrationsDirectory = resolve(baseConfigDirectory, configuredMigrationsDirectory); + +if (migrationCommand) { + const migrationFiles = existsSync(migrationsDirectory) + ? readdirSync(migrationsDirectory).filter((name) => name.endsWith(".sql")).sort() + : []; + if (migrationFiles.length === 0) { + console.error( + `No D1 migration files found in ${migrationsDirectory}. Update the repository checkout before retrying.`, + ); + process.exit(1); + } + console.log(`[ok] local D1 migrations: ${migrationFiles.length} files`); +} + const replaceTomlValue = (source, key, value) => { if (!value) { return source; @@ -215,6 +237,13 @@ if (isRemoteCommand && config.includes(`database_id = "${PLACEHOLDER_D1_ID}"`)) const configPath = changed ? generatedConfigPath : baseConfigPath; if (changed) { + // Wrangler resolves migrations_dir relative to its config. Use an absolute, + // slash-normalized path so generated configs behave consistently in Windows + // Git Bash, PowerShell, Linux, and macOS. + config = config.replace( + /^migrations_dir\s*=\s*"[^"]+"/m, + `migrations_dir = ${tomlString(migrationsDirectory.replaceAll("\\", "/"))}`, + ); writeFileSync(generatedConfigPath, config); } diff --git a/scripts/verify-deployment.mjs b/scripts/verify-deployment.mjs index e4cea1fa..4d1d12aa 100644 --- a/scripts/verify-deployment.mjs +++ b/scripts/verify-deployment.mjs @@ -14,19 +14,38 @@ export const REQUIRED_TABLES = [ export const buildSchemaVerificationSql = () => `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${REQUIRED_TABLES.map((name) => `'${name}'`).join(", ")}) ORDER BY name`; +export const parseJsonOutput = (output, description) => { + if (!output.trim()) { + throw new Error(`Wrangler returned no JSON while checking ${description}.`); + } + + try { + return JSON.parse(output); + } catch (error) { + throw new Error( + `Wrangler returned invalid JSON while checking ${description}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + export const parseD1Rows = (output) => { - const parsed = JSON.parse(output); + // Some Wrangler versions emit an empty stdout for a successful D1 query + // with zero rows instead of the documented empty JSON result. + if (!output.trim()) { + return []; + } + const parsed = parseJsonOutput(output, "the remote D1 schema"); const results = Array.isArray(parsed) ? parsed : [parsed]; return results.flatMap((result) => result?.results ?? []); }; export const parseSecretNames = (output) => { - const parsed = JSON.parse(output); + const parsed = parseJsonOutput(output, "Worker Secrets"); const secrets = Array.isArray(parsed) ? parsed : parsed?.secrets ?? parsed?.result ?? []; return new Set(secrets.map((secret) => secret?.name).filter(Boolean)); }; -const runWrangler = (args) => { +const runWrangler = (args, options = {}) => { const result = spawnSync( process.execPath, [resolve("scripts/run-wrangler.mjs"), ...args], @@ -39,6 +58,13 @@ const runWrangler = (args) => { throw new Error(`Wrangler verification command exited with status ${result.status ?? 1}.`); } + if (!result.stdout.trim() && !options.allowEmptyOutput) { + if (result.stderr) process.stderr.write(result.stderr); + throw new Error( + "Wrangler completed without returning verification data. Re-run bun run deploy:doctor, then retry.", + ); + } + return result.stdout; }; @@ -51,7 +77,7 @@ const main = () => { "--command", buildSchemaVerificationSql(), "--json", - ]); + ], { allowEmptyOutput: true }); const tableNames = new Set(parseD1Rows(schemaOutput).map((row) => row.name)); const missingTables = REQUIRED_TABLES.filter((table) => !tableNames.has(table)); if (missingTables.length > 0) { diff --git a/tests/deployment-verify.test.ts b/tests/deployment-verify.test.ts index ee339cac..972e8f1e 100644 --- a/tests/deployment-verify.test.ts +++ b/tests/deployment-verify.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildSchemaVerificationSql, parseD1Rows, + parseJsonOutput, parseSecretNames, REQUIRED_TABLES, } from "../scripts/verify-deployment.mjs"; @@ -18,6 +19,16 @@ describe("deployment verification", () => { expect(parseD1Rows(JSON.stringify([{ results: [{ name: "users" }] }]))).toEqual([{ name: "users" }]); }); + test("reports empty Wrangler JSON output clearly", () => { + expect(() => parseJsonOutput("", "the remote D1 schema")).toThrow( + "Wrangler returned no JSON while checking the remote D1 schema.", + ); + }); + + test("treats an empty D1 result as zero rows", () => { + expect(parseD1Rows("")).toEqual([]); + }); + test("parses current and wrapped Wrangler secret output", () => { expect(parseSecretNames(JSON.stringify([{ name: "EDGE_EVER_AUTH_PASSWORD" }]))).toContain( "EDGE_EVER_AUTH_PASSWORD",