Files

324 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 一键部署(给完全没碰过 Cloudflare 的人用)
//
// 用法:在这个文件夹里打开命令行,输入
//
// node deploy.mjs
//
// 它会按顺序做完这七件事,每一步都会在屏幕上说清楚现在在干什么:
// 1. 检查 Node.js 版本(要 18 以上)
// 2. 检查有没有登录 Cloudflare;没有就打开浏览器让你点「同意」
// 3. 建一个免费的 D1 数据库,并把它的 id 自动写进 wrangler.toml
// 4. 生成一把加密密钥并设置成 Worker Secret(账号密码/Cookie 就是用它加密的)
// 5. 部署前把关(database_id 必须真的是 id,不能还是占位符)
// 6. 部署,并把你的面板网址打印出来
// 7. 部署完当场验收:首页能不能打开、数据库通不通(不合格会把排错命令列出来)
//
// 中途任何一步失败都会停下来并告诉你怎么处理,不会带着半成品往下跑。
//
// 想先看看「配置齐了没、能不能部署」而不要真的发布,加一个 --check:
//
// node deploy.mjs --check
//
// 它只读不写:检查 Node 版本、wrangler 登录状态、wrangler.toml 里的 database_id、
// ENCRYPT_KEY 有没有设置,然后告诉你还差点什么(GitHub Actions 里的部署检查用的就是它)。
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(fileURLToPath(import.meta.url));
const TOML = join(ROOT, 'wrangler.toml');
const DB_NAME = 'daily-checkin-panel'; // 要和 wrangler.toml 里的 database_name 一致
const SECRET_NAME = 'ENCRYPT_KEY';
const CHECK_ONLY = process.argv.includes('--check'); // 只体检、不发布
const c = {
dim: (s) => `\x1b[2m${s}\x1b[0m`,
bold: (s) => `\x1b[1m${s}\x1b[0m`,
green: (s) => `\x1b[32m${s}\x1b[0m`,
red: (s) => `\x1b[31m${s}\x1b[0m`,
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
};
let stepNo = 0;
function step(title) {
stepNo++;
console.log('\n' + c.bold(`【第 ${stepNo} 步】${title}`));
}
function ok(msg) { console.log(c.green(' ✅ ') + msg); }
function info(msg) { console.log(c.dim(' ' + msg)); }
function warn(msg) { console.log(c.yellow(' ⚠️ ') + msg); }
function die(msg, hint) {
console.log('\n' + c.red('❌ ' + msg));
if (hint) console.log('\n' + hint);
console.log('');
process.exit(1);
}
// 调 wrangler。capture=true 时把输出收起来自己解析,否则直接把输出给用户看。
function wrangler(args, { capture = false, input } = {}) {
const full = ['wrangler@latest', ...args];
const r = spawnSync('npx', full, {
cwd: ROOT,
shell: true, // Windows 上是 npx.cmd,必须走 shell 才找得到
encoding: 'utf8',
stdio: capture ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'inherit'],
input,
maxBuffer: 32 * 1024 * 1024,
});
// status === null 表示「命令压根没跑起来」(这台机器上没有 npx)。
// 以前这种情况会把后面每一步都误导到别的报错上去,这里直接说清楚。
if (r.status === null) {
die('没法运行 npx(多半是这台机器上没装 Node.js,或没装好)',
'先在命令行里试试这两条,都能打印版本号才算正常:\n\n node -v\n npx -v\n\n'
+ '报「不是内部或外部命令 / command not found」的话,去 https://nodejs.org/ 下载 LTS 版装上,'
+ '再重新打开命令行,然后跑:node deploy.mjs');
}
return r;
}
// 部署前把关:wrangler.toml 里必须是真 id,不能还是占位符。
// 否则部署本身会成功,但面板一打开就报错 —— 那种「部署成功却不能用」最难查。
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// ---------- 第 1 步:Node 版本 ----------
step('检查运行环境');
{
const major = Number(process.versions.node.split('.')[0]);
if (Number.isNaN(major) || major < 18) {
die(`Node.js 版本太低(现在是 ${process.versions.node},需要 18 或更高)`,
'去 https://nodejs.org/ 下载 LTS 版,装完重新打开命令行,再跑一次 node deploy.mjs');
}
ok(`Node.js ${process.versions.node}`);
const toml = readFileSync(TOML, 'utf8');
if (!/database_name\s*=\s*"([^"]+)"/.test(toml)) {
die('wrangler.toml 里找不到 database_name,这个文件可能被改坏了',
'重新下载一份本项目,或把 wrangler.toml 里的 database_name 改回 "' + DB_NAME + '"');
}
}
// ---------- 第 2 步:登录 Cloudflare ----------
step('登录 Cloudflare(免费账号就行,不用绑卡)');
let cloudflareLoggedIn = false;
{
const who = wrangler(['whoami'], { capture: true });
const out = `${who.stdout || ''}${who.stderr || ''}`;
const loggedIn = who.status === 0 && !/not authenticated|You are not authenticated|not logged in/i.test(out);
cloudflareLoggedIn = loggedIn;
if (loggedIn) {
const who2 = (out.match(/[^\s@]+@[^\s@]+\.[^\s@]+/) || [])[0];
ok('已经登录过了' + (who2 ? '(' + who2 + ')' : ''));
} else if (CHECK_ONLY) {
// --check 是「只读体检」:绝不能去拉一个登录授权流程(会弹浏览器、也会在 CI 里挂住)。
warn('还没登录 Cloudflare —— 正式部署时这一步会自动打开浏览器让你点「允许」');
} else {
info('还没登录,下面会打开浏览器,请点「允许 / Allow」。');
info('如果浏览器没自动打开,屏幕上会有一个链接,复制到浏览器里打开。');
const login = wrangler(['login']);
if (login.status !== 0) {
die('登录没有完成',
'手动跑一下这条命令看提示:\n\n npx wrangler@latest login\n\n登录成功后再跑 node deploy.mjs');
}
ok('登录成功');
}
}
// ---------- 第 3 步:D1 数据库 ----------
if (CHECK_ONLY) {
step('准备数据库(--check 模式:只看现状,不创建、不修改)');
if (!cloudflareLoggedIn) {
warn('没登录 Cloudflare,没法列出数据库(正式部署时它会自动建库并填好 id)');
} else {
const list = wrangler(['d1', 'list'], { capture: true });
const text = `${list.stdout || ''}${list.stderr || ''}`;
if (text.includes(DB_NAME)) ok(`已经有一个叫 ${DB_NAME} 的数据库`);
else warn(`没找到叫 ${DB_NAME} 的数据库 —— 正式部署时这一步会自动帮你建`);
}
} else {
step('准备数据库(D1,免费额度足够签到面板用)');
let databaseId = '';
{
info(`正在尝试创建数据库 ${DB_NAME} …`);
const create = wrangler(['d1', 'create', DB_NAME], { capture: true });
const out = `${create.stdout || ''}\n${create.stderr || ''}`;
const m = out.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (create.status === 0 && m) {
databaseId = m[0];
ok('数据库已创建');
} else {
// 多半是「这个名字的库早就建过了」——那就去列表里找它的 id
info('创建没有成功(可能是你以前建过同名数据库),改去已有的列表里找 …');
const list = wrangler(['d1', 'list', '--json'], { capture: true });
const text = `${list.stdout || ''}${list.stderr || ''}`;
let arr = null;
try { arr = JSON.parse(text.slice(text.indexOf('['))); } catch { arr = null; }
if (Array.isArray(arr)) {
const hit = arr.find((x) => x && (x.name === DB_NAME));
if (hit) databaseId = hit.uuid || hit.database_id || hit.id || '';
}
if (!databaseId) {
die('没能拿到数据库 id',
'先看看 wrangler 到底报了什么错:\n\n npx wrangler@latest d1 create ' + DB_NAME + '\n\n'
+ '如果提示 already exists,执行下面这条列出所有数据库:\n\n npx wrangler@latest d1 list\n\n'
+ '然后手动把那一串 database_id 填进 wrangler.toml,再跑 node deploy.mjs');
}
ok('找到已存在的数据库');
}
info('database_id = ' + databaseId);
const toml = readFileSync(TOML, 'utf8');
const next = toml.replace(/^(\s*database_id\s*=\s*)".*?"/m, `$1"${databaseId}"`);
if (next === toml) {
die('没能把 database_id 写进 wrangler.toml',
'用记事本打开 wrangler.toml,把 database_id 那一行改成:\n\n database_id = "' + databaseId + '"');
}
writeFileSync(TOML, next);
ok('已把 database_id 写进 wrangler.toml');
}
} // ← 非 --check 模式结束
// ---------- 第 4 步:加密密钥 ----------
step(CHECK_ONLY
? '加密密钥(--check 模式:只看有没有设置)'
: '设置加密密钥(账号的 Cookie / 密码就是用它加密后存进数据库的)');
{
const list = cloudflareLoggedIn ? wrangler(['secret', 'list'], { capture: true }) : { stdout: '', stderr: '' };
const text = `${list.stdout || ''}${list.stderr || ''}`;
const hasSecret = new RegExp(SECRET_NAME).test(text) && !/not found|没有|Error/i.test(text);
if (CHECK_ONLY) {
if (!cloudflareLoggedIn) warn('没登录 Cloudflare,没法核对该密钥是否已设置');
else if (hasSecret) ok(`已经设置过 ${SECRET_NAME}`);
else warn(`还没有设置 ${SECRET_NAME} —— 正式部署时这一步会自动帮你生成并设置`);
} else if (hasSecret) {
ok('已经设置过,保持不变(改了会导致旧数据解不开)');
} else {
const key = randomBytes(32).toString('base64');
info('正在设置 ' + SECRET_NAME + ' …');
const put = wrangler(['secret', 'put', SECRET_NAME], { input: key + '\n' });
if (put.status !== 0) {
warn('自动设置没成功。不影响使用(面板会自动生成一把),但建议手动补上:');
info(' npx wrangler@latest secret put ' + SECRET_NAME);
info(' (提示 Enter a secret value 时,粘这一串进去:' + key + ')');
} else {
ok('加密密钥已设置');
}
}
}
// ---------- 第 5 步:部署前把关 ----------
// 「部署成功但面板打不开 / 一打开就报错」几乎都是这一关能提前拦住的。
step('部署前最后检查一遍配置');
{
const toml = readFileSync(TOML, 'utf8');
const m = toml.match(/^\s*database_id\s*=\s*"([^"]*)"/m);
if (!m || !UUID_RE.test(m[1])) {
// --check 模式下这**不算错**:新用户第一次跑时 id 还是占位符,正式部署会自动建库并填好。
if (CHECK_ONLY) {
warn('database_id 还没填(现在是:' + (m ? m[1] : '(整行都没找到)') + ')—— 正式部署会自动建库并写进去');
} else {
die('wrangler.toml 里的 database_id 不是一个真正的 D1 数据库 id(现在是:' + (m ? m[1] : '(整行都没找到)') + ')',
'这种情况直接部署也能成功,但面板一打开就什么都读不出来。\n'
+ '先列出你自己的数据库,把里面的 uuid 复制到 wrangler.toml 的 database_id:\n\n npx wrangler@latest d1 list\n\n'
+ '改好后重新跑:node deploy.mjs');
}
} else {
ok('database_id 已填好:' + m[1]);
}
if (!/^\s*name\s*=\s*"[^"]+"/m.test(toml)) {
die('wrangler.toml 里没有 name(Worker 的名字)',
'重新下载一份本项目,或把这一行加回去:\n\n name = "daily-checkin-panel"');
}
ok('Worker 名字没问题');
}
// --check 模式到这里就够了:上面每一项都是「读了就知道」的,不需要真的发布。
if (CHECK_ONLY) {
console.log('\n' + c.green(c.bold('✅ 部署前检查完毕')));
console.log(c.dim(' 上面标 ⚠️ 的项在正式部署时会被自动处理;标 ❌ 的项要先按提示改好。'));
console.log('\n 真正发布:node deploy.mjs(不加 --check)\n');
process.exit(0);
}
// ---------- 第 6 步:部署 ----------
step('部署到 Cloudflare');
let panelUrl = '';
{
const dep = wrangler(['deploy'], { capture: true });
const out = `${dep.stdout || ''}${dep.stderr || ''}`;
const url = (out.match(/https:\/\/[a-z0-9.-]+\.workers\.dev/gi) || []).pop();
if (dep.status !== 0 || !url) {
console.log(out.split('\n').slice(-25).join('\n'));
die('部署没有成功',
'把上面那段报错复制下来查一下。最常见的两种:\n'
+ ' · database_id 填错了 → 跑 npx wrangler@latest d1 list 看一下正确的 id\n'
+ ' · 没登录 / 登录过期 → 跑 npx wrangler@latest login\n\n'
+ '处理好之后再跑一次:node deploy.mjs');
}
panelUrl = url;
ok('已部署:' + url);
}
// ---------- 第 7 步:部署完立刻验一下,真的能用才算成功 ----------
// wrangler 说成功、面板却打不开(或一打开就报错)是最坑的情况:
// 用户以为已经弄好了,其实还得排错。所以这里当场把面板请一遍:
// ① 首页能不能打开、是不是本面板的页面
// ② 数据库通不通(用「故意输错密码」探登录接口:能回 JSON 就说明 D1 绑上了)
step('验收:真的能用才算部署成功');
{
let bad = 0;
// ① 首页
try {
const res = await fetch(panelUrl, { redirect: 'follow' });
const html = await res.text();
if (!res.ok) { warn(`首页返回 HTTP ${res.status}(等一两分钟再刷新试试,新部署有时要几十秒才完全生效)`); bad++; }
else if (!/签到面板/.test(html)) { warn('首页能打开,但内容不像本面板(同名 Worker 被别人占了?)'); bad++; }
else ok('面板首页能正常打开');
} catch (e) {
warn('打不开面板地址:' + ((e && e.message) || e));
bad++;
}
// ② 数据库:故意用错密码调登录接口。
// 密码肯定是错的 → 应该回一个 401/JSON;回 5xx 就说明 D1 没绑上或表没建起来。
try {
const res = await fetch(panelUrl + '/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'deploy-self-check-not-a-real-password' }),
});
const ct = res.headers.get('content-type') || '';
if (res.status >= 500) { warn(`数据库接口返回 HTTP ${res.status} —— D1 没绑上或没建表,面板会读不出任何东西`); bad++; }
else if (!/json/i.test(ct)) { warn('数据库接口没有回 JSON —— 静态资源和 API 路由可能没对上'); bad++; }
else ok('数据库(D1)连得上,接口正常');
} catch (e) {
warn('数据库接口请求失败:' + ((e && e.message) || e));
bad++;
}
if (bad) {
console.log('');
console.log(c.yellow('⚠️ 部署命令是成功的,但上面这几项没过 —— 请先按下面做,做完再刷新面板:'));
console.log(c.dim(' 1) 等 1 分钟(新部署生效有时有几士秒延迟),再刷新一次面板地址'));
console.log(c.dim(' 2) 还不行就跑这两条看真实报错:'));
console.log(c.dim(' npx wrangler@latest deployments list'));
console.log(c.dim(' npx wrangler@latest tail (然后在浏览器里刷新面板,看它报什么错)'));
console.log(c.dim(' 3) 报数据库相关(D1 / DB / no such table)就先列一下库和 id:'));
console.log(c.dim(' npx wrangler@latest d1 list'));
console.log('');
}
console.log('\n' + c.green(c.bold(bad ? '🎉 已部署完成(但请先看上面的 ⚠️)' : '🎉 部署成功,一切正常!')));
console.log('\n你的签到面板地址是:\n\n ' + c.bold(panelUrl) + '\n');
console.log('接下来:');
console.log(' 1. 用浏览器打开上面的地址,设置一个管理密码(至少 8 位)');
console.log(' 2. 进去后点「设置」→「浏览器扩展」→ 点「🎲 重新生成 API Key」,把 Key 复制下来');
console.log(' (这串 Key 只显示那一次,先粘到扩展弹窗里再关页面)');
console.log(' 3. 在「设置」→「🔌 浏览器扩展」里点「⬇️ 下载」,解压后按页面上的 4 步装上扩展');
console.log(' (下载包里已经自动写好了你现在的面板地址,不用手抄)');
console.log(' 4. 在扩展弹窗里填 API Key,点「🔌 面板连接检查」');
console.log('\n下一步的图文步骤看 README.md 的「部署完还要做 4 件事」;');
console.log('想手动一步步来、或想弄清每步在干什么,看 docs/详细手册.md。\n');
}