11/**
2- * postinstall.js —— Wiki 数据同步(第一层: npm install 触发)
2+ * postinstall.js — Wiki data sync (layer 1: triggered by npm install)
33 *
4- * npm/pnpm 装完 bailian-cli 后自动执行:无条件下载全量 Wiki 数据包并覆盖本地目录,
5- * 保证用户首次使用 `bl advisor recommend` 时数据已就位。
4+ * Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
5+ * package and overwrites the local directory, ensuring data is in place the first time the user runs
6+ * `bl advisor recommend`.
67 *
7- * 流程:
8- * 1. 从公共读 OSS 直链下载 manifest.json + wiki-doc-full.tar.br(~2.15MB)
9- * 2. 校验 sha256
10- * 3. Node 原生 brotli 解压 + tar-stream 解包到同盘临时目录
11- * 4. renameSync 原子替换到 ~/.bailian/skills/bailian-docs-llm-wiki/
12- * 5. 写 ~/.bailian/wiki-sync-state.json
8+ * Flow (unified skill publishing protocol: skills/index.json + one skill.tar.br per skill):
9+ * 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
10+ * 2. Download skills/bailian-docs-llm-wiki/skill.tar.br (brotli q6, ~2.3MB)
11+ * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
12+ * 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
13+ * 5. Write ~/.bailian/wiki-sync-state.json
14+ * 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
1315 *
14- * 设计约束:
15- * - 无条件覆盖:每次 install 都全量替换,不比对已有版本
16- * - 失败静默:任何一步失败 → console.warn → process.exit(0),绝不阻塞安装
17- * - 独立实现:不 import bailian-cli-core,避免打包后 ESM 路径问题
18- * - 依赖 Node 原生模块 + tar-stream(与 sync.ts / Crawler oss-upload .mjs 一致)
16+ * Design constraints:
17+ * - Unconditional overwrite: every install fully replaces, no version comparison
18+ * - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
19+ * - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
20+ * - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish .mjs)
1921 */
20- import { createHash } from "node:crypto" ;
2122import {
2223 createWriteStream ,
2324 existsSync ,
2425 mkdirSync ,
26+ readFileSync ,
2527 renameSync ,
2628 rmSync ,
2729 writeFileSync ,
@@ -33,14 +35,15 @@ import { pipeline } from "node:stream/promises";
3335import { createBrotliDecompress } from "node:zlib" ;
3436import tar from "tar-stream" ;
3537
36- const OSS_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/bailian-docs-llm-wiki" ;
38+ const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills" ;
39+ const WIKI_SKILL_NAME = "bailian-docs-llm-wiki" ;
3740const CONFIG_DIR_NAME = ".bailian" ;
3841const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki" ;
3942const STATE_FILE_NAME = "wiki-sync-state.json" ;
40- const MANIFEST_KEY = "manifest .json" ;
41- const ASSET_KEY = "wiki-doc-full .tar.br" ;
43+ const INDEX_KEY = "index .json" ;
44+ const ASSET_NAME = "skill .tar.br" ;
4245
43- const MANIFEST_TIMEOUT_MS = 3000 ;
46+ const INDEX_TIMEOUT_MS = 3000 ;
4447const DOWNLOAD_TIMEOUT_MS = 30000 ;
4548
4649function getConfigDir ( ) {
@@ -56,6 +59,35 @@ function getStatePath() {
5659 return join ( getConfigDir ( ) , STATE_FILE_NAME ) ;
5760}
5861
62+ function getSkillLockPath ( ) {
63+ return join ( getConfigDir ( ) , "skills" , "skill-lock.json" ) ;
64+ }
65+
66+ /**
67+ * Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
68+ * Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
69+ * entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
70+ * best-effort: failure does not affect data sync results.
71+ */
72+ function upsertSkillLock ( name , entry ) {
73+ try {
74+ let lock = { version : 1 , skills : { } } ;
75+ try {
76+ const parsed = JSON . parse ( readFileSync ( getSkillLockPath ( ) , "utf-8" ) ) ;
77+ if ( parsed ?. version === 1 && parsed . skills && typeof parsed . skills === "object" ) {
78+ lock = parsed ;
79+ }
80+ } catch {
81+ /* absent/corrupted → empty table */
82+ }
83+ lock . skills [ name ] = { ...lock . skills [ name ] , ...entry } ;
84+ mkdirSync ( dirname ( getSkillLockPath ( ) ) , { recursive : true } ) ;
85+ writeFileSync ( getSkillLockPath ( ) , JSON . stringify ( lock , null , 2 ) + "\n" ) ;
86+ } catch {
87+ /* Bookkeeping failure does not block install; advisor-side sync will backfill */
88+ }
89+ }
90+
5991async function fetchJson ( url , timeoutMs ) {
6092 const res = await fetch ( url , { signal : AbortSignal . timeout ( timeoutMs ) } ) ;
6193 if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` ) ;
@@ -68,11 +100,21 @@ async function downloadBuffer(url) {
68100 return Buffer . from ( await res . arrayBuffer ( ) ) ;
69101}
70102
71- /** brotli 解压 + tar-stream 解包到 destDir(与 Crawler tar.pack() 对称)。 */
103+ /** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
104+ function isSafeEntryName ( name ) {
105+ if ( name . startsWith ( "/" ) || / ^ [ a - z A - Z ] : [ \\ / ] / . test ( name ) ) return false ;
106+ return ! name . split ( "/" ) . includes ( ".." ) ;
107+ }
108+
109+ /** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
72110async function extractTarBr ( tarBrBuffer , destDir ) {
73111 const extract = tar . extract ( ) ;
74112
75113 extract . on ( "entry" , ( header , stream , next ) => {
114+ if ( ! isSafeEntryName ( header . name ) ) {
115+ next ( new Error ( `unsafe tar entry: ${ header . name } ` ) ) ;
116+ return ;
117+ }
76118 const filePath = join ( destDir , header . name ) ;
77119 if ( header . type === "directory" ) {
78120 mkdirSync ( filePath , { recursive : true } ) ;
@@ -90,7 +132,7 @@ async function extractTarBr(tarBrBuffer, destDir) {
90132 await pipeline ( Readable . from ( tarBrBuffer ) , createBrotliDecompress ( ) , extract ) ;
91133}
92134
93- /** 原子替换: tmpDir(同盘) → catalogDir。 */
135+ /** Atomic swap: tmpDir (same volume) → catalogDir. */
94136function atomicSwap ( tmpDir , catalogDir ) {
95137 mkdirSync ( dirname ( catalogDir ) , { recursive : true } ) ;
96138 const backup = `${ catalogDir } .old-${ Date . now ( ) } ` ;
@@ -105,18 +147,16 @@ function atomicSwap(tmpDir, catalogDir) {
105147}
106148
107149async function main ( ) {
108- // 1. 下载 manifest
109- const manifest = await fetchJson ( `${ OSS_BASE_URL } /${ MANIFEST_KEY } ` , MANIFEST_TIMEOUT_MS ) ;
110- if ( ! manifest ?. version ) throw new Error ( "manifest 无 version" ) ;
111-
112- // 2. 下载 tar.br + 校验
113- const tarBuf = await downloadBuffer ( `${ OSS_BASE_URL } /${ ASSET_KEY } ` ) ;
114- const sha256 = createHash ( "sha256" ) . update ( tarBuf ) . digest ( "hex" ) ;
115- if ( manifest . asset ?. sha256 && sha256 !== manifest . asset . sha256 ) {
116- throw new Error ( "sha256 校验失败" ) ;
117- }
150+ // 1. Download skills/index.json and get the wiki entry
151+ const index = await fetchJson ( `${ REGISTRY_BASE_URL } /${ INDEX_KEY } ` , INDEX_TIMEOUT_MS ) ;
152+ const entry = index ?. skills ?. [ WIKI_SKILL_NAME ] ;
153+ if ( ! entry ?. contentHash )
154+ throw new Error ( "no bailian-docs-llm-wiki entry (or contentHash) in index.json" ) ;
155+
156+ // 2. Download skill.tar.br (per-entry path safety check during extraction)
157+ const tarBuf = await downloadBuffer ( `${ REGISTRY_BASE_URL } /${ WIKI_SKILL_NAME } /${ ASSET_NAME } ` ) ;
118158
119- // 3. 解包到同盘临时目录 + 原子替换
159+ // 3. Extract to same-volume temp dir + atomic swap
120160 const catalogDir = getCatalogDir ( ) ;
121161 const tmpDir = `${ catalogDir } .tmp-${ process . pid } -${ Date . now ( ) } ` ;
122162 try {
@@ -128,24 +168,35 @@ async function main() {
128168 throw err ;
129169 }
130170
131- // 4. 写 state
171+ // 4. Write state
132172 try {
133173 writeFileSync (
134174 getStatePath ( ) ,
135- JSON . stringify ( { lastChecked : Date . now ( ) , version : manifest . version } ) ,
175+ JSON . stringify ( { lastChecked : Date . now ( ) , contentHash : entry . contentHash } ) ,
136176 ) ;
137177 } catch {
138- /* state 写失败不影响:首次 recommend 会重新检查 */
178+ /* state write failure has no impact: first recommend will re-check */
139179 }
140180
141- process . stdout . write ( `bailian-cli: wiki 数据已就绪 (v${ manifest . version } )\n` ) ;
181+ // 5. skill-lock.json record: wiki shares the same ledger as bl skill
182+ upsertSkillLock ( WIKI_SKILL_NAME , {
183+ contentHash : entry . contentHash ,
184+ ...( entry . publishedAt ? { publishedAt : entry . publishedAt } : { } ) ,
185+ installedAt : new Date ( ) . toISOString ( ) ,
186+ sourceType : "oss" ,
187+ ...( entry . description ? { description : entry . description } : { } ) ,
188+ } ) ;
189+
190+ process . stdout . write ( `bailian-cli: wiki data ready (${ entry . publishedAt ?? "latest" } )\n` ) ;
142191}
143192
144193main ( ) . catch ( ( err ) => {
145- // 无条件放行:安装期网络/权限问题不应阻塞 npm install,
146- // 首次 `bl advisor recommend` 时 sync.ts 会兜底同步。
194+ // Unconditional pass-through: install-time network/permission issues should not block npm install;
195+ // sync.ts will fall back to syncing on the first `bl advisor recommend`.
147196 const msg = err instanceof Error ? err . message : String ( err ) ;
148- process . stderr . write ( `bailian-cli: wiki 数据预下载跳过 (${ msg } ),首次使用时将自动同步。\n` ) ;
197+ process . stderr . write (
198+ `bailian-cli: wiki data pre-download skipped (${ msg } ); will sync automatically on first use.\n` ,
199+ ) ;
149200 // Force a success exit code so a download failure never fails `npm install`.
150201 // eslint-disable-next-line unicorn/no-process-exit
151202 process . exit ( 0 ) ;
0 commit comments