diff --git a/.env.local.example b/.env.local.example index ec0ce544..e09f8779 100644 --- a/.env.local.example +++ b/.env.local.example @@ -14,6 +14,9 @@ CLOUDFLARE_API_TOKEN= # 默认会部署为名叫 edgeever 的 Cloudflare Worker。 EDGE_EVER_WORKER_NAME=edgeever +# 是否启用 workers.dev 子域名。可选值:true / false。 +EDGE_EVER_WORKERS_DEV=true + # D1 数据库名称。 # 创建命令:`bunx wrangler d1 create edgeever` EDGE_EVER_D1_DATABASE_NAME=edgeever @@ -44,6 +47,14 @@ EDGE_EVER_AUTH_PASSWORD_HASH= # Web session 有效期,单位:天。服务端会限制在 1-90 天。 EDGE_EVER_SESSION_TTL_DAYS=30 +# 可选:自定义域名。设置后部署脚本会生成 custom_domain route。 +# 例如:notes.example.com +EDGE_EVER_CUSTOM_DOMAIN= + +# 可选:完整 route pattern。未设置时会回退使用 EDGE_EVER_CUSTOM_DOMAIN。 +# 例如:notes.example.com/* +EDGE_EVER_ROUTE_PATTERN= + # 前端访问 API 的基础地址。 # 本地开发默认指向 Wrangler dev:`bun run dev:api` VITE_API_BASE_URL=http://127.0.0.1:8787 diff --git a/AGENTS.md b/AGENTS.md index a56011ed..7662c4d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,23 @@ 严禁创建新的 Git 分支;所有修改、提交和推送都必须直接在 `main` 分支上完成。 +## Cloudflare 自动部署约束 + +当用户要求“根据 GitHub 项目链接把本项目安装部署到 Cloudflare”时,应优先按 `docs/agent-deploy-cloudflare.md` 执行。 + +推荐流程: + +```sh +bun install +EDGE_EVER_PASSWORD='<首次登录密码>' bun run deploy:setup +bun run deploy:doctor +bun run deploy +``` + +如果用户没有提供首次登录密码,应只询问这一个必要信息,或在用户同意后生成随机密码。Cloudflare 授权、账号、D1/R2 资源、Worker 名称、自定义域名等私有配置必须来自用户环境、Cloudflare MCP/插件、Wrangler 登录态或 `.env.local`,严禁硬编码到仓库文件。 + +部署脚本必须通过 `scripts/run-wrangler.mjs` 读取 `.env.local` 并生成临时 Wrangler 配置。不要直接修改 `wrangler.toml` 来写入个人 `database_id`、bucket 名称、Worker 名称或 route。 + ## 本地启动约束 本地预览或调试时,必须优先使用 `bun run dev` 启动完整开发环境,让 API 通过 `scripts/run-wrangler.mjs` 读取 `.env.local` 中的个性化实例配置。实例名称、D1/R2 资源、账号等本机私有配置均以 `.env.local` 为准,严禁在代理指令或代码中硬编码个人实例名。 diff --git a/README.md b/README.md index 3b87c172..436476ad 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,6 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作区。它保留经典印象笔记的三栏体验,同时提供清晰的数据模型、REST API、OpenAPI schema 和 MCP endpoint。 -

- - Deploy to Cloudflare - -

- ## 在线演示 - Demo 地址:[https://demo.edgeever.org](https://demo.edgeever.org) @@ -75,19 +69,36 @@ bun run build ## 部署 -最简单的方式是点击上方 **Deploy to Cloudflare** 按钮,根据 Cloudflare 向导完成授权和部署。 +### AI Agent 一句话部署 + +如果你使用 Codex、Claude Code、Antigravity 等 AI Agent,并且已经登录 Cloudflare 或安装了 Cloudflare 相关插件,可以直接把下面这句话发给 Agent: + +```text +这个是 GitHub 上的项目链接:https://github.com/msh01/edgeever。请把这个项目安装部署到 Cloudflare 上。 +``` + +Agent 应优先按 [AI Agent Cloudflare Deployment](docs/agent-deploy-cloudflare.md) 执行,只在需要 Cloudflare 授权、首次登录密码或自定义域名时询问你。 + +### 手动部署 如果使用 CLI 部署: ```sh cp .env.local.example .env.local +bun install bunx wrangler d1 create edgeever bunx wrangler r2 bucket create edgeever-resources bun run auth:hash -- <你的密码> bun run deploy ``` -把 D1 创建命令返回的 `database_id` 和密码 hash 填入本机 `.env.local`。 +把 D1 创建命令返回的 `database_id` 和密码 hash 填入本机 `.env.local`。也可以使用自动化辅助命令: + +```sh +EDGE_EVER_PASSWORD='<你的密码>' bun run deploy:setup +bun run deploy:doctor +bun run deploy +``` ## 目录结构 diff --git a/docs/agent-deploy-cloudflare.md b/docs/agent-deploy-cloudflare.md new file mode 100644 index 00000000..99be3c96 --- /dev/null +++ b/docs/agent-deploy-cloudflare.md @@ -0,0 +1,141 @@ +# AI Agent Cloudflare Deployment + +This document is the deployment contract for AI agents. If a user asks an agent to deploy EdgeEver from a GitHub URL to Cloudflare, follow this runbook before asking the user for anything. + +## One Prompt Install + +Users should be able to start with only this prompt: + +```text +This is the GitHub project URL: . Install and deploy this project to Cloudflare for me. +``` + +Chinese version: + +```text +这个是 GitHub 上的项目链接:。请把这个项目安装部署到 Cloudflare 上。 +``` + +## Agent Rules + +- Read `AGENTS.md`, `README.md`, `.env.local.example`, and this file first. +- Do not create a new Git branch. Work on `main`. +- Prefer `bun run deploy:setup`, `bun run deploy:doctor`, and `bun run deploy`. +- Do not hard-code a personal Worker name, D1 database ID, R2 bucket name, account ID, API token, or domain in source files. +- Use `.env.local` for local/private deployment values. It is git-ignored. +- If the Cloudflare MCP or Cloudflare plugin is available, it may be used to inspect or create resources. If not, use Wrangler through the scripts in this repo. +- Ask the user only for values that cannot be inferred or generated safely, such as Cloudflare authorization, the first login password, or custom domain ownership. + +## Required Tools + +- Bun +- Wrangler, installed by `bun install` as a project dev dependency +- A Cloudflare account authorized by either `wrangler login` or `CLOUDFLARE_API_TOKEN` + +## Standard Flow + +1. Clone the repository and enter it. + + ```sh + git clone + cd edgeever + ``` + +2. Install dependencies. + + ```sh + bun install + ``` + +3. Ensure Cloudflare authentication. + + ```sh + bunx wrangler whoami + ``` + + If this fails, ask the user to finish Cloudflare login or provide a suitable API token. Do not continue deployment until this works. + +4. Prepare deployment resources and `.env.local`. + + If the user gave a first login password, run: + + ```sh + EDGE_EVER_PASSWORD='' bun run deploy:setup + ``` + + If no password was provided, ask for one or ask permission to generate a random password. Then rerun the command with `EDGE_EVER_PASSWORD`. + + `deploy:setup` will: + + - copy `.env.local.example` to `.env.local` when needed + - reuse or create the D1 database + - create the R2 buckets when needed + - generate `EDGE_EVER_AUTH_PASSWORD_HASH` from `EDGE_EVER_PASSWORD` + +5. Check the deployment inputs. + + ```sh + bun run deploy:doctor + ``` + + Fix every `fail` result before deploying. + +6. Deploy. + + ```sh + bun run deploy + ``` + + `bun run deploy` builds the web app, applies remote D1 migrations, and deploys the Worker. During deploy, `scripts/run-wrangler.mjs` uploads `EDGE_EVER_AUTH_PASSWORD_HASH` as a Worker secret via a generated `.env.wrangler.generated*.secrets` file. + +7. Verify the result. + + Use the Worker URL from Wrangler output, then check: + + ```sh + curl -I https:/// + curl https:///api/openapi.json + ``` + + Open the site, log in with `EDGE_EVER_AUTH_USERNAME` and the first login password, then create an MCP token from the in-app MCP settings. + +## Optional Customization + +Set these values in `.env.local` before `bun run deploy:setup` or `bun run deploy`: + +```sh +EDGE_EVER_WORKER_NAME=edgeever +EDGE_EVER_D1_DATABASE_NAME=edgeever +EDGE_EVER_R2_BUCKET_NAME=edgeever-resources +EDGE_EVER_R2_PREVIEW_BUCKET_NAME=edgeever-resources-preview +EDGE_EVER_AUTH_USERNAME=admin +EDGE_EVER_CUSTOM_DOMAIN=notes.example.com +``` + +For multiple instances, set `EDGE_EVER_INSTANCE=` and use scoped variables such as: + +```sh +EDGE_EVER_PROD_WORKER_NAME=edgeever-prod +EDGE_EVER_PROD_D1_DATABASE_ID= +EDGE_EVER_PROD_R2_BUCKET_NAME=edgeever-prod-resources +EDGE_EVER_PROD_AUTH_PASSWORD_HASH= +``` + +## Blocking Conditions + +Stop and ask the user only when: + +- Cloudflare authentication is missing and the agent cannot open or complete login. +- The user must choose or provide the first login password. +- The requested custom domain is not available in the Cloudflare account. +- Resource creation fails because of account limits, permissions, billing, or conflicting names that cannot be resolved by choosing a new name. + +## Final User Response + +After deployment, report: + +- deployed URL +- login username +- whether the password was user-provided or generated +- where to create the EdgeEver MCP token in the app +- any custom domain or Cloudflare DNS step that remains diff --git a/package.json b/package.json index ac8b2da6..c420ecb2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "cli": "bun scripts/edgeever.mjs", "import:evernote": "bun scripts/import-evernote-enex-via-mcp.mjs", "mcp:stdio": "bun scripts/edgeever-mcp-stdio.mjs", + "deploy:doctor": "bun scripts/cloudflare-deploy.mjs doctor", + "deploy:setup": "bun scripts/cloudflare-deploy.mjs setup", "deploy": "bun run build && bun run db:migrate:remote && bun scripts/run-wrangler.mjs deploy" }, "cloudflare": { diff --git a/scripts/cloudflare-deploy.mjs b/scripts/cloudflare-deploy.mjs new file mode 100644 index 00000000..7efe81db --- /dev/null +++ b/scripts/cloudflare-deploy.mjs @@ -0,0 +1,288 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const PLACEHOLDER_D1_ID = "00000000-0000-0000-0000-000000000000"; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const PASSWORD_HASH_PATTERN = /^pbkdf2-sha256\$100000\$[^$]+\$[^$]+$/; + +const command = process.argv[2] ?? "doctor"; +if (!["doctor", "setup"].includes(command)) { + console.error("Usage: bun scripts/cloudflare-deploy.mjs "); + process.exit(1); +} + +const envPath = resolve(".env.local"); +const envExamplePath = resolve(".env.local.example"); +const localWrangler = resolve( + "node_modules", + ".bin", + process.platform === "win32" ? "wrangler.cmd" : "wrangler", +); +const wrangler = existsSync(localWrangler) + ? localWrangler + : process.platform === "win32" + ? "wrangler.cmd" + : "wrangler"; + +const parseEnv = (content) => { + const values = new Map(); + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { + continue; + } + + const separatorIndex = trimmed.indexOf("="); + const key = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values.set(key, value); + } + + return values; +}; + +const readEnv = () => { + const values = existsSync(envPath) + ? parseEnv(readFileSync(envPath, "utf8")) + : new Map(); + + for (const [key, value] of values) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + + return values; +}; + +const scopedKey = (name, values) => { + const instance = (values.get("EDGE_EVER_INSTANCE") || process.env.EDGE_EVER_INSTANCE || "") + .trim() + .replace(/[^a-zA-Z0-9]/g, "_") + .toUpperCase(); + return instance ? `EDGE_EVER_${instance}_${name}` : undefined; +}; + +const envValue = (name, values) => { + const scoped = scopedKey(name, values); + return ( + (scoped ? values.get(scoped) || process.env[scoped] : undefined) || + values.get(`EDGE_EVER_${name}`) || + process.env[`EDGE_EVER_${name}`] || + "" + ).trim(); +}; + +const targetKey = (name, values) => scopedKey(name, values) || `EDGE_EVER_${name}`; + +const upsertEnv = (key, value) => { + const content = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""; + const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=.*$`, "m"); + const next = pattern.test(content) + ? content.replace(pattern, `${key}=${value}`) + : `${content.trimEnd()}\n${key}=${value}\n`; + + writeFileSync(envPath, next.startsWith("\n") ? next.slice(1) : next); + process.env[key] = value; +}; + +const run = (executable, args, options = {}) => + spawnSync(executable, args, { + cwd: resolve("."), + encoding: "utf8", + env: process.env, + shell: process.platform === "win32", + ...options, + }); + +const runWrangler = (args) => run(wrangler, args); + +const check = (label, passed, detail = "") => { + const status = passed ? "ok" : "fail"; + console.log(`[${status}] ${label}${detail ? `: ${detail}` : ""}`); + return passed; +}; + +const ensureEnvLocal = () => { + if (existsSync(envPath)) { + return; + } + + copyFileSync(envExamplePath, envPath); + console.log("[ok] created .env.local from .env.local.example"); +}; + +const extractUuid = (text) => { + const assignment = text.match(/database_id\s*=\s*"([^"]+)"/); + if (assignment?.[1] && UUID_PATTERN.test(assignment[1])) { + return assignment[1]; + } + + const match = text.match(UUID_PATTERN); + return match?.[0] ?? ""; +}; + +const findD1DatabaseId = (databaseName) => { + const result = runWrangler(["d1", "list", "--json"]); + if (result.status !== 0) { + return ""; + } + + try { + const databases = JSON.parse(result.stdout); + const database = Array.isArray(databases) + ? databases.find((item) => item?.name === databaseName) + : undefined; + return database?.uuid || database?.id || ""; + } catch { + return ""; + } +}; + +const ensureD1 = (values) => { + const currentId = envValue("D1_DATABASE_ID", values); + if (currentId && currentId !== PLACEHOLDER_D1_ID) { + return check("D1 database id", UUID_PATTERN.test(currentId), currentId); + } + + const databaseName = envValue("D1_DATABASE_NAME", values) || "edgeever"; + const existingId = findD1DatabaseId(databaseName); + if (existingId) { + upsertEnv(targetKey("D1_DATABASE_ID", values), existingId); + console.log(`[ok] reused D1 database ${databaseName}`); + return true; + } + + const result = runWrangler(["d1", "create", databaseName]); + const output = `${result.stdout}\n${result.stderr}`; + if (result.status !== 0) { + console.error(output.trim()); + return check("create D1 database", false, `set ${targetKey("D1_DATABASE_ID", values)} manually`); + } + + const databaseId = extractUuid(output); + if (!databaseId) { + console.error(output.trim()); + return check("read D1 database id", false, "could not parse wrangler output"); + } + + upsertEnv(targetKey("D1_DATABASE_ID", values), databaseId); + console.log(`[ok] created D1 database ${databaseName}`); + return true; +}; + +const ensureR2 = (values, name) => { + const bucketName = envValue(name, values); + if (!bucketName) { + return name === "R2_PREVIEW_BUCKET_NAME" || check(`${name}`, false, "missing bucket name"); + } + + const result = runWrangler(["r2", "bucket", "create", bucketName]); + const output = `${result.stdout}\n${result.stderr}`.trim(); + if (result.status === 0 || /already exists|binding already exists|bucket.+exists/i.test(output)) { + console.log(`[ok] R2 bucket ${bucketName}`); + return true; + } + + console.error(output); + return check(`create R2 bucket ${bucketName}`, false); +}; + +const ensurePasswordHash = (values) => { + const currentHash = envValue("AUTH_PASSWORD_HASH", values); + if (currentHash) { + return check("auth password hash", PASSWORD_HASH_PATTERN.test(currentHash), "configured"); + } + + const password = process.env.EDGE_EVER_PASSWORD?.trim(); + if (!password) { + return check( + "auth password hash", + false, + "set EDGE_EVER_PASSWORD and rerun setup, or set EDGE_EVER_AUTH_PASSWORD_HASH", + ); + } + + const result = run("bun", ["scripts/hash-password.mjs"], { + env: { ...process.env, EDGE_EVER_PASSWORD: password }, + }); + if (result.status !== 0) { + console.error(result.stderr.trim()); + return check("generate auth password hash", false); + } + + const passwordHash = result.stdout.trim(); + upsertEnv(targetKey("AUTH_PASSWORD_HASH", values), passwordHash); + console.log("[ok] generated auth password hash"); + return true; +}; + +const doctor = () => { + const values = readEnv(); + let passed = true; + + passed = check("Bun", run("bun", ["--version"]).status === 0) && passed; + passed = check("Wrangler", runWrangler(["--version"]).status === 0) && passed; + passed = check(".env.local", existsSync(envPath), existsSync(envPath) ? "present" : "missing") && passed; + + const whoami = runWrangler(["whoami"]); + passed = check("Cloudflare auth", whoami.status === 0, whoami.status === 0 ? "authenticated" : "run wrangler login") && passed; + + const databaseId = envValue("D1_DATABASE_ID", values); + passed = + check( + "D1 database id", + Boolean(databaseId && databaseId !== PLACEHOLDER_D1_ID && UUID_PATTERN.test(databaseId)), + databaseId && databaseId !== PLACEHOLDER_D1_ID ? databaseId : "missing", + ) && passed; + + passed = + check("R2 bucket name", Boolean(envValue("R2_BUCKET_NAME", values)), envValue("R2_BUCKET_NAME", values) || "missing") && + passed; + + const passwordHash = envValue("AUTH_PASSWORD_HASH", values); + passed = + check( + "auth password hash", + Boolean(passwordHash && PASSWORD_HASH_PATTERN.test(passwordHash)), + passwordHash ? "configured" : "missing", + ) && passed; + + process.exit(passed ? 0 : 1); +}; + +const setup = () => { + ensureEnvLocal(); + const values = readEnv(); + let passed = true; + + passed = check("Wrangler", runWrangler(["--version"]).status === 0) && passed; + const whoami = runWrangler(["whoami"]); + passed = check("Cloudflare auth", whoami.status === 0, whoami.status === 0 ? "authenticated" : "run wrangler login") && passed; + + if (!passed) { + process.exit(1); + } + + passed = ensureD1(values) && passed; + passed = ensureR2(values, "R2_BUCKET_NAME") && passed; + passed = ensureR2(values, "R2_PREVIEW_BUCKET_NAME") && passed; + passed = ensurePasswordHash(values) && passed; + + process.exit(passed ? 0 : 1); +}; + +if (command === "setup") { + setup(); +} else { + doctor(); +} diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index c7da85bc..6870f068 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -51,6 +51,11 @@ const generatedConfigPath = resolve( ? `.wrangler.generated.${instanceKey.toLowerCase()}.toml` : ".wrangler.generated.toml", ); +const generatedSecretsPath = resolve( + instanceKey + ? `.env.wrangler.generated.${instanceKey.toLowerCase()}.secrets` + : ".env.wrangler.generated.secrets", +); let config = readFileSync(baseConfigPath, "utf8"); let changed = false; @@ -164,6 +169,16 @@ if (changed) { writeFileSync(generatedConfigPath, config); } +const isDeployCommand = wranglerArgs.includes("deploy"); +const hasSecretsFileArg = wranglerArgs.some((arg) => arg === "--secrets-file" || arg.startsWith("--secrets-file=")); +const authPasswordHash = envValue("AUTH_PASSWORD_HASH"); +const finalWranglerArgs = [...wranglerArgs]; + +if (isDeployCommand && authPasswordHash && !hasSecretsFileArg) { + writeFileSync(generatedSecretsPath, `EDGE_EVER_AUTH_PASSWORD_HASH=${authPasswordHash}\n`); + finalWranglerArgs.push("--secrets-file", generatedSecretsPath); +} + const localWrangler = resolve( "node_modules", ".bin", @@ -174,7 +189,7 @@ const executable = existsSync(localWrangler) : process.platform === "win32" ? "wrangler.cmd" : "wrangler"; -const result = spawnSync(executable, ["--config", configPath, ...wranglerArgs], { +const result = spawnSync(executable, ["--config", configPath, ...finalWranglerArgs], { stdio: "inherit", shell: process.platform === "win32", });