feat: 引入 usage 队列与本地部署脚本
- usage 队列异步写入与流式解析限流 - 管理台展示运行时配置 - 本地 dev/部署脚本与 CI 队列创建
This commit is contained in:
1 parent
e12d38f384
commit
d205289f9f
27 files changed
+1338
-291
No files matched your search
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_CONFIG = "apps/worker/wrangler.toml";
|
||||
const DEFAULT_ENV_FILE = path.join(ROOT, ".env");
|
||||
const DEFAULT_ENV_EXAMPLE = path.join(ROOT, ".env.example");
|
||||
const BUN_CMD = (() => {
|
||||
if (process.env.BUN_BIN && existsSync(process.env.BUN_BIN)) {
|
||||
return process.env.BUN_BIN;
|
||||
}
|
||||
const npmExec = process.env.npm_execpath;
|
||||
if (npmExec && npmExec.toLowerCase().includes("bun")) {
|
||||
return npmExec;
|
||||
}
|
||||
if (process.env.BUN_INSTALL) {
|
||||
const candidate = path.join(
|
||||
process.env.BUN_INSTALL,
|
||||
"bin",
|
||||
process.platform === "win32" ? "bun.exe" : "bun",
|
||||
);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return "bun";
|
||||
})();
|
||||
|
||||
const parseArgs = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
action: null,
|
||||
target: "auto",
|
||||
migrate: "auto",
|
||||
config: DEFAULT_CONFIG,
|
||||
skipInstall: false,
|
||||
};
|
||||
|
||||
const positionals = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const value = args[i + 1];
|
||||
if (key === "action" && value) {
|
||||
options.action = value;
|
||||
i += 1;
|
||||
} else if (key === "target" && value) {
|
||||
options.target = value;
|
||||
i += 1;
|
||||
} else if (key === "migrate" && value) {
|
||||
options.migrate = value;
|
||||
i += 1;
|
||||
} else if (key === "config" && value) {
|
||||
options.config = value;
|
||||
i += 1;
|
||||
} else if (key === "skip-install") {
|
||||
options.skipInstall = true;
|
||||
} else if (key === "help") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (positionals[0] === "init" || positionals[0] === "update") {
|
||||
options.action = positionals[0];
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const printHelp = () => {
|
||||
console.log("Usage:");
|
||||
console.log(" node scripts/deploy.mjs init [--target both] [--migrate true]");
|
||||
console.log(" node scripts/deploy.mjs update [--target auto] [--migrate auto]");
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(" --action init|update 部署动作(可用位置参数替代)");
|
||||
console.log(" --target frontend|backend|both|auto");
|
||||
console.log(" --migrate true|false|auto");
|
||||
console.log(" --config <path> wrangler.toml 路径");
|
||||
console.log(" --skip-install 跳过 bun install");
|
||||
console.log("");
|
||||
console.log("说明: 本脚本仅执行本地流程(构建 + 本地迁移),不会触发远程部署。");
|
||||
};
|
||||
|
||||
const run = (command, args, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (error.code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
`无法执行 ${command},请确认已安装 Bun 并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${command} failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const runCapture = (command, args, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (error.code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
`无法执行 ${command},请确认已安装 Bun 并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(
|
||||
new Error(`${command} failed: ${stderr.trim() || stdout.trim()}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const runBun = (args, options) => run(BUN_CMD, args, options);
|
||||
const runBunx = (args, options) => run(BUN_CMD, ["x", ...args], options);
|
||||
|
||||
const parseEnvLine = (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let value = trimmed.slice(idx + 1).trim();
|
||||
if (
|
||||
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return { key, value };
|
||||
};
|
||||
|
||||
const loadEnvFile = async (filePath) => {
|
||||
if (!existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
const lines = raw.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const parsed = parseEnvLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (process.env[parsed.key] === undefined) {
|
||||
process.env[parsed.key] = parsed.value;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const ensureEnvFile = async (filePath, examplePath) => {
|
||||
if (existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
if (existsSync(examplePath)) {
|
||||
const template = await readFile(examplePath, "utf8");
|
||||
await writeFile(filePath, template, "utf8");
|
||||
return;
|
||||
}
|
||||
await writeFile(filePath, "# 本地部署环境变量(自动生成)\n", "utf8");
|
||||
};
|
||||
|
||||
const promptChoice = async (rl, label, choices, defaultValue) => {
|
||||
const choicesText = choices.join("/");
|
||||
const defaultText = defaultValue ? ` [${defaultValue}]` : "";
|
||||
while (true) {
|
||||
const answer = (await rl.question(`${label} (${choicesText})${defaultText}: `))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!answer && defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
const numeric = Number(answer);
|
||||
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
|
||||
return choices[numeric - 1];
|
||||
}
|
||||
if (choices.includes(answer)) {
|
||||
return answer;
|
||||
}
|
||||
console.log(`无效输入,请输入 ${choicesText} 或序号 1-${choices.length}`);
|
||||
}
|
||||
};
|
||||
|
||||
const promptOptions = async (options) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const action = await promptChoice(
|
||||
rl,
|
||||
"选择部署动作",
|
||||
["init", "update"],
|
||||
"update",
|
||||
);
|
||||
options.action = action;
|
||||
if (action === "init") {
|
||||
options.target = "both";
|
||||
options.migrate = "true";
|
||||
return options;
|
||||
}
|
||||
options.target = await promptChoice(
|
||||
rl,
|
||||
"选择部署目标",
|
||||
["frontend", "backend", "both", "auto"],
|
||||
options.target,
|
||||
);
|
||||
options.migrate = await promptChoice(
|
||||
rl,
|
||||
"是否执行迁移",
|
||||
["true", "false", "auto"],
|
||||
options.migrate,
|
||||
);
|
||||
return options;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureUiBuild = async () => {
|
||||
await runBun(["run", "--filter", "api-worker-ui", "build"]);
|
||||
const distDir = path.join(ROOT, "apps/ui/dist");
|
||||
const indexFile = path.join(distDir, "index.html");
|
||||
if (!existsSync(distDir) || !existsSync(indexFile)) {
|
||||
throw new Error("UI 构建失败:dist 或 index.html 不存在");
|
||||
}
|
||||
};
|
||||
|
||||
const getChangedFiles = async () => {
|
||||
try {
|
||||
const insideRepo = await runCapture("git", [
|
||||
"rev-parse",
|
||||
"--is-inside-work-tree",
|
||||
]);
|
||||
if (insideRepo !== "true") {
|
||||
return null;
|
||||
}
|
||||
await runCapture("git", ["rev-parse", "HEAD~1"]);
|
||||
const diff = await runCapture("git", [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"HEAD~1",
|
||||
"HEAD",
|
||||
]);
|
||||
return diff.split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTarget = (target, changedFiles) => {
|
||||
if (target !== "auto") {
|
||||
if (target === "backend") {
|
||||
return { deployUi: false, deployWorker: true };
|
||||
}
|
||||
if (target === "frontend") {
|
||||
return { deployUi: true, deployWorker: false };
|
||||
}
|
||||
return { deployUi: true, deployWorker: true };
|
||||
}
|
||||
if (!changedFiles) {
|
||||
return { deployUi: true, deployWorker: true };
|
||||
}
|
||||
const deployUi = changedFiles.some((file) => file.startsWith("apps/ui/"));
|
||||
const deployWorker = changedFiles.some((file) =>
|
||||
file.startsWith("apps/worker/"),
|
||||
);
|
||||
if (!deployUi && !deployWorker) {
|
||||
return { deployUi: true, deployWorker: true };
|
||||
}
|
||||
return { deployUi, deployWorker };
|
||||
};
|
||||
|
||||
const resolveMigrate = (migrate, changedFiles) => {
|
||||
if (migrate === "true") {
|
||||
return true;
|
||||
}
|
||||
if (migrate === "false") {
|
||||
return false;
|
||||
}
|
||||
if (!changedFiles) {
|
||||
return true;
|
||||
}
|
||||
return changedFiles.some((file) =>
|
||||
file.startsWith("apps/worker/migrations/"),
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs();
|
||||
if (!options.action) {
|
||||
await promptOptions(options);
|
||||
}
|
||||
const configPath = path.resolve(ROOT, options.config);
|
||||
|
||||
await access(configPath);
|
||||
await ensureEnvFile(DEFAULT_ENV_FILE, DEFAULT_ENV_EXAMPLE);
|
||||
await loadEnvFile(DEFAULT_ENV_FILE);
|
||||
|
||||
if (options.action === "init") {
|
||||
options.target = "both";
|
||||
options.migrate = "true";
|
||||
}
|
||||
|
||||
const changedFiles = await getChangedFiles();
|
||||
const targets = resolveTarget(options.target, changedFiles);
|
||||
const shouldMigrate = resolveMigrate(options.migrate, changedFiles);
|
||||
|
||||
console.log("本地部署计划:");
|
||||
console.log(`- action: ${options.action}`);
|
||||
console.log(`- target: ${options.target}`);
|
||||
console.log(`- migrate: ${options.migrate} => ${shouldMigrate}`);
|
||||
console.log(`- build UI: ${targets.deployUi}`);
|
||||
console.log(`- prepare backend: ${targets.deployWorker}`);
|
||||
console.log("- mode: local (no remote deploy)");
|
||||
|
||||
if (!options.skipInstall) {
|
||||
await runBun(["install"]);
|
||||
}
|
||||
|
||||
if (targets.deployUi) {
|
||||
await ensureUiBuild();
|
||||
}
|
||||
|
||||
await runBunx(["wrangler", "telemetry", "disable"]);
|
||||
|
||||
if (shouldMigrate) {
|
||||
await runBunx([
|
||||
"wrangler",
|
||||
"d1",
|
||||
"migrations",
|
||||
"apply",
|
||||
"DB",
|
||||
"--local",
|
||||
"--config",
|
||||
configPath,
|
||||
]);
|
||||
}
|
||||
|
||||
console.log("✅ 本地部署完成");
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`❌ 部署失败: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BUN_CMD = (() => {
|
||||
if (process.env.BUN_BIN && existsSync(process.env.BUN_BIN)) {
|
||||
return process.env.BUN_BIN;
|
||||
}
|
||||
const npmExec = process.env.npm_execpath;
|
||||
if (npmExec && npmExec.toLowerCase().includes("bun")) {
|
||||
return npmExec;
|
||||
}
|
||||
if (process.env.BUN_INSTALL) {
|
||||
const candidate = path.join(
|
||||
process.env.BUN_INSTALL,
|
||||
"bin",
|
||||
process.platform === "win32" ? "bun.exe" : "bun",
|
||||
);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return "bun";
|
||||
})();
|
||||
|
||||
const commands = [
|
||||
{ name: "worker", cmd: BUN_CMD, args: ["run", "dev:worker"] },
|
||||
{ name: "ui", cmd: BUN_CMD, args: ["run", "dev:ui"] },
|
||||
];
|
||||
|
||||
const children = new Map();
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = (code = 0) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
for (const child of children.values()) {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
process.exit(code);
|
||||
};
|
||||
|
||||
for (const command of commands) {
|
||||
const child = spawn(command.cmd, command.args, { stdio: "inherit" });
|
||||
children.set(command.name, child);
|
||||
child.on("error", (error) => {
|
||||
if (error.code === "ENOENT") {
|
||||
console.error(
|
||||
"❌ 未找到 Bun,请确认已安装并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。",
|
||||
);
|
||||
shutdown(1);
|
||||
return;
|
||||
}
|
||||
console.error(`❌ 启动 ${command.name} 失败: ${error.message}`);
|
||||
shutdown(1);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (code && code !== 0) {
|
||||
shutdown(code);
|
||||
return;
|
||||
}
|
||||
const allExited = Array.from(children.values()).every(
|
||||
(item) => item.exitCode !== null,
|
||||
);
|
||||
if (allExited) {
|
||||
shutdown(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
Reference in new issue
Block a user