mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
9e6f5ca9cb
- bl skill init: raise index timeout 10s→30s and retry transient network failures (3 attempts); advisor sync silent channel stays fail-fast - make post-swap backup deletion best-effort so host safe-delete guards cannot fail a completed install (postinstall.js mirror included) - add unit coverage for guard-blocked cleanup and registry retry policy
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
/**
|
|
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
|
|
*
|
|
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
|
|
* package and overwrites the local directory, ensuring data is in place the first time the user runs
|
|
* `bl advisor recommend`.
|
|
*
|
|
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill):
|
|
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
|
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
|
|
* legacy fallback to skill.tar.br when the entry has no valid object field
|
|
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
|
|
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
|
|
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
|
|
* 5. Write ~/.bailian/wiki-sync-state.json
|
|
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
|
|
*
|
|
* Design constraints:
|
|
* - Unconditional overwrite: every install fully replaces, no version comparison
|
|
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
|
|
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
|
|
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
createWriteStream,
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { createBrotliDecompress } from "node:zlib";
|
|
import tar from "tar-stream";
|
|
|
|
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
|
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
|
const CONFIG_DIR_NAME = ".bailian";
|
|
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
|
const STATE_FILE_NAME = "wiki-sync-state.json";
|
|
const INDEX_KEY = "index.json";
|
|
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
|
|
const LEGACY_ASSET_NAME = "skill.tar.br";
|
|
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
|
|
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
|
|
|
|
const INDEX_TIMEOUT_MS = 3000;
|
|
const DOWNLOAD_TIMEOUT_MS = 30000;
|
|
|
|
function getConfigDir() {
|
|
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
|
|
return join(homedir(), CONFIG_DIR_NAME);
|
|
}
|
|
|
|
function getCatalogDir() {
|
|
return join(getConfigDir(), SKILL_DIR_NAME);
|
|
}
|
|
|
|
function getStatePath() {
|
|
return join(getConfigDir(), STATE_FILE_NAME);
|
|
}
|
|
|
|
function getSkillLockPath() {
|
|
return join(getConfigDir(), "skills", "skill-lock.json");
|
|
}
|
|
|
|
/**
|
|
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
|
|
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
|
|
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
|
|
* best-effort: failure does not affect data sync results.
|
|
*/
|
|
function upsertSkillLock(name, entry) {
|
|
try {
|
|
let lock = { version: 1, skills: {} };
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
|
|
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
|
|
lock = parsed;
|
|
}
|
|
} catch {
|
|
/* absent/corrupted → empty table */
|
|
}
|
|
lock.skills[name] = { ...lock.skills[name], ...entry };
|
|
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
|
|
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
|
} catch {
|
|
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, timeoutMs) {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function downloadBuffer(url) {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
|
function isSafeEntryName(name) {
|
|
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
|
|
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
|
|
if (name.includes("\\") || name.includes("\0")) return false;
|
|
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
|
return !name.split("/").includes("..");
|
|
}
|
|
|
|
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
|
|
async function extractTarBr(tarBrBuffer, destDir) {
|
|
const extract = tar.extract();
|
|
|
|
extract.on("entry", (header, stream, next) => {
|
|
if (!isSafeEntryName(header.name)) {
|
|
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
|
|
// error; silence the entry stream to avoid its companion error becoming unhandled
|
|
stream.on("error", () => {});
|
|
stream.resume();
|
|
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
|
|
return;
|
|
}
|
|
const filePath = join(destDir, header.name);
|
|
if (header.type === "directory") {
|
|
mkdirSync(filePath, { recursive: true });
|
|
stream.resume();
|
|
stream.on("end", next);
|
|
return;
|
|
}
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
const ws = createWriteStream(filePath);
|
|
stream.pipe(ws);
|
|
ws.on("finish", next);
|
|
ws.on("error", next);
|
|
});
|
|
|
|
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
|
}
|
|
|
|
/**
|
|
* Recompute the publisher's deterministic content hash over an extracted directory
|
|
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
|
|
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
|
|
*/
|
|
function computeDirContentHash(dir) {
|
|
const relPaths = [];
|
|
const walk = (sub) => {
|
|
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
|
|
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
|
|
if (dirent.isDirectory()) walk(rel);
|
|
else if (dirent.isFile()) relPaths.push(rel);
|
|
}
|
|
};
|
|
walk("");
|
|
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
const hash = createHash("sha256");
|
|
for (const rel of relPaths) {
|
|
hash.update(rel);
|
|
hash.update(readFileSync(join(dir, rel)));
|
|
}
|
|
return `sha256:${hash.digest("hex")}`;
|
|
}
|
|
|
|
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
|
function atomicSwap(tmpDir, catalogDir) {
|
|
mkdirSync(dirname(catalogDir), { recursive: true });
|
|
const backup = `${catalogDir}.old-${Date.now()}`;
|
|
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
|
|
try {
|
|
renameSync(tmpDir, catalogDir);
|
|
} catch (err) {
|
|
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
|
|
throw err;
|
|
}
|
|
// Best-effort cleanup (symmetric with core skills/extract.ts): the swap already
|
|
// succeeded, so a backup deletion failure must not fail the pre-download
|
|
try {
|
|
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
|
} catch {
|
|
/* keep the backup on disk rather than report a completed swap as failed */
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
// 1. Download skills/index.json and get the wiki entry
|
|
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
|
|
const entry = index?.skills?.[WIKI_SKILL_NAME];
|
|
if (!entry?.contentHash)
|
|
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
|
|
|
|
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
|
|
const assetName =
|
|
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
|
|
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
|
|
|
|
// 3. Extract to same-volume temp dir + integrity check + atomic swap
|
|
const catalogDir = getCatalogDir();
|
|
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
|
try {
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
await extractTarBr(tarBuf, tmpDir);
|
|
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
|
|
// before touching the canonical dir
|
|
if (entry.contentHash.startsWith("sha256:")) {
|
|
const actualContentHash = computeDirContentHash(tmpDir);
|
|
if (actualContentHash !== entry.contentHash) {
|
|
throw new Error(
|
|
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
|
|
);
|
|
}
|
|
}
|
|
atomicSwap(tmpDir, catalogDir);
|
|
} catch (err) {
|
|
try {
|
|
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* cleanup must not mask the original error */
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// 4. Write state
|
|
try {
|
|
writeFileSync(
|
|
getStatePath(),
|
|
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
|
|
);
|
|
} catch {
|
|
/* state write failure has no impact: first recommend will re-check */
|
|
}
|
|
|
|
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
|
|
upsertSkillLock(WIKI_SKILL_NAME, {
|
|
contentHash: entry.contentHash,
|
|
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
|
installedAt: new Date().toISOString(),
|
|
sourceType: "oss",
|
|
...(entry.description ? { description: entry.description } : {}),
|
|
});
|
|
|
|
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
// Unconditional pass-through: install-time network/permission issues should not block npm install;
|
|
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(
|
|
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
|
|
);
|
|
// Force a success exit code so a download failure never fails `npm install`.
|
|
// eslint-disable-next-line unicorn/no-process-exit
|
|
process.exit(0);
|
|
});
|