Improve AI agent Cloudflare deployment

This commit is contained in:
tianma committed 2026-07-01 23:16:57 +08:00
1 parent 761dbd8f7f
commit dca1762c68
7 files changed
+494 -9

No files matched your search

+11
View File
@@ -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
+17
View File
@@ -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` 为准,严禁在代理指令或代码中硬编码个人实例名。
+19 -8
View File
@@ -6,12 +6,6 @@
EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作区。它保留经典印象笔记的三栏体验,同时提供清晰的数据模型、REST API、OpenAPI schema 和 MCP endpoint。
<p align="center">
<a href="https://deploy.workers.cloudflare.com/?url=https://github.com/msh01/edgeever">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare" />
</a>
</p>
## 在线演示
- 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
```
## 目录结构
+141
View File
@@ -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: <repo-url>. Install and deploy this project to Cloudflare for me.
```
Chinese version:
```text
这个是 GitHub 上的项目链接:<repo-url>。请把这个项目安装部署到 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 <repo-url>
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='<first-login-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://<worker-url>/
curl https://<worker-url>/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=<name>` and use scoped variables such as:
```sh
EDGE_EVER_PROD_WORKER_NAME=edgeever-prod
EDGE_EVER_PROD_D1_DATABASE_ID=<database-id>
EDGE_EVER_PROD_R2_BUCKET_NAME=edgeever-prod-resources
EDGE_EVER_PROD_AUTH_PASSWORD_HASH=<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
+2
View File
@@ -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": {
+288
View File
@@ -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 <doctor|setup>");
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();
}
+16 -1
View File
@@ -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",
});