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。
-
-
-
-
-
-
## 在线演示
- 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",
});