@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
111 lines (107 loc) • 3.62 kB
JavaScript
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 }
);
}