Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/cli/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* 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
* 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)
Expand All @@ -20,10 +21,12 @@
* - 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,
Expand Down Expand Up @@ -106,6 +109,9 @@ async function downloadBuffer(url) {

/** 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("..");
}
Expand Down Expand Up @@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
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 });
Expand All @@ -166,12 +196,22 @@ async function main() {
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 + atomic swap
// 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) {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
Expand Down
7 changes: 6 additions & 1 deletion packages/commands/src/commands/skill/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ export default defineCommand({
return { name, status: "failed", reason: "skill not found in registry" };
}
try {
const record = await installSkillWithFanout(name, entry, agents);
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return {
name,
Expand Down
15 changes: 13 additions & 2 deletions packages/commands/src/commands/skill/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fanOutSkillToAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
Expand Down Expand Up @@ -45,6 +46,7 @@ export default defineCommand({
const lock = readSkillLock();
const disk = new Set(listSkillDirsOnDisk());

const agents = detectInstalledAgents();
const results: UpdateOutcome[] = [];
const targets: string[] = [];
if (requested === "all") {
Expand All @@ -60,6 +62,11 @@ export default defineCommand({
continue;
}
if (entry.contentHash === locked.contentHash && disk.has(name)) {
// Self-healing: content unchanged, but still fill fan-out links for agents
// detected since the last install (and refresh recorded copies); the merged
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
lock.skills[name] = { ...locked, links: fanout.links };
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
continue;
}
Expand All @@ -80,14 +87,18 @@ export default defineCommand({
}
}

const agents = detectInstalledAgents();
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
const entry = index.skills[name];
if (!entry) {
return { name, status: "failed", reason: "skill not found in registry" };
}
try {
const record = await installSkillWithFanout(name, entry, agents);
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return { name, status: "updated", publishedAt: entry.publishedAt };
} catch (err) {
Expand Down
30 changes: 23 additions & 7 deletions packages/core/src/advisor/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts";
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
import { fetchSkillsIndex } from "../skills/registry.ts";
Expand Down Expand Up @@ -90,12 +91,17 @@ function recordWikiInLock(lockEntry: SkillLockEntry): void {
}
}

/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
function wikiLockUpToDate(contentHash: string): boolean {
/**
* Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the
* record carries no fan-out links (postinstall writes contentHash only and never fans
* out, so agents would otherwise never see the wiki skill until content changes).
*/
function wikiLockNeedsBackfill(contentHash: string): boolean {
try {
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
return locked?.contentHash !== contentHash || !Array.isArray(locked.links);
} catch {
return false;
return true;
}
}

Expand Down Expand Up @@ -136,15 +142,25 @@ export async function maybeSyncWikiData(): Promise<boolean> {
const dataOk = catalogDataExists();
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
writeState({ lastChecked: now, contentHash: entry.contentHash });
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
// Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill
if (wikiLockNeedsBackfill(entry.contentHash)) {
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks);
recordWikiInLock(buildSkillLockEntry(entry, fanout.links));
}
return false;
}

// 4. Different content or missing data: delegate to the shared skill install pipeline
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
try {
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
const record = await installSkillWithFanout(
WIKI_SKILL_NAME,
entry,
detectInstalledAgents(),
previousLinks,
);
recordWikiInLock(record.lockEntry);
} catch {
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
Expand Down
Loading