Files
yingfungsolar/scripts/setup.mjs
T
philip-cat fdee1aabd7 Add Zod validation layer and AI blog generation
Introduce a schema-first validation layer and an AI blog generation
feature, plus one-command Cloudflare provisioning.

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
2026-09-11 23:49:18 +08:00

161 lines
5.6 KiB
JavaScript
Raw 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
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(SCRIPT_DIR);
const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
const WRANGLER = join(ROOT, "wrangler.jsonc");
const SEED_FILE = join(ROOT, "migrations", "seed.sql");
const ASTRO_CONFIG = join(ROOT, "astro.config.mjs");
const DB_NAME = "yingfung-solar-db";
const rl = createInterface({ input, output });
function run(cmd) {
return execSync(cmd, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
}
function runSoft(cmd) {
try {
return { ok: true, out: run(cmd), err: "" };
} catch (err) {
return { ok: false, out: err.stdout ?? err.stderr ?? "", err: err.stderr ?? "" };
}
}
/** 由 wrangler 輸出抽 id;同時支援 JSON"key": "value")同 TOMLkey = "value")。 */
function extractId(out, keys) {
for (const key of keys) {
const re = new RegExp(`(?<!\\w)"?${key}"?\\s*[=:]\\s*"([0-9a-f-]+)"`, "i");
const m = out.match(re);
if (m) return m[1];
}
return null;
}
/** 由 wrangler 嘅 JSON 陣列輸出搵第一個符合條件嘅項目。 */
function findInJsonArray(text, predicate) {
try {
const arr = JSON.parse(text);
if (Array.isArray(arr)) return arr.find(predicate) ?? null;
} catch {
// 唔係 JSON 就當搵唔到。
}
return null;
}
async function main() {
console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");
console.log("檢查 wrangler 登入狀態…");
const who = runSoft("npx wrangler whoami --json");
const whoText = `${who.out}\n${who.err}`;
if (!who.ok || /not authenticated|not logged in/i.test(whoText)) {
console.error("未登入 Cloudflare。請先執行: npm run login");
process.exit(1);
}
let config = readFileSync(WRANGLER, "utf8");
if (config.includes(D1_PLACEHOLDER)) {
console.log(`建立 D1 database「${DB_NAME}」…`);
const res = runSoft(`npx wrangler d1 create ${DB_NAME}`);
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["database_id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 database_id…");
const list = runSoft("npx wrangler d1 list --json");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.name === DB_NAME);
id = found?.uuid ?? null;
}
if (!id) {
console.error("建立 / 查詢 D1 database 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(D1_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` database_id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("D1 id 已設定,略過。");
}
if (config.includes(KV_PLACEHOLDER)) {
console.log("建立 KV namespace「CACHE」…");
const res = runSoft("npx wrangler kv namespace create CACHE");
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 KV id…");
const list = runSoft("npx wrangler kv namespace list");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.title === "CACHE");
id = found?.id ?? null;
}
if (!id) {
console.error("建立 / 查詢 KV namespace 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(KV_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` kv id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("KV id 已設定,略過。");
}
const setPassword = await rl.question("而家設定後台密碼 ADMIN_PASSWORD(y/N)");
if (setPassword.trim().toLowerCase() === "y") {
execSync("npx wrangler secret put ADMIN_PASSWORD", { stdio: "inherit" });
console.log("ADMIN_PASSWORD 已處理。");
} else {
console.log("略過 ADMIN_PASSWORD。");
}
const migrate = await rl.question("而家套用雲端 migration + seed(y/N)");
if (migrate.trim().toLowerCase() === "y") {
execSync(`npx wrangler d1 migrations apply ${DB_NAME} --remote`, { stdio: "inherit" });
execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file="${SEED_FILE}"`, { stdio: "inherit" });
}
let siteReady = true;
try {
if (readFileSync(ASTRO_CONFIG, "utf8").includes("https://example.com")) {
siteReady = false;
console.log("\n⚠️ 警告:astro.config.mjs 嘅 site 仍然係 https://example.com。");
console.log(" 上線前必須改成真域名,否則 sitemap / canonical / OG 會出錯。");
console.log(" 已跳過 build + deploy;更新 site 後請自行執行: npm run deploy\n");
}
} catch {
// 讀唔到 astro.config.mjs 就當冇問題,繼續。
}
if (siteReady) {
const deploy = await rl.question("而家 build + deploy(y/N)");
if (deploy.trim().toLowerCase() === "y") {
execSync("npm run build", { stdio: "inherit" });
execSync("npx wrangler deploy", { stdio: "inherit" });
}
}
console.log("\n完成。\n");
rl.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});