UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

111 lines (107 loc) • 3.62 kB
import { createHash, randomUUID } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import { withProfileSoulLock } from "./fly-soul-bridge.mjs"; const bundledFiles = JSON.parse( readFileSync(new URL("./public-skills.json", import.meta.url), "utf8") ); const paths = new Set(); if (!Array.isArray(bundledFiles) || bundledFiles.length === 0) throw new Error("public_skills_bundle_empty"); for (const file of bundledFiles) { if ( !/^skills\/sellable\/sellable-[a-z0-9-]+\/(SKILL|SOUL)\.md$/.test( file.path ) || paths.has(file.path) || typeof file.content !== "string" || !file.content.trim() ) { throw new Error("public_skills_bundle_invalid"); } paths.add(file.path); } const bundleDigest = createHash("sha256") .update(JSON.stringify(bundledFiles)) .digest("hex"); function assertDirectory(path) { const stat = lstatSync(path, { throwIfNoEntry: false }); if (!stat) return; if (stat.isSymbolicLink()) throw new Error("public_skills_symlink_rejected"); if (!stat.isDirectory()) throw new Error("public_skills_directory_rejected"); } /** Restore image-owned public skills after profile restore/materialization. * Only canonical skill files are owned here; custom skills and support files survive. */ export function reconcilePublicHermesSkills({ profileId, profileRoot, dataRoot, }) { if ( !isAbsolute(dataRoot ?? "") || !/^[A-Za-z0-9_-]+$/.test(profileId ?? "") || profileRoot !== join(dataRoot, "profiles", profileId) ) { throw new Error("public_skills_profile_rejected"); } for (const path of [dataRoot, join(dataRoot, "profiles"), profileRoot]) assertDirectory(path); if (!existsSync(profileRoot)) throw new Error("public_skills_profile_missing"); return withProfileSoulLock( profileId, () => { // Check every target before writing any file. Never follow an operator link. for (const file of bundledFiles) { let directory = profileRoot; for (const name of file.path.split("/").slice(0, -1)) { directory = join(directory, name); assertDirectory(directory); } const target = join(profileRoot, file.path); const stat = lstatSync(target, { throwIfNoEntry: false }); if (stat) { if (stat.isSymbolicLink()) throw new Error("public_skills_symlink_rejected"); if (!stat.isFile()) throw new Error("public_skills_file_rejected"); } } let changed = false; for (const file of bundledFiles) { const target = join(profileRoot, file.path); if ( existsSync(target) && readFileSync(target, "utf8") === file.content && (lstatSync(target).mode & 0o777) === 0o600 ) continue; mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); const temporary = `${target}.${randomUUID()}.tmp`; try { writeFileSync(temporary, file.content, { mode: 0o600, flag: "wx" }); chmodSync(temporary, 0o600); renameSync(temporary, target); } finally { rmSync(temporary, { force: true }); } changed = true; } for (const file of bundledFiles) { if (readFileSync(join(profileRoot, file.path), "utf8") !== file.content) throw new Error("public_skills_readback_failed"); } return { changed, bundleDigest, files: [...paths] }; }, { dataRoot } ); }