Skip to content

Commit d08edf0

Browse files
committed
feat: sync wiki data from oss by fc
1 parent 39f9256 commit d08edf0

9 files changed

Lines changed: 571 additions & 34 deletions

File tree

packages/cli/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
},
2626
"files": [
2727
"dist",
28-
"README.zh.md"
28+
"README.zh.md",
29+
"postinstall.js"
2930
],
3031
"type": "module",
3132
"exports": {
@@ -45,12 +46,14 @@
4546
"build": "vp pack",
4647
"dev": "tsx src/main.ts",
4748
"test": "vp test",
48-
"check": "vp check"
49+
"check": "vp check",
50+
"postinstall": "node postinstall.js"
4951
},
5052
"dependencies": {
5153
"bailian-cli-commands": "workspace:*",
5254
"bailian-cli-core": "workspace:*",
53-
"bailian-cli-runtime": "workspace:*"
55+
"bailian-cli-runtime": "workspace:*",
56+
"tar-stream": "catalog:"
5457
},
5558
"devDependencies": {
5659
"@clack/prompts": "^0.7.0",

packages/cli/postinstall.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
});

packages/commands/src/commands/advisor/recommend.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type GetModelsOptions,
77
getModels,
88
type IntentProfile,
9+
maybeSyncWikiData,
910
type PipelineStep,
1011
type RecommendedModel,
1112
type RecommendResult,
@@ -248,6 +249,12 @@ export default defineCommand({
248249
const { settings, flags } = ctx;
249250
const userInput = flags.message;
250251
const top = 3;
252+
253+
// Keep the local wiki catalog fresh: throttled (12h) version check against
254+
// the remote manifest, silently replaces data when a newer version exists.
255+
// Never throws — a sync failure must not block recommendation.
256+
await maybeSyncWikiData();
257+
251258
// Default to JSON for structured output; render boxen cards only when the
252259
// user explicitly asked for text output.
253260
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@
4040
"check": "vp check"
4141
},
4242
"dependencies": {
43+
"tar-stream": "catalog:",
4344
"yaml": "^2.8.3",
4445
"yauzl": "catalog:"
4546
},
4647
"devDependencies": {
4748
"@types/node": "catalog:",
49+
"@types/tar-stream": "catalog:",
4850
"@types/yauzl": "catalog:",
4951
"@typescript/native-preview": "7.0.0-dev.20260328.1",
5052
"typescript": "^6.0.2",

packages/core/src/advisor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { recallCandidates } from "./recall.ts";
77
export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts";
88
export type { RecommendOptions } from "./recommend.ts";
99
export { buildDocLink, rankModels } from "./recommend.ts";
10+
export { maybeSyncWikiData } from "./sync.ts";
1011
export type { ModelSource } from "./sources/types.ts";
1112
export type {
1213
Budget,

packages/core/src/advisor/sources/catalog.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2-
import { dirname, join } from "node:path";
3-
import { fileURLToPath } from "node:url";
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
43
import { getConfigDir } from "../../config/paths.ts";
54
import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts";
65
import type { ModelSource } from "./types.ts";
@@ -13,12 +12,9 @@ function getCatalogDir(): string {
1312
}
1413

1514
function getCatalogPath(): string {
16-
return join(getCatalogDir(), MODELS_FILE);
17-
}
18-
19-
function getMonorepoModelsDir(): string {
20-
const coreDir = dirname(fileURLToPath(import.meta.url));
21-
return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models");
15+
// Full-package layout keeps the `models/` subdir (raw/, wiki/, models/, …),
16+
// so models.jsonl lives at <skill>/models/models.jsonl — not at the skill root.
17+
return join(getCatalogDir(), "models", MODELS_FILE);
2218
}
2319

2420
function fromJsonlRecord(raw: Record<string, unknown>): ModelProfile | null {
@@ -62,41 +58,24 @@ function readJsonlModels(filePath: string): ModelProfile[] {
6258
return models;
6359
}
6460

65-
function installFromMonorepo(): boolean {
66-
const src = getMonorepoModelsDir();
67-
if (!existsSync(join(src, MODELS_FILE))) return false;
68-
const dest = getCatalogDir();
69-
try {
70-
mkdirSync(dest, { recursive: true });
71-
cpSync(src, dest, { recursive: true });
72-
return true;
73-
} catch {
74-
return false;
75-
}
76-
}
77-
7861
export interface CatalogSourceOptions {
7962
onPrepareStart?: () => void;
8063
}
8164

8265
export class CatalogSource implements ModelSource {
8366
readonly name = "catalog";
84-
private options: CatalogSourceOptions;
8567

86-
constructor(options?: CatalogSourceOptions) {
87-
this.options = options ?? {};
88-
}
68+
// Options retained for API compatibility. Data is now always provisioned by
69+
// the CLI postinstall hook and refreshed by advisor sync, so the previous
70+
// `onPrepareStart` install callback is obsolete.
71+
constructor(_options?: CatalogSourceOptions) {}
8972

9073
available(): boolean {
9174
return existsSync(getCatalogPath());
9275
}
9376

9477
async load(): Promise<ModelProfile[]> {
95-
if (!this.available()) {
96-
this.options.onPrepareStart?.();
97-
const installed = installFromMonorepo();
98-
if (!installed) return [];
99-
}
78+
if (!this.available()) return [];
10079
return readJsonlModels(getCatalogPath());
10180
}
10281
}

0 commit comments

Comments
 (0)