UNPKG

@sellable/install

Version:

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

393 lines (376 loc) • 12 kB
#!/usr/bin/env node import { createHash } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join, posix, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { containsSoulSecretMaterial } from "./soul-artifact-validator.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const FRAGMENTS = join(HERE, "default-profile-bundles"); const SCHEMA = "sellable-agent-default-profile-fragment/v1"; const BUNDLE_SCHEMA = "sellable-agent-default-profile-bundle/v1"; const ID = /^[a-z0-9][a-z0-9-]{0,63}$/; const SKILL_PATH = /^skills\/sellable-defaults\/([a-z0-9][a-z0-9-]{0,63})(?:\/.*)?$/; const PUBLIC_SKILL_PATH = /^skills\/sellable\/([a-z0-9][a-z0-9-]{0,127})\/SKILL\.md$/; const CRON_PATH = /^crons\/([a-z0-9][a-z0-9-]{0,63})\.json$/; const MAX_FILES = 64; const MAX_FILE_BYTES = 64 * 1024; // A fragment embeds the exact bytes of each declared file so the closed-root // readback can reject drift. Five shared skills currently total about 129 KiB; // keep bounded headroom here while the compiled bundle remains capped at 1 MiB. const MAX_FRAGMENT_BYTES = 192 * 1024; const MAX_TOTAL_BYTES = 1024 * 1024; function readNativeSkillName(content) { const lines = content.split("\n"); if (lines[0] !== "---") reject("skill_frontmatter_rejected"); const end = lines.indexOf("---", 1); if (end < 2) reject("skill_frontmatter_rejected"); const names = lines .slice(1, end) .map((line) => /^name:\s*([a-z0-9][a-z0-9-]{0,63})\s*$/.exec(line)?.[1]) .filter(Boolean); if (names.length !== 1 || !ID.test(names[0])) { reject("skill_frontmatter_rejected"); } return names[0]; } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) .join(",")}}`; } return JSON.stringify(value); } function reject(code) { throw new Error(`default_profile_bundle_${code}`); } const SHARED_FRAGMENT_AUXILIARY_FILES = new Set([ "scripts/customer-program-due.py", ]); function assertClosedFragmentRoot(path, fragment) { const root = dirname(path); const declared = new Map( fragment.files.map((file) => [file.path, file.content]) ); const visit = (candidate, relativePath = "") => { const entry = lstatSync(candidate); if (entry.isSymbolicLink()) reject("symlink_rejected"); if (entry.isDirectory()) { for (const name of readdirSync(candidate).sort()) { visit( join(candidate, name), relativePath ? `${relativePath}/${name}` : name ); } return; } if (!entry.isFile()) reject("file_type_rejected"); if (relativePath === "manifest.fragment.json") return; if ( fragment.fragmentId === "shared" && SHARED_FRAGMENT_AUXILIARY_FILES.has(relativePath) ) { if ( (entry.mode & 0o111) === 0 || entry.size < 1 || entry.size > MAX_FILE_BYTES ) { reject("auxiliary_file_rejected"); } return; } const expected = declared.get(relativePath); if (typeof expected !== "string") reject("undeclared_file_rejected"); if (!readFileSync(candidate).equals(Buffer.from(expected, "utf8"))) { reject("declared_file_mismatch"); } }; visit(root); } function readFragment(path) { if (!isAbsolute(path) || !existsSync(path)) reject("fragment_missing"); const manifestStat = lstatSync(path); if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) { reject("fragment_invalid"); } let value; try { const bytes = readFileSync(path); if (bytes.byteLength > MAX_FRAGMENT_BYTES) reject("fragment_too_large"); value = JSON.parse(bytes.toString("utf8")); } catch (error) { if ( error instanceof Error && error.message.startsWith("default_profile_bundle_") ) { throw error; } reject("fragment_invalid"); } if ( !value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join("\0") !== [ "bundleVersion", "files", "fragmentId", "kind", "materializerReceipts", "schemaVersion", ] .sort() .join("\0") || value.schemaVersion !== SCHEMA || !ID.test(value.fragmentId ?? "") || !["SHARED", "CUSTOMER", "ADMIN", "FIXTURE"].includes(value.kind) || !Number.isSafeInteger(value.bundleVersion) || value.bundleVersion < 1 || value.bundleVersion > 2_147_483_647 || !Array.isArray(value.materializerReceipts) || !Array.isArray(value.files) ) { reject("fragment_invalid"); } assertClosedFragmentRoot(path, value); return value; } function validatePath(path) { if ( typeof path !== "string" || path.length < 1 || path.length > 256 || isAbsolute(path) || path.includes("\\") || path.includes("\0") || posix.normalize(path) !== path || path.startsWith("../") ) { reject("path_rejected"); } } function normalizeFile(value, fragmentKind) { if (!value || typeof value !== "object" || Array.isArray(value)) { reject("file_invalid"); } const allowed = value.type === "cron" ? ["content", "id", "mode", "name", "ownership", "path", "type"] : ["content", "id", "mode", "ownership", "path", "type"]; if (Object.keys(value).sort().join("\0") !== allowed.sort().join("\0")) { reject("file_invalid"); } validatePath(value.path); if ( !ID.test(value.id ?? "") || !["skill", "cron"].includes(value.type) || value.ownership !== "system_managed" || value.mode !== "0444" || typeof value.content !== "string" ) { reject("file_invalid"); } const bytes = Buffer.from(value.content, "utf8"); if ( bytes.byteLength < 1 || bytes.byteLength > MAX_FILE_BYTES || containsSoulSecretMaterial(bytes) ) { reject("content_rejected"); } let nativeName = null; if (value.type === "skill") { nativeName = readNativeSkillName(value.content); const managedMatch = SKILL_PATH.exec(value.path); const publicMatch = PUBLIC_SKILL_PATH.exec(value.path); const managedPathValid = managedMatch?.[1] === value.id && value.path.endsWith("/SKILL.md"); const canonicalPublicPathValid = publicMatch?.[1] === nativeName && nativeName === `sellable-${value.id}`; if (!managedPathValid && !canonicalPublicPathValid) { reject("skill_path_rejected"); } } else { const match = CRON_PATH.exec(value.path); if ( !match || value.name !== match[1] || !value.name.startsWith("sellable-default-") ) { reject("cron_path_rejected"); } } return Object.freeze({ id: value.id, type: value.type, ownership: value.ownership, kind: fragmentKind, path: value.path, mode: value.mode, ...(value.type === "cron" ? { name: value.name } : { nativeName }), content: value.content, sha256: sha256(bytes), }); } function writeBundle(outputRoot, result) { mkdirSync(outputRoot, { recursive: true, mode: 0o755 }); for (const file of result.files) { const target = join(outputRoot, ...file.path.split("/")); mkdirSync(dirname(target), { recursive: true, mode: 0o755 }); writeFileSync(target, file.content, { mode: 0o444 }); chmodSync(target, 0o444); } writeFileSync( join(outputRoot, "manifest.json"), `${canonicalJson({ ...result.manifest, bundleDigest: result.bundleDigest })}\n`, { mode: 0o444 } ); chmodSync(join(outputRoot, "manifest.json"), 0o444); } /** Build the one canonical kind bundle. No identity-specific digest is imported. */ export function buildDefaultProfileBundle({ kind, fragmentPaths, adminFragmentPath, fixtureVersion, outputRoot, } = {}) { if (!["CUSTOMER", "ADMIN"].includes(kind)) reject("kind_rejected"); if (fixtureVersion !== undefined && ![1, 2].includes(fixtureVersion)) { reject("fixture_rejected"); } const paths = fragmentPaths ? [...fragmentPaths] : [ join(FRAGMENTS, "shared", "manifest.fragment.json"), kind === "CUSTOMER" ? join(FRAGMENTS, "customer", "manifest.fragment.json") : resolve(adminFragmentPath ?? ""), ...(fixtureVersion ? [ join( FRAGMENTS, "fixtures", `v${fixtureVersion}`, "manifest.fragment.json" ), ] : []), ]; if (!Array.isArray(paths) || paths.length < 1 || paths.length > 4) { reject("fragment_inventory_rejected"); } const fragments = paths.map((path) => readFragment(resolve(path))); if ( new Set(fragments.map((fragment) => fragment.fragmentId)).size !== fragments.length ) { reject("fragment_collision"); } for (const fragment of fragments) { if ( !["SHARED", kind].includes(fragment.kind) && !(fixtureVersion && fragment.kind === "FIXTURE") ) { reject("kind_isolation_rejected"); } } const files = fragments .flatMap((fragment) => fragment.files.map((file) => normalizeFile(file, fragment.kind)) ) .sort((left, right) => left.path.localeCompare(right.path)); if ( files.length > MAX_FILES || files.reduce((sum, file) => sum + Buffer.byteLength(file.content), 0) > MAX_TOTAL_BYTES || new Set(files.map((file) => file.path)).size !== files.length || new Set( files.filter((file) => file.type === "skill").map((file) => file.id) ).size !== files.filter((file) => file.type === "skill").length || new Set( files .filter((file) => file.type === "skill") .map((file) => file.nativeName) ).size !== files.filter((file) => file.type === "skill").length || new Set( files.filter((file) => file.type === "cron").map((file) => file.name) ).size !== files.filter((file) => file.type === "cron").length ) { reject("entry_collision"); } const materializerReceipts = [ ...new Set(fragments.flatMap((fragment) => fragment.materializerReceipts)), ].sort(); if ( materializerReceipts.some( (path) => path !== "SOUL.md" && path !== "skills/sellable/SKILL.md" && path !== "skills/sellable-admin/SKILL.md" ) ) { reject("materializer_receipt_rejected"); } const bundleVersion = Math.max( ...fragments.map((fragment) => fragment.bundleVersion) ); const inventory = files.map(({ content: _content, ...file }) => file); const manifest = Object.freeze({ schemaVersion: BUNDLE_SCHEMA, kind, bundleVersion, materializerReceipts, files: inventory, }); const result = Object.freeze({ bundleVersion, manifest, files, bundleDigest: sha256(canonicalJson({ manifest, files })), }); if (outputRoot !== undefined) { if (!isAbsolute(outputRoot)) reject("output_root_rejected"); writeBundle(outputRoot, result); } return result; } function argumentValue(flag) { const index = process.argv.indexOf(flag); return index >= 0 ? process.argv[index + 1] : undefined; } if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const result = buildDefaultProfileBundle({ kind: argumentValue("--kind"), adminFragmentPath: argumentValue("--admin-fragment"), fixtureVersion: argumentValue("--fixture-version") ? Number(argumentValue("--fixture-version")) : undefined, outputRoot: resolve(argumentValue("--output") ?? ""), }); process.stdout.write( `${JSON.stringify({ bundleVersion: result.bundleVersion, bundleDigest: result.bundleDigest, })}\n` ); }