diff --git a/.changeset/reliable-runtime-mcp-registry.md b/.changeset/reliable-runtime-mcp-registry.md new file mode 100644 index 00000000..ba321b21 --- /dev/null +++ b/.changeset/reliable-runtime-mcp-registry.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Make packaged runtimes discover bundled DuckDB resources without Desktop-only environment setup, let npm and Bun CLI installs follow a verified same-or-newer Desktop-managed runtime, remove unpublished workspace packages from registry resolution during installation, and add the bounded read-only `selftune mcp serve` local skill registry. diff --git a/README.md b/README.md index e3afffe5..a5e0b7d4 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Your agent runs these — you just say what you want ("improve my skills", "show | | `selftune service status --json` | Inspect registration, process state, version, and durable daemon health | | | `selftune service doctor --json` | Diagnose the fixed current-user Windows service-lock compatibility state | | | `selftune service repair-lock --json` | Repair only a proven stale pre-SQLite Windows service lock | +| | `selftune mcp serve` | Serve the local skill catalog over MCP stdio with Skills extension discovery | | | `selftune doctor` | Health check: logs, hooks, config, permissions | | **skills** | `selftune skills audit` | Inventory installed skills and recommend keep, repair, consolidate, or quarantine review | | | `selftune skills consolidate --skill ` | Preview or apply reversible archive-and-link cleanup for duplicate installations | diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 73d6f161..3468ee7d 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -24,6 +24,7 @@ * selftune dashboard — Open visual data dashboard * selftune daemon — Run and inspect the local service * selftune service — Manage the OS-supervised background service + * selftune mcp serve — Serve local skills over MCP stdio * selftune last — Show last session details * selftune cron — Scheduling & automation (setup, list, remove) * selftune badge — Generate skill health badges for READMEs @@ -104,6 +105,7 @@ Primary Lifecycle: dashboard Open visual data dashboard daemon Run and inspect the local SelfTune service service Manage the OS-supervised background service + mcp serve Serve the local skill registry over MCP stdio Advanced / Stage Commands: evolve [target] Evolve skill descriptions (body, rollback) @@ -152,6 +154,7 @@ const FAST_COMMANDS: ReadonlySet = new Set([ "pi", "daemon", "service", + "mcp", ]); // Track command usage (lazy import — skip for hooks and --help to avoid loading crypto/os) @@ -186,6 +189,15 @@ if (!internalCommand && !command) { const { runEffectCliMain } = await import("./effect-cli/runtime.js"); runEffectCliMain("status", []); } else if (!internalCommand) { + if (command === "mcp") { + const subcommand = process.argv[3]; + if (subcommand !== "serve") { + throw new CLIError("Usage: selftune mcp serve", "INVALID_ARGUMENT"); + } + const { runSkillRegistryStdio } = await import("@selftune/runtime/mcp/skill-registry"); + await runSkillRegistryStdio(); + process.exit(0); + } if (!isSkillSearch) { const { startDashboardActionStream } = await import("@selftune/runtime/dashboard-action-stream"); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 170c39e8..54c59db3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/desktop", - "version": "0.4.10", + "version": "0.4.11", "private": true, "description": "Native desktop control plane for SelfTune skill observability and improvement", "homepage": "https://selftune.dev", diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index a5a012bd..12681ef5 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -105,6 +105,7 @@ function isolatedRuntimeEnvironment(paths: RuntimePaths): NodeJS.ProcessEnv { SELFTUNE_HOME: paths.homeDir, SELFTUNE_OPENCLAW_DIR: join(paths.homeDir, ".openclaw"), SELFTUNE_PI_DIR: join(paths.homeDir, ".pi"), + SELFTUNE_DESKTOP_RESOURCE_DIR: undefined, VIBE_HOME: join(paths.homeDir, ".vibe"), XDG_CONFIG_HOME: join(paths.homeDir, ".config"), }; @@ -249,7 +250,6 @@ const startRuntime = Effect.fn("SelfTuneSidecar.smoke.start")(function* (paths: ...isolatedRuntimeEnvironment(paths), SELFTUNE_BIN_PATH: paths.binary, SELFTUNE_DESKTOP: "1", - SELFTUNE_DESKTOP_RESOURCE_DIR: paths.root, SELFTUNE_RUNTIME_OWNER: "desktop", SELFTUNE_SUPERVISED: "0", SELFTUNE_VERSION: process.env.npm_package_version ?? "0.0.0-smoke", diff --git a/apps/local/src/cloud-billing.ts b/apps/local/src/cloud-billing.ts index 222525cd..9e7b9fdd 100644 --- a/apps/local/src/cloud-billing.ts +++ b/apps/local/src/cloud-billing.ts @@ -189,8 +189,12 @@ function decodeBillingStatus(body: unknown): DesktopBillingStatus { name: "Community", price: "$0", period: null, - description: "The complete local-first skill manager.", - features: ["Local library", "Skill Sets", "Project-scoped installs"], + description: "Find, review, package, and install skills on your machine.", + features: [ + "Unlimited local skills and Skill Sets", + "Discovery across supported agents and projects", + "Local evaluation and reviewable proposals", + ], highlighted: false, }, { @@ -198,8 +202,12 @@ function decodeBillingStatus(body: unknown): DesktopBillingStatus { name: "Pro", price: "$19", period: "/month", - description: "Subscription access and secure hosted sharing.", - features: ["Secure sharing", "Update summaries", "Device management"], + description: "Publish and manage reviewed Skill Sets as one owner.", + features: [ + "Explicit skill and Skill Set sharing", + "Expiring and revocable share links", + "Linked-device and revision status", + ], highlighted: true, }, { @@ -207,8 +215,12 @@ function decodeBillingStatus(body: unknown): DesktopBillingStatus { name: "Team", price: "$49", period: "/month", - description: "Shared access for teams managing the same skill estate.", - features: ["Everything in Pro", "Team workspace"], + description: "Distribute reviewed Skill Sets across a workspace.", + features: [ + "Everything in Pro", + "3 members included", + "Workspace membership and access roles", + ], highlighted: false, seats: { minimum: 3, label: "members" }, }, diff --git a/apps/local/src/local-runtime.ts b/apps/local/src/local-runtime.ts index d5a0ec22..01a47ee4 100644 --- a/apps/local/src/local-runtime.ts +++ b/apps/local/src/local-runtime.ts @@ -10,6 +10,7 @@ import { readFileSync, renameSync, rmSync, + unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -333,10 +334,18 @@ export function removeDaemonManifestIfOwned( ): void { const manifest = readServerManifest(configDir); if (manifest?.pid === pid && (instanceId === undefined || manifest.instance_id === instanceId)) { - rmSync(daemonManifestPath(configDir), { force: true }); + try { + unlinkSync(daemonManifestPath(configDir)); + } catch (cause) { + if (!isMissingFileError(cause)) throw cause; + } } } +function isMissingFileError(cause: unknown): boolean { + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"; +} + export function isProcessAlive(pid: number): boolean { if (!Number.isSafeInteger(pid) || pid <= 1) return false; try { diff --git a/bin/desktop-runtime.cjs b/bin/desktop-runtime.cjs new file mode 100644 index 00000000..fa366a75 --- /dev/null +++ b/bin/desktop-runtime.cjs @@ -0,0 +1,67 @@ +const { existsSync, readFileSync, realpathSync } = require("node:fs"); +const { homedir } = require("node:os"); +const { basename, dirname, join, relative, resolve, sep } = require("node:path"); + +function numericVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + return match ? match.slice(1).map(Number) : null; +} + +function versionAtLeast(candidate, installed) { + const left = numericVersion(candidate); + const right = numericVersion(installed); + if (!left || !right) return candidate === installed; + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index]; + } + return true; +} + +function desktopDataRoots(environment = process.env, platform = process.platform) { + if (platform === "darwin") { + return [join(homedir(), "Library", "Application Support", "SelfTune")]; + } + if (platform === "win32" && environment.APPDATA) { + return [join(environment.APPDATA, "SelfTune"), join(environment.APPDATA, "selftune")]; + } + const configRoot = environment.XDG_CONFIG_HOME || join(homedir(), ".config"); + return [join(configRoot, "SelfTune"), join(configRoot, "selftune")]; +} + +function isInside(parent, child) { + const path = relative(parent, child); + return path !== "" && path !== ".." && !path.startsWith(`..${sep}`); +} + +function resolveDesktopRuntime(installedVersion, options = {}) { + if (options.environment?.SELFTUNE_DISABLE_DESKTOP_RUNTIME === "1") return null; + const roots = options.dataRoots ?? desktopDataRoots(options.environment, options.platform); + const executableName = + (options.platform ?? process.platform) === "win32" ? "selftune.exe" : "selftune"; + + for (const dataRoot of roots) { + try { + const runtimeRoot = realpathSync(join(dataRoot, "runtime")); + const pointer = JSON.parse(readFileSync(join(runtimeRoot, "current.json"), "utf8")); + if ( + !pointer || + typeof pointer.version !== "string" || + typeof pointer.path !== "string" || + !versionAtLeast(pointer.version, installedVersion) + ) { + continue; + } + const selectedRoot = realpathSync(resolve(pointer.path)); + if (!isInside(runtimeRoot, selectedRoot) || basename(selectedRoot) !== pointer.version) + continue; + const executable = realpathSync(join(selectedRoot, executableName)); + if (dirname(executable) !== selectedRoot || !existsSync(executable)) continue; + return executable; + } catch { + // An absent, incomplete, or untrusted Desktop runtime is not a CLI failure. + } + } + return null; +} + +module.exports = { desktopDataRoots, resolveDesktopRuntime, versionAtLeast }; diff --git a/bin/selftune.cjs b/bin/selftune.cjs index 2e58979a..7dc58dba 100755 --- a/bin/selftune.cjs +++ b/bin/selftune.cjs @@ -2,6 +2,18 @@ const { execFileSync } = require("child_process"); const { join } = require("path"); +const { resolveDesktopRuntime } = require("./desktop-runtime.cjs"); + +const installedVersion = require("../package.json").version; +const desktopRuntime = resolveDesktopRuntime(installedVersion, { environment: process.env }); +if (desktopRuntime) { + try { + execFileSync(desktopRuntime, process.argv.slice(2), { stdio: "inherit" }); + process.exit(0); + } catch (error) { + process.exit(error.status ?? 1); + } +} const entrypoint = join(__dirname, "..", "apps", "cli", "src", "main.ts"); diff --git a/bun.lock b/bun.lock index 396153cd..6b1d5a20 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ }, "apps/desktop": { "name": "@selftune/desktop", - "version": "0.4.10", + "version": "0.4.11", "dependencies": { "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", diff --git a/package.json b/package.json index 11dd5c21..b9d7cd3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.4.10", + "version": "0.4.11", "description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically", "keywords": [ "agent", diff --git a/packages/observability/package.json b/packages/observability/package.json index cca2f95a..55e75d46 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -12,6 +12,7 @@ "./otlp": "./src/otlp.ts", "./duckdb-store": "./src/duckdb-store.ts", "./duckdb-node-api": "./src/duckdb-node-api.ts", + "./duckdb-module-resolution": "./src/duckdb-module-resolution.ts", "./evidence-cohort": "./src/evidence-cohort.ts", "./historical-backfill": "./src/historical-backfill.ts", "./reader-parity": "./src/reader-parity.ts" diff --git a/packages/observability/src/duckdb-module-resolution.ts b/packages/observability/src/duckdb-module-resolution.ts new file mode 100644 index 00000000..bcd3d312 --- /dev/null +++ b/packages/observability/src/duckdb-module-resolution.ts @@ -0,0 +1,37 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export interface DuckDbModuleResolutionOptions { + readonly desktopResourceDirectory?: string; + readonly executablePath?: string; + readonly pathExists?: (path: string) => boolean; +} + +export interface PackagedDuckDbModuleResolution { + readonly modulePath: string; + readonly resourceDirectory: string; +} + +export function resolvePackagedDuckDbModule( + options: DuckDbModuleResolutionOptions = {}, +): PackagedDuckDbModuleResolution | undefined { + const pathExists = options.pathExists ?? existsSync; + const explicitResourceDirectory = options.desktopResourceDirectory?.trim(); + const candidates = [ + explicitResourceDirectory, + options.executablePath ? dirname(options.executablePath) : undefined, + ]; + + for (const resourceDirectory of candidates) { + if (!resourceDirectory) continue; + const modulePath = join(resourceDirectory, "node_modules/@duckdb/node-api/lib/index.js"); + if (pathExists(modulePath)) return { modulePath, resourceDirectory }; + } + return undefined; +} + +export function resolvePackagedDuckDbModulePath( + options: DuckDbModuleResolutionOptions = {}, +): string | undefined { + return resolvePackagedDuckDbModule(options)?.modulePath; +} diff --git a/packages/observability/src/duckdb-node-api.ts b/packages/observability/src/duckdb-node-api.ts index 64c3cf81..f2bb1da7 100644 --- a/packages/observability/src/duckdb-node-api.ts +++ b/packages/observability/src/duckdb-node-api.ts @@ -10,20 +10,18 @@ import { type DuckDbConnection, type DuckDbInstanceFactory, } from "./duckdb-store.js"; +import { resolvePackagedDuckDbModule } from "./duckdb-module-resolution.js"; -const desktopResourceDirectory = process.env.SELFTUNE_DESKTOP_RESOURCE_DIR; -const packagedDesktopResourceDirectory = - desktopResourceDirectory === undefined || desktopResourceDirectory.length === 0 - ? undefined - : desktopResourceDirectory; -const duckDbModule = - packagedDesktopResourceDirectory === undefined - ? await import(["@duckdb", "node-api"].join("/")) - : await import( - pathToFileURL( - join(packagedDesktopResourceDirectory, "node_modules/@duckdb/node-api/lib/index.js"), - ).href - ); +const packagedDuckDbModule = resolvePackagedDuckDbModule({ + desktopResourceDirectory: process.env.SELFTUNE_DESKTOP_RESOURCE_DIR, + executablePath: process.execPath, +}); +if (packagedDuckDbModule && !process.env.SELFTUNE_DESKTOP_RESOURCE_DIR) { + process.env.SELFTUNE_DESKTOP_RESOURCE_DIR = packagedDuckDbModule.resourceDirectory; +} +const duckDbModule = packagedDuckDbModule + ? await import(pathToFileURL(packagedDuckDbModule.modulePath).href) + : await import(["@duckdb", "node-api"].join("/")); const { DuckDBInstance, DuckDBTimestampValue } = duckDbModule as typeof import("@duckdb/node-api"); /** Bounded desktop analytical-store memory; DuckDB never receives an unlimited process budget. */ diff --git a/packages/runtime/mcp/skill-registry.ts b/packages/runtime/mcp/skill-registry.ts new file mode 100644 index 00000000..728161c7 --- /dev/null +++ b/packages/runtime/mcp/skill-registry.ts @@ -0,0 +1,425 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync, realpathSync } from "node:fs"; +import { basename, join, relative, sep } from "node:path"; + +import { + findInstalledSkillPackages, + getDefaultSkillSearchDirs, + type InstalledSkillPackage, +} from "../utils/skill-discovery.js"; +import { parseFrontmatter } from "../utils/frontmatter.js"; + +const MAX_FILES = 100; +const MAX_SKILL_MD_BYTES = 256 * 1024; +const MAX_RESOURCE_BYTES = 1024 * 1024; +const MAX_SKILL_BYTES = 5 * 1024 * 1024; + +export interface SkillResourceEntry { + uri: string; + digest: string; +} + +export interface McpSkillEntry { + uri: string; + frontmatter: Record; + resources: SkillResourceEntry[]; +} + +interface IndexedResource extends SkillResourceEntry { + path: string; + mimeType: string; + bytes: Uint8Array; +} + +interface IndexedSkill { + entry: McpSkillEntry; + name: string; + description: string; + revision: string; + resources: IndexedResource[]; +} + +export interface SkillRegistryOptions { + searchDirs?: string[]; + cwd?: string; + homeDir?: string; + codexHome?: string; + pageSize?: number; +} + +export interface JsonRpcRequest { + id?: string | number | null; + method?: string; + params?: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +function digest(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +/** Parse every top-level frontmatter key while preserving unknown fields. */ +export function parseSkillFrontmatter(content: string): Record { + const lines = content.split(/\r?\n/); + if (lines[0]?.trim() !== "---") return {}; + const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + if (end < 0) return {}; + const parsed = Bun.YAML.parse(lines.slice(1, end).join("\n")); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; +} + +function mimeType(path: string): string { + if (/\.json$/i.test(path)) return "application/json"; + if (/\.(?:md|mdx|txt|ya?ml|ts|tsx|js|jsx|css|html|sh|py)$/i.test(path)) return "text/plain"; + if (/\.png$/i.test(path)) return "image/png"; + if (/\.jpe?g$/i.test(path)) return "image/jpeg"; + if (/\.gif$/i.test(path)) return "image/gif"; + if (/\.svg$/i.test(path)) return "image/svg+xml"; + if (/\.pdf$/i.test(path)) return "application/pdf"; + return "application/octet-stream"; +} + +function collectFiles(root: string, current = root, files: string[] = []): string[] { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "__MACOSX") continue; + const path = join(current, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) collectFiles(root, path, files); + else if (entry.isFile()) { + files.push(path); + if (files.length > MAX_FILES) return files; + } + } + return files; +} + +function encodePath(path: string): string { + return path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function indexSkill(skill: InstalledSkillPackage): IndexedSkill | undefined { + const root = realpathSync(skill.package_path); + const files = collectFiles(root).toSorted((left, right) => { + if (basename(left) === "SKILL.md") return -1; + if (basename(right) === "SKILL.md") return 1; + return left.localeCompare(right); + }); + if (files.length === 0 || files.length > MAX_FILES) return undefined; + const records = files.map((path) => { + const normalized = relative(root, path).split(sep).join("/"); + if (!normalized || normalized.startsWith("../") || normalized.includes("/../")) { + throw new Error(`Unsafe skill resource path: ${normalized}`); + } + return { path, normalized, bytes: readFileSync(path) }; + }); + const skillFile = records.find((file) => file.normalized === "SKILL.md"); + if (!skillFile || skillFile.bytes.byteLength > MAX_SKILL_MD_BYTES) return undefined; + if (records.some((file) => file.bytes.byteLength > MAX_RESOURCE_BYTES)) return undefined; + if (records.reduce((sum, file) => sum + file.bytes.byteLength, 0) > MAX_SKILL_BYTES) + return undefined; + + const content = skillFile.bytes.toString("utf8"); + const parsed = parseFrontmatter(content); + const frontmatter = parseSkillFrontmatter(content); + const name = String(frontmatter.name || parsed.name || skill.name).trim(); + const description = String(frontmatter.description || parsed.description || "").trim(); + if (!name || !description || name !== basename(root)) return undefined; + const revision = createHash("sha256") + .update( + records + .map((file) => `${file.normalized}\0${digest(file.bytes)}`) + .sort() + .join("\n"), + ) + .digest("hex"); + const rootUri = `skill://selftune/${revision}/${encodeURIComponent(name)}`; + const resources = records.map((file) => ({ + uri: `${rootUri}/${encodePath(file.normalized)}`, + digest: digest(file.bytes), + path: file.path, + mimeType: mimeType(file.normalized), + bytes: file.bytes, + })); + const uri = resources.find((resource) => resource.path === skillFile.path)?.uri; + if (!uri) return undefined; + return { + name, + description, + revision, + resources, + entry: { + uri, + frontmatter: { ...frontmatter, name, description }, + resources: resources.map(({ uri: resourceUri, digest: resourceDigest }) => ({ + uri: resourceUri, + digest: resourceDigest, + })), + }, + }; +} + +function tokens(value: string): string[] { + return value.toLowerCase().match(/[a-z0-9][a-z0-9_-]*/g) ?? []; +} + +function score(skill: IndexedSkill, query: string): number { + const terms = [...new Set(tokens(query))]; + if (terms.length === 0) return 0; + const name = skill.name.toLowerCase(); + const description = skill.description.toLowerCase(); + let value = 0; + for (const term of terms) { + if (name === term) value += 12; + else if (name.includes(term)) value += 7; + if (description.includes(term)) value += 3; + } + if (`${name} ${description}`.includes(query.trim().toLowerCase())) value += 8; + return value / terms.length; +} + +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export class LocalSkillRegistry { + private readonly skills: IndexedSkill[]; + private readonly pageSize: number; + + constructor(options: SkillRegistryOptions = {}) { + const cwd = options.cwd ?? process.cwd(); + const homeDir = options.homeDir ?? process.env.SELFTUNE_HOME ?? process.env.HOME ?? ""; + const codexHome = options.codexHome ?? process.env.CODEX_HOME ?? join(homeDir, ".codex"); + const searchDirs = options.searchDirs ?? getDefaultSkillSearchDirs(cwd, homeDir, codexHome); + const seen = new Set(); + this.skills = findInstalledSkillPackages(searchDirs, homeDir, codexHome) + .map((candidate) => { + try { + return indexSkill(candidate); + } catch { + return undefined; + } + }) + .filter((candidate): candidate is IndexedSkill => { + if (!candidate || seen.has(candidate.name)) return false; + seen.add(candidate.name); + return true; + }); + this.pageSize = Math.max(1, Math.min(options.pageSize ?? 50, 100)); + } + + list(cursor?: string): { skills: McpSkillEntry[]; nextCursor?: string } { + const offset = cursor === undefined ? 0 : Number.parseInt(cursor, 10); + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid cursor"); + const page = this.skills.slice(offset, offset + this.pageSize); + const next = offset + page.length; + return { + skills: page.map((skill) => skill.entry), + ...(next < this.skills.length ? { nextCursor: String(next) } : {}), + }; + } + + get(uri: string): McpSkillEntry { + const skill = this.skills.find((candidate) => candidate.entry.uri === uri); + if (!skill) throw new Error("Unknown skill URI"); + return skill.entry; + } + + read(uri: string): { contents: Array> } { + const resource = this.skills + .flatMap((skill) => skill.resources) + .find((item) => item.uri === uri); + if (!resource) throw new Error("Unknown resource URI"); + const bytes = resource.bytes; + return resource.mimeType.startsWith("text/") || resource.mimeType === "application/json" + ? { + contents: [ + { uri, mimeType: resource.mimeType, text: Buffer.from(bytes).toString("utf8") }, + ], + } + : { + contents: [ + { uri, mimeType: resource.mimeType, blob: Buffer.from(bytes).toString("base64") }, + ], + }; + } + + search(query: string, limit = 5): Array> { + const bounded = Math.max(1, Math.min(limit, 20)); + return this.skills + .map((skill) => ({ skill, score: score(skill, query) })) + .filter((item) => item.score > 0) + .toSorted( + (left, right) => + right.score - left.score || left.skill.name.localeCompare(right.skill.name), + ) + .slice(0, bounded) + .map(({ skill, score: relevance }) => ({ + uri: skill.entry.uri, + name: skill.name, + description: skill.description, + revision: skill.revision, + score: relevance, + })); + } +} + +export function handleSkillRegistryRequest( + registry: LocalSkillRegistry, + request: JsonRpcRequest, +): JsonRpcResponse | undefined { + if (request.id === undefined) return undefined; + const id = request.id; + try { + const params = object(request.params); + switch (request.method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2026-07-28", + serverInfo: { name: "selftune-skill-registry", version: "0.1.0" }, + capabilities: { + extensions: { "io.modelcontextprotocol/skills": {} }, + resources: {}, + tools: {}, + }, + instructions: + "Search the local skill catalog before broad skill-related tasks. Loading instructions never grants tool permissions.", + }, + }; + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + case "skills/list": + return { + jsonrpc: "2.0", + id, + result: registry.list(typeof params.cursor === "string" ? params.cursor : undefined), + }; + case "skills/get": + if (typeof params.uri !== "string") throw new Error("uri is required"); + return { jsonrpc: "2.0", id, result: { skill: registry.get(params.uri) } }; + case "resources/read": + if (typeof params.uri !== "string") throw new Error("uri is required"); + return { jsonrpc: "2.0", id, result: registry.read(params.uri) }; + case "tools/list": + return { jsonrpc: "2.0", id, result: { tools: toolDefinitions } }; + case "tools/call": { + const args = object(params.arguments); + if (params.name === "search_skills") { + if (typeof args.query !== "string" || !args.query.trim()) + throw new Error("query is required"); + const results = registry.search( + args.query, + typeof args.limit === "number" ? args.limit : undefined, + ); + return toolResult(id, { results }); + } + if (params.name === "load_skill") { + if (typeof args.uri !== "string") throw new Error("uri is required"); + const skill = registry.get(args.uri); + return toolResult(id, { skill, ...registry.read(skill.uri) }); + } + throw new Error("Unknown tool"); + } + default: + return { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } }; + } + } catch (error) { + return { + jsonrpc: "2.0", + id, + error: { code: -32602, message: error instanceof Error ? error.message : String(error) }, + }; + } +} + +const toolDefinitions = [ + { + name: "search_skills", + title: "Search local skills", + description: + "Search SelfTune's local skill catalog by task or intent. Returns canonical immutable skill URIs; use load_skill on the selected URI.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20 }, + }, + required: ["query"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + { + name: "load_skill", + title: "Load a local skill", + description: + "Load one exact skill revision by canonical URI, including SKILL.md and its resource manifest. Does not install, execute, or grant permissions.", + inputSchema: { + type: "object", + properties: { uri: { type: "string", pattern: "^skill://" } }, + required: ["uri"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, +] as const; + +function toolResult(id: string | number | null, value: unknown): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + result: { + structuredContent: value, + content: [{ type: "text", text: JSON.stringify(value) }], + }, + }; +} + +export async function runSkillRegistryStdio(options: SkillRegistryOptions = {}): Promise { + const registry = new LocalSkillRegistry(options); + const decoder = new TextDecoder(); + let pending = ""; + for await (const chunk of Bun.stdin.stream()) { + pending += decoder.decode(chunk, { stream: true }); + let newline = pending.indexOf("\n"); + while (newline >= 0) { + const line = pending.slice(0, newline).trim(); + pending = pending.slice(newline + 1); + if (line) { + let response: JsonRpcResponse | undefined; + try { + response = handleSkillRegistryRequest(registry, JSON.parse(line)); + } catch { + response = { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }; + } + if (response) process.stdout.write(`${JSON.stringify(response)}\n`); + } + newline = pending.indexOf("\n"); + } + } + const finalLine = pending.trim(); + if (finalLine) { + let response: JsonRpcResponse | undefined; + try { + response = handleSkillRegistryRequest(registry, JSON.parse(finalLine)); + } catch { + response = { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }; + } + if (response) process.stdout.write(`${JSON.stringify(response)}\n`); + } +} diff --git a/scripts/pack-package.ts b/scripts/pack-package.ts index b7ed1d39..7524a9af 100644 --- a/scripts/pack-package.ts +++ b/scripts/pack-package.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -104,6 +104,23 @@ const program = Effect.scoped( "--ignore-scripts", ...process.argv.slice(2), ]); + const tarballName = result.stdout.trim().split("\n").at(-1); + if (!tarballName) { + return yield* PackageBuildFailure.make({ + operation: "locate packed npm artifact", + message: "npm pack did not report a tarball name.", + }); + } + const destinationIndex = process.argv.indexOf("--pack-destination"); + const destination = + destinationIndex >= 0 && process.argv[destinationIndex + 1] + ? resolve(process.argv[destinationIndex + 1]!) + : repositoryRoot; + yield* runCommand("sanitize packed npm manifest", [ + "node", + "scripts/sanitize-packed-package.cjs", + join(destination, tarballName), + ]); yield* Effect.sync(() => { if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); diff --git a/scripts/publish-package-json.cjs b/scripts/publish-package-json.cjs index a9bf191b..bb438a32 100644 --- a/scripts/publish-package-json.cjs +++ b/scripts/publish-package-json.cjs @@ -252,6 +252,22 @@ if (mode === "prepare") { normalizeWorkspaceLinks(); } const pkg = readJson(packageJsonPath); + for (const workspacePackage of flattenedWorkspacePackages) { + const workspacePkg = readJson(path.join(repoRoot, workspacePackage.path)); + for (const [dependencyName, dependencySpec] of Object.entries( + workspacePkg.dependencies ?? {}, + )) { + if (dependencyName.startsWith("@selftune/") || dependencySpec === workspaceSpec) continue; + const existingSpec = pkg.dependencies?.[dependencyName]; + if (existingSpec !== undefined && existingSpec !== dependencySpec) { + throw new Error( + `Conflicting published dependency ${dependencyName}: ${existingSpec} vs ${dependencySpec}`, + ); + } + pkg.dependencies ??= {}; + pkg.dependencies[dependencyName] = dependencySpec; + } + } const changes = Object.entries(workspaceDependencies).filter( ([dependencyName]) => pkg.dependencies?.[dependencyName] === workspaceSpec, ); @@ -265,17 +281,14 @@ if (mode === "prepare") { for (const workspacePackage of flattenedWorkspacePackages) { const workspacePackagePath = path.join(repoRoot, workspacePackage.path); const workspacePkg = readJson(workspacePackagePath); - workspacePkg.peerDependencies = { - ...workspacePkg.peerDependencies, - ...Object.fromEntries( - Object.entries(workspacePkg.dependencies ?? {}).map( - ([dependencyName, dependencySpec]) => [ - dependencyName, - dependencySpec === workspaceSpec ? "*" : dependencySpec, - ], - ), + workspacePkg.peerDependencies = Object.fromEntries( + Object.entries(workspacePkg.peerDependencies ?? {}).filter( + ([dependencyName]) => !dependencyName.startsWith("@selftune/"), ), - }; + ); + if (Object.keys(workspacePkg.peerDependencies).length === 0) { + delete workspacePkg.peerDependencies; + } delete workspacePkg.dependencies; delete workspacePkg.devDependencies; workspacePkg.files = workspacePackage.files; diff --git a/scripts/sanitize-packed-package.cjs b/scripts/sanitize-packed-package.cjs new file mode 100644 index 00000000..6f23bc7f --- /dev/null +++ b/scripts/sanitize-packed-package.cjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +function sanitizeRootManifest(manifest) { + const dependencies = Object.fromEntries( + Object.entries(manifest.dependencies ?? {}).filter( + ([dependencyName]) => !dependencyName.startsWith("@selftune/"), + ), + ); + return { ...manifest, dependencies }; +} + +function sanitizePackedPackage(tarballPath) { + const absoluteTarballPath = path.resolve(tarballPath); + const temporaryRoot = fs.mkdtempSync( + path.join(path.dirname(absoluteTarballPath), ".selftune-packed-package-"), + ); + const rewrittenTarball = path.join(temporaryRoot, path.basename(absoluteTarballPath)); + try { + execFileSync("tar", ["-xzf", absoluteTarballPath, "-C", temporaryRoot]); + const manifestPath = path.join(temporaryRoot, "package", "package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + fs.writeFileSync(manifestPath, `${JSON.stringify(sanitizeRootManifest(manifest), null, 2)}\n`); + execFileSync("tar", ["-czf", rewrittenTarball, "-C", temporaryRoot, "package"]); + fs.renameSync(rewrittenTarball, absoluteTarballPath); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +} + +if (require.main === module) { + const tarballPath = process.argv[2]; + if (!tarballPath) { + console.error("Usage: node scripts/sanitize-packed-package.cjs "); + process.exit(1); + } + sanitizePackedPackage(tarballPath); +} + +module.exports = { sanitizePackedPackage, sanitizeRootManifest }; diff --git a/scripts/sanitize-packed-package.test.ts b/scripts/sanitize-packed-package.test.ts new file mode 100644 index 00000000..53e677b2 --- /dev/null +++ b/scripts/sanitize-packed-package.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; + +const { sanitizeRootManifest } = require("./sanitize-packed-package.cjs") as { + sanitizeRootManifest: (manifest: Record) => Record; +}; + +test("removes registry-unresolvable bundled workspace dependencies from the final manifest", () => { + expect( + sanitizeRootManifest({ + name: "selftune", + dependencies: { + "@selftune/runtime": "1.0.0", + effect: "4.0.0-beta.66", + }, + bundledDependencies: ["@selftune/runtime"], + }), + ).toEqual({ + name: "selftune", + dependencies: { effect: "4.0.0-beta.66" }, + bundledDependencies: ["@selftune/runtime"], + }); +}); diff --git a/scripts/smoke-packed-package.ts b/scripts/smoke-packed-package.ts index a2807899..332ee61a 100644 --- a/scripts/smoke-packed-package.ts +++ b/scripts/smoke-packed-package.ts @@ -173,6 +173,22 @@ const program = Effect.scoped( ); const packageRoot = join(extractionRoot, "package"); + const packedManifest = yield* Effect.try({ + try: () => + JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }, + catch: (cause) => failure("read packed package manifest", cause), + }); + const leakedWorkspaceDependency = Object.keys(packedManifest.dependencies ?? {}).find((name) => + name.startsWith("@selftune/"), + ); + if (leakedWorkspaceDependency) { + return yield* PackageSmokeFailure.make({ + operation: "check packed package manifest", + message: `Bundled workspace dependency still requires registry resolution: ${leakedWorkspaceDependency}`, + }); + } const inventory = packageInventory(packageRoot); if (inventory.entries.length > maximumEntries) { return yield* PackageSmokeFailure.make({ @@ -226,6 +242,21 @@ const program = Effect.scoped( ["npm", "install", "--ignore-scripts", tarballPath], consumerRoot, ); + const bunConsumerRoot = join(temporaryRoot, "bun-consumer"); + mkdirSync(bunConsumerRoot, { recursive: true }); + yield* Effect.tryPromise({ + try: () => + writeFile( + join(bunConsumerRoot, "package.json"), + `${JSON.stringify({ name: "selftune-bun-package-smoke", private: true, version: "1.0.0" }, null, 2)}\n`, + ), + catch: (cause) => failure("write Bun consumer manifest", cause), + }); + yield* runCommand( + "install npm artifact with Bun", + ["bun", "add", "--ignore-scripts", tarballPath], + bunConsumerRoot, + ); const installedPackageRoot = join(consumerRoot, "node_modules", manifest.name); const duckDbProbePath = join(installedPackageRoot, "packed-duckdb-probe.mjs"); const duckDbDatabasePath = join(temporaryRoot, "packed-observability.duckdb"); diff --git a/skill/SKILL.md b/skill/SKILL.md index f4b94003..1d720030 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -14,7 +14,7 @@ description: > they do not name SelfTune explicitly. metadata: author: selftune-dev - version: 0.4.10 + version: 0.4.11 category: developer-tools --- @@ -89,6 +89,7 @@ Commands vary in output format: - **Mixed runtime output:** `selftune run` / `selftune orchestrate` emit JSON on stdout and a human report on stderr. - **JSON opt-in:** `selftune sync --json` enables structured JSON output. - **Server:** `selftune dashboard` starts a local SPA server — it does not emit data. +- **MCP server:** `selftune mcp serve` exposes the read-only local skill registry over stdio. - **JSON:** `selftune library` reconciles installed, cached, draft, and archived packages. - **Search:** `selftune skills search "task or collection" --json` finds local skills without activating them; see `workflows/SkillSearch.md`. diff --git a/skill/references/cli-quick-reference.md b/skill/references/cli-quick-reference.md index 8fe21c7a..324ef1a3 100644 --- a/skill/references/cli-quick-reference.md +++ b/skill/references/cli-quick-reference.md @@ -48,6 +48,7 @@ selftune status selftune last selftune doctor selftune dashboard [--port ] [--no-open] +selftune mcp serve # read-only local Skills-over-MCP server over stdio selftune daemon run [--port PORT] [--hostname HOST] [--spa-dir PATH] [--config-dir PATH] [--owner cli|desktop] [--supervised] selftune daemon status [--config-dir PATH] [--json] selftune daemon stop [--config-dir PATH] diff --git a/tests/cli/cli-routing-parity.test.ts b/tests/cli/cli-routing-parity.test.ts index b92c7040..0e17d5fb 100644 --- a/tests/cli/cli-routing-parity.test.ts +++ b/tests/cli/cli-routing-parity.test.ts @@ -19,6 +19,7 @@ const documentedTopLevelCommands = [ "dashboard", "daemon", "service", + "mcp", "evolve", "search-run", "eval", @@ -98,6 +99,7 @@ function extractDocumentedCommands(help: string): string[] { function extractRegisteredCommands(): Set { const commands = new Set(LEGACY_COMMANDS); for (const command of FULLY_EFFECT_OWNED_COMMANDS) commands.add(command); + commands.add("mcp"); return commands; } @@ -118,7 +120,7 @@ describe("CLI routing parity", () => { for (const command of FULLY_EFFECT_OWNED_COMMANDS) { expect(runCli(command, "--help")).toContain(`selftune ${command}`); } - }, 15_000); + }, 30_000); for (const fixture of groupedHelpFixtures) { test(`${fixture.command} dispatches to its grouped help surface`, () => { diff --git a/tests/cli/effect-cli-compat.test.ts b/tests/cli/effect-cli-compat.test.ts index 25ca1e09..b3ec01ad 100644 --- a/tests/cli/effect-cli-compat.test.ts +++ b/tests/cli/effect-cli-compat.test.ts @@ -187,6 +187,7 @@ describe("hybrid Effect CLI compatibility", () => { "pi", "daemon", "service", + "mcp", ]); expect(mainSource.match(/!FAST_COMMANDS\.has\(command\)/g)).toHaveLength(2); expect(mainSource.match(/command !== "--help"/g)).toHaveLength(2); diff --git a/tests/mcp/skill-registry.test.ts b/tests/mcp/skill-registry.test.ts new file mode 100644 index 00000000..7ffb1513 --- /dev/null +++ b/tests/mcp/skill-registry.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + handleSkillRegistryRequest, + LocalSkillRegistry, + parseSkillFrontmatter, +} from "../../packages/runtime/mcp/skill-registry.js"; + +const root = join(import.meta.dir, ".tmp-skill-registry"); + +describe("MCP skill registry", () => { + beforeEach(() => { + rmSync(root, { recursive: true, force: true }); + mkdirSync(join(root, "research", "references"), { recursive: true }); + writeFileSync( + join(root, "research", "SKILL.md"), + `---\nname: research\ndescription: >\n Investigate questions against primary sources.\nversion: 1.2.3\nmetadata:\n owner: selftune\n---\n# Research\n\nUse primary sources.\n`, + ); + writeFileSync(join(root, "research", "references", "quality.md"), "Check evidence.\n"); + writeFileSync(join(root, "research", ".env"), "SECRET=never-serve-this\n"); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test("parses all top-level frontmatter entries", () => { + expect( + parseSkillFrontmatter( + "---\nname: demo\ndescription: hello\nversion: 2\nenabled: true\n---\n", + ), + ).toEqual({ name: "demo", description: "hello", version: 2, enabled: true }); + }); + + test("serves the OpenAI Skills-over-MCP subset", () => { + const registry = new LocalSkillRegistry({ searchDirs: [root], pageSize: 1 }); + const initialize = handleSkillRegistryRequest(registry, { id: 1, method: "initialize" }); + expect(initialize?.result).toMatchObject({ + capabilities: { extensions: { "io.modelcontextprotocol/skills": {} } }, + }); + const listed = handleSkillRegistryRequest(registry, { + id: 2, + method: "skills/list", + params: {}, + }); + const result = listed?.result as { skills: McpListedSkill[] }; + expect(result.skills).toHaveLength(1); + const skill = result.skills[0]; + expect(skill.uri).toMatch(/^skill:\/\/selftune\/[a-f0-9]{64}\/research\/SKILL\.md$/); + expect(skill.frontmatter).toMatchObject({ + name: "research", + description: "Investigate questions against primary sources.", + version: "1.2.3", + metadata: { owner: "selftune" }, + }); + expect(skill.resources).toHaveLength(2); + expect(skill.resources.every((resource) => /^sha256:[a-f0-9]{64}$/.test(resource.digest))).toBe( + true, + ); + expect( + handleSkillRegistryRequest(registry, { + id: 3, + method: "skills/get", + params: { uri: skill.uri }, + })?.result, + ).toEqual({ skill }); + expect( + handleSkillRegistryRequest(registry, { + id: 4, + method: "resources/read", + params: { uri: skill.uri }, + })?.result, + ).toMatchObject({ + contents: [{ uri: skill.uri, text: expect.stringContaining("Use primary sources") }], + }); + + writeFileSync(join(root, "research", "SKILL.md"), "changed after indexing"); + expect( + handleSkillRegistryRequest(registry, { + id: 5, + method: "resources/read", + params: { uri: skill.uri }, + })?.result, + ).toMatchObject({ contents: [{ text: expect.stringContaining("Use primary sources") }] }); + }); + + test("searches metadata and loads an immutable revision", () => { + const registry = new LocalSkillRegistry({ searchDirs: [root] }); + const search = handleSkillRegistryRequest(registry, { + id: 1, + method: "tools/call", + params: { name: "search_skills", arguments: { query: "primary source investigation" } }, + }); + const found = search?.result as { structuredContent: { results: Array<{ uri: string }> } }; + expect(found.structuredContent.results).toHaveLength(1); + const load = handleSkillRegistryRequest(registry, { + id: 2, + method: "tools/call", + params: { name: "load_skill", arguments: { uri: found.structuredContent.results[0].uri } }, + }); + expect(load?.error).toBeUndefined(); + expect(load?.result).toMatchObject({ + structuredContent: { contents: [{ text: expect.stringContaining("Use primary sources") }] }, + }); + }); + + test("skips packages that exceed the bounded resource manifest", () => { + for (let index = 0; index < 100; index += 1) { + writeFileSync(join(root, "research", `resource-${index}.txt`), String(index)); + } + const registry = new LocalSkillRegistry({ searchDirs: [root] }); + expect(registry.list().skills).toEqual([]); + }); +}); + +interface McpListedSkill { + uri: string; + frontmatter: Record; + resources: Array<{ uri: string; digest: string }>; +} diff --git a/tests/observability/duckdb-module-resolution.test.ts b/tests/observability/duckdb-module-resolution.test.ts new file mode 100644 index 00000000..af7428ff --- /dev/null +++ b/tests/observability/duckdb-module-resolution.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; + +import { + resolvePackagedDuckDbModule, + resolvePackagedDuckDbModulePath, +} from "@selftune/observability/duckdb-module-resolution"; + +const suffix = "node_modules/@duckdb/node-api/lib/index.js"; + +test("prefers the explicit Desktop resource directory", () => { + expect( + resolvePackagedDuckDbModulePath({ + desktopResourceDirectory: "/app/resources/selftune", + executablePath: "/managed/runtime/selftune", + pathExists: (path) => path === `/app/resources/selftune/${suffix}`, + }), + ).toBe(`/app/resources/selftune/${suffix}`); +}); + +test("discovers DuckDB beside a packaged CLI executable without Desktop environment variables", () => { + expect( + resolvePackagedDuckDbModulePath({ + executablePath: "/managed/runtime/selftune", + pathExists: (path) => path === `/managed/runtime/${suffix}`, + }), + ).toBe(`/managed/runtime/${suffix}`); + expect( + resolvePackagedDuckDbModule({ + executablePath: "/managed/runtime/selftune", + pathExists: (path) => path === `/managed/runtime/${suffix}`, + }), + ).toEqual({ + modulePath: `/managed/runtime/${suffix}`, + resourceDirectory: "/managed/runtime", + }); +}); + +test("falls back to normal package resolution for a source CLI", () => { + expect( + resolvePackagedDuckDbModulePath({ + executablePath: "/usr/local/bin/bun", + pathExists: () => false, + }), + ).toBeUndefined(); +}); diff --git a/tests/runtime/desktop-runtime-cli.test.ts b/tests/runtime/desktop-runtime-cli.test.ts new file mode 100644 index 00000000..c8b56f4d --- /dev/null +++ b/tests/runtime/desktop-runtime-cli.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { resolveDesktopRuntime, versionAtLeast } = require("../../bin/desktop-runtime.cjs") as { + resolveDesktopRuntime: ( + installedVersion: string, + options: { dataRoots: string[]; environment?: Record; platform: string }, + ) => string | null; + versionAtLeast: (candidate: string, installed: string) => boolean; +}; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { force: true, recursive: true }))); + +function desktopRuntime(version: string) { + const dataRoot = mkdtempSync(join(tmpdir(), "selftune-desktop-cli-")); + roots.push(dataRoot); + const destination = join(dataRoot, "runtime", version); + mkdirSync(destination, { recursive: true }); + const executable = join(destination, "selftune"); + writeFileSync(executable, "#!/bin/sh\n", { mode: 0o700 }); + chmodSync(executable, 0o700); + writeFileSync( + join(dataRoot, "runtime", "current.json"), + `${JSON.stringify({ version, path: destination })}\n`, + ); + return { dataRoot, executable }; +} + +test("uses the Desktop-managed runtime when it is at least as new as the npm bootstrap", () => { + const fixture = desktopRuntime("0.4.12"); + expect( + resolveDesktopRuntime("0.4.11", { dataRoots: [fixture.dataRoot], platform: "darwin" }), + ).toBe(realpathSync(fixture.executable)); +}); + +test("keeps a newer standalone CLI and honors the explicit opt-out", () => { + const fixture = desktopRuntime("0.4.10"); + expect( + resolveDesktopRuntime("0.4.11", { dataRoots: [fixture.dataRoot], platform: "darwin" }), + ).toBeNull(); + expect( + resolveDesktopRuntime("0.4.9", { + dataRoots: [fixture.dataRoot], + environment: { SELFTUNE_DISABLE_DESKTOP_RUNTIME: "1" }, + platform: "darwin", + }), + ).toBeNull(); +}); + +test("rejects a current pointer that escapes the Desktop runtime directory", () => { + const fixture = desktopRuntime("0.4.12"); + const outside = join(fixture.dataRoot, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "selftune"), "#!/bin/sh\n", { mode: 0o700 }); + writeFileSync( + join(fixture.dataRoot, "runtime", "current.json"), + `${JSON.stringify({ version: "0.4.12", path: outside })}\n`, + ); + expect( + resolveDesktopRuntime("0.4.11", { dataRoots: [fixture.dataRoot], platform: "darwin" }), + ).toBeNull(); +}); + +test("rejects symlink escapes and compares release versions numerically", () => { + const fixture = desktopRuntime("0.4.12"); + const outside = join(fixture.dataRoot, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "selftune"), "#!/bin/sh\n", { mode: 0o700 }); + const linked = join(fixture.dataRoot, "runtime", "0.4.13"); + symlinkSync(outside, linked); + writeFileSync( + join(fixture.dataRoot, "runtime", "current.json"), + `${JSON.stringify({ version: "0.4.13", path: linked })}\n`, + ); + expect( + resolveDesktopRuntime("0.4.11", { dataRoots: [fixture.dataRoot], platform: "darwin" }), + ).toBeNull(); + expect(versionAtLeast("0.10.0", "0.9.9")).toBe(true); + expect(versionAtLeast("0.4.10", "0.4.11")).toBe(false); +}); diff --git a/tests/trust-floor/publish-deps.test.ts b/tests/trust-floor/publish-deps.test.ts index 9f178e43..6a80bbf5 100644 --- a/tests/trust-floor/publish-deps.test.ts +++ b/tests/trust-floor/publish-deps.test.ts @@ -288,20 +288,19 @@ describe("publish dependency protocol", () => { } expect(config.dependencies).toBeUndefined(); expect(config.devDependencies).toBeUndefined(); - expect(config.peerDependencies?.effect).toBe(pkg.dependencies.effect); + expect(config.peerDependencies).toBeUndefined(); expect(config.files).toEqual(["src/**/*.ts", ...developmentOnlyPackageFiles]); expect(harnessRegistry.dependencies).toBeUndefined(); expect(harnessRegistry.devDependencies).toBeUndefined(); - expect(harnessRegistry.peerDependencies?.["@selftune/harness-core"]).toBe("*"); + expect(harnessRegistry.peerDependencies).toBeUndefined(); expect(harnessRegistry.files).toEqual(["src/**/*.ts", ...developmentOnlyPackageFiles]); expect(observability.dependencies).toBeUndefined(); expect(observability.devDependencies).toBeUndefined(); - expect(observability.peerDependencies?.["@duckdb/node-api"]).toBe("1.4.5-r.1"); - expect(observability.peerDependencies?.effect).toBe(pkg.dependencies.effect); + expect(observability.peerDependencies).toBeUndefined(); expect(observability.files).toEqual(["src/**/*.ts", ...developmentOnlyPackageFiles]); expect(contract.dependencies).toBeUndefined(); expect(contract.devDependencies).toBeUndefined(); - expect(contract.peerDependencies?.zod).toBe("^4.3.6"); + expect(contract.peerDependencies).toBeUndefined(); expect(contract.files).toContain("index.ts"); const localStore = JSON.parse( readFileSync(join(ROOT, "packages/local-store/package.json"), "utf-8"),