|
| 1 | +/** |
| 2 | + * postinstall.js —— Wiki 数据同步(第一层:npm install 触发) |
| 3 | + * |
| 4 | + * npm/pnpm 装完 bailian-cli 后自动执行:无条件下载全量 Wiki 数据包并覆盖本地目录, |
| 5 | + * 保证用户首次使用 `bl advisor recommend` 时数据已就位。 |
| 6 | + * |
| 7 | + * 流程: |
| 8 | + * 1. fetch FC 签名函数 → 拿 OSS 预签名 URL |
| 9 | + * 2. 下载 manifest.json + wiki-doc-full.tar.br(~2.15MB) |
| 10 | + * 3. 校验 sha256 |
| 11 | + * 4. Node 原生 brotli 解压 + tar-stream 解包到同盘临时目录 |
| 12 | + * 5. renameSync 原子替换到 ~/.bailian/skills/bailian-docs-llm-wiki/ |
| 13 | + * 6. 写 ~/.bailian/wiki-sync-state.json |
| 14 | + * |
| 15 | + * 设计约束: |
| 16 | + * - 无条件覆盖:每次 install 都全量替换,不比对已有版本 |
| 17 | + * - 失败静默:任何一步失败 → console.warn → process.exit(0),绝不阻塞安装 |
| 18 | + * - 独立实现:不 import bailian-cli-core,避免打包后 ESM 路径问题 |
| 19 | + * - 依赖 Node 原生模块 + tar-stream(与 sync.ts / Crawler oss-upload.mjs 一致) |
| 20 | + */ |
| 21 | +import { createHash } from "node:crypto"; |
| 22 | +import { |
| 23 | + createWriteStream, |
| 24 | + existsSync, |
| 25 | + mkdirSync, |
| 26 | + renameSync, |
| 27 | + rmSync, |
| 28 | + writeFileSync, |
| 29 | +} from "node:fs"; |
| 30 | +import { homedir } from "node:os"; |
| 31 | +import { dirname, join } from "node:path"; |
| 32 | +import { Readable } from "node:stream"; |
| 33 | +import { pipeline } from "node:stream/promises"; |
| 34 | +import { createBrotliDecompress } from "node:zlib"; |
| 35 | +import tar from "tar-stream"; |
| 36 | + |
| 37 | +const FC_SIGN_URL = "https://signature-gmqkxxozrl.cn-hangzhou.fcapp.run"; |
| 38 | +const CONFIG_DIR_NAME = ".bailian"; |
| 39 | +const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki"; |
| 40 | +const STATE_FILE_NAME = "wiki-sync-state.json"; |
| 41 | +const MANIFEST_KEY = "manifest.json"; |
| 42 | +const ASSET_KEY = "wiki-doc-full.tar.br"; |
| 43 | + |
| 44 | +const MANIFEST_TIMEOUT_MS = 3000; |
| 45 | +const DOWNLOAD_TIMEOUT_MS = 30000; |
| 46 | + |
| 47 | +function getConfigDir() { |
| 48 | + if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR; |
| 49 | + return join(homedir(), CONFIG_DIR_NAME); |
| 50 | +} |
| 51 | + |
| 52 | +function getCatalogDir() { |
| 53 | + return join(getConfigDir(), SKILL_DIR_NAME); |
| 54 | +} |
| 55 | + |
| 56 | +function getStatePath() { |
| 57 | + return join(getConfigDir(), STATE_FILE_NAME); |
| 58 | +} |
| 59 | + |
| 60 | +async function fetchJson(url, timeoutMs) { |
| 61 | + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); |
| 62 | + if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 63 | + return res.json(); |
| 64 | +} |
| 65 | + |
| 66 | +async function downloadBuffer(url) { |
| 67 | + const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); |
| 68 | + if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 69 | + return Buffer.from(await res.arrayBuffer()); |
| 70 | +} |
| 71 | + |
| 72 | +/** brotli 解压 + tar-stream 解包到 destDir(与 Crawler tar.pack() 对称)。 */ |
| 73 | +async function extractTarBr(tarBrBuffer, destDir) { |
| 74 | + const extract = tar.extract(); |
| 75 | + |
| 76 | + extract.on("entry", (header, stream, next) => { |
| 77 | + const filePath = join(destDir, header.name); |
| 78 | + if (header.type === "directory") { |
| 79 | + mkdirSync(filePath, { recursive: true }); |
| 80 | + stream.resume(); |
| 81 | + stream.on("end", next); |
| 82 | + return; |
| 83 | + } |
| 84 | + mkdirSync(dirname(filePath), { recursive: true }); |
| 85 | + const ws = createWriteStream(filePath); |
| 86 | + stream.pipe(ws); |
| 87 | + ws.on("finish", next); |
| 88 | + ws.on("error", next); |
| 89 | + }); |
| 90 | + |
| 91 | + await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract); |
| 92 | +} |
| 93 | + |
| 94 | +/** 原子替换:tmpDir(同盘)→ catalogDir。 */ |
| 95 | +function atomicSwap(tmpDir, catalogDir) { |
| 96 | + mkdirSync(dirname(catalogDir), { recursive: true }); |
| 97 | + const backup = `${catalogDir}.old-${Date.now()}`; |
| 98 | + if (existsSync(catalogDir)) renameSync(catalogDir, backup); |
| 99 | + try { |
| 100 | + renameSync(tmpDir, catalogDir); |
| 101 | + } catch (err) { |
| 102 | + if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir); |
| 103 | + throw err; |
| 104 | + } |
| 105 | + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); |
| 106 | +} |
| 107 | + |
| 108 | +async function main() { |
| 109 | + // 1. 拿全部签名 URL |
| 110 | + const signResp = await fetchJson(FC_SIGN_URL, MANIFEST_TIMEOUT_MS); |
| 111 | + const urls = signResp?.urls; |
| 112 | + if (!urls?.[MANIFEST_KEY] || !urls?.[ASSET_KEY]) { |
| 113 | + throw new Error("签名函数未返回所需 URL"); |
| 114 | + } |
| 115 | + |
| 116 | + // 2. 下载 manifest |
| 117 | + const manifest = await fetchJson(urls[MANIFEST_KEY], MANIFEST_TIMEOUT_MS); |
| 118 | + if (!manifest?.version) throw new Error("manifest 无 version"); |
| 119 | + |
| 120 | + // 3. 下载 tar.br + 校验 |
| 121 | + const tarBuf = await downloadBuffer(urls[ASSET_KEY]); |
| 122 | + const sha256 = createHash("sha256").update(tarBuf).digest("hex"); |
| 123 | + if (manifest.asset?.sha256 && sha256 !== manifest.asset.sha256) { |
| 124 | + throw new Error("sha256 校验失败"); |
| 125 | + } |
| 126 | + |
| 127 | + // 4. 解包到同盘临时目录 + 原子替换 |
| 128 | + const catalogDir = getCatalogDir(); |
| 129 | + const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`; |
| 130 | + try { |
| 131 | + mkdirSync(tmpDir, { recursive: true }); |
| 132 | + await extractTarBr(tarBuf, tmpDir); |
| 133 | + atomicSwap(tmpDir, catalogDir); |
| 134 | + } catch (err) { |
| 135 | + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); |
| 136 | + throw err; |
| 137 | + } |
| 138 | + |
| 139 | + // 5. 写 state |
| 140 | + try { |
| 141 | + writeFileSync( |
| 142 | + getStatePath(), |
| 143 | + JSON.stringify({ lastChecked: Date.now(), version: manifest.version }), |
| 144 | + ); |
| 145 | + } catch { |
| 146 | + /* state 写失败不影响:首次 recommend 会重新检查 */ |
| 147 | + } |
| 148 | + |
| 149 | + process.stdout.write(`bailian-cli: wiki 数据已就绪 (v${manifest.version})\n`); |
| 150 | +} |
| 151 | + |
| 152 | +main().catch((err) => { |
| 153 | + // 无条件放行:安装期网络/权限问题不应阻塞 npm install, |
| 154 | + // 首次 `bl advisor recommend` 时 sync.ts 会兜底同步。 |
| 155 | + const msg = err instanceof Error ? err.message : String(err); |
| 156 | + process.stderr.write(`bailian-cli: wiki 数据预下载跳过 (${msg}),首次使用时将自动同步。\n`); |
| 157 | + // Force a success exit code so a download failure never fails `npm install`. |
| 158 | + // eslint-disable-next-line unicorn/no-process-exit |
| 159 | + process.exit(0); |
| 160 | +}); |
0 commit comments