diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 0215769..958b55d 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -3,7 +3,7 @@ import readline from "node:readline"; import { styleText } from "node:util"; import { removeFile, removeEmptyDirs, displayPath } from "../utils/fs-utils.ts"; import { log } from "../utils/log.ts"; -import { getPlatform, PLATFORMS } from "../core/constants.ts"; +import { resolvePlatforms, PLATFORMS } from "../core/constants.ts"; import type { Platform } from "../core/constants.ts"; import { getSections, readConfigOrExit } from "../core/resolver.ts"; import { loadHashDB, saveHashDB, normalizeKey } from "../core/hash-db.ts"; @@ -71,9 +71,9 @@ async function removeFilesForPlatform(platform: Platform, sections: ReturnType): void { +function sweepOrphanedEntries(hashDB: HashDB, sections: ReturnType, extraPlatforms: Platform[] = []): void { const allKnownPrefixes = new Set(); - for (const platform of PLATFORMS) { + for (const platform of [...PLATFORMS, ...extraPlatforms]) { for (const section of sections) { allKnownPrefixes.add(normalizeKey(path.join(platform.targetDir, section.name)) + "/"); } @@ -92,9 +92,7 @@ export async function clean(options: CleanOptions = {}): Promise { const hashDB = await loadHashDB(); const sections = getSections(config); - const activePlatforms = config.platforms - .map((n) => getPlatform(n)) - .filter((p): p is NonNullable => p !== undefined); + const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg)); const activePlatformDirs = new Set(activePlatforms.map((p) => p.targetDir)); const inactivePlatforms = PLATFORMS.filter((p) => !activePlatformDirs.has(p.targetDir)); @@ -148,7 +146,7 @@ export async function clean(options: CleanOptions = {}): Promise { } } - sweepOrphanedEntries(hashDB, sections); + sweepOrphanedEntries(hashDB, sections, activePlatforms); await saveHashDB(hashDB); console.log(); diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..309b0d5 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,121 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { styleText } from "node:util"; +import { log } from "../utils/log.ts"; +import { resolvePlatforms } from "../core/constants.ts"; +import { resolveEntries, deduplicateEntries, expandEntries, getSections, readConfigOrExit } from "../core/resolver.ts"; +import { loadHashDB, normalizeKey, md5 } from "../core/hash-db.ts"; + +type FileState = "synced" | "modified" | "missing" | "new"; + +/** Determines the most severe state for a file across all active platforms. */ +async function resolveState( + fileName: string, + sectionName: string, + platforms: Awaited>, + hashDB: Record, +): Promise { + let state: FileState = "new"; + + for (const platform of platforms) { + const destPath = path.join(platform.targetDir, sectionName, fileName); + const stored = hashDB[normalizeKey(destPath)]; + + if (!stored) continue; + + let diskHash: string | null = null; + try { + diskHash = md5(await fs.readFile(destPath)); + } catch { + // file is missing from disk + } + + if (diskHash === null) { + state = "missing"; + } else if (diskHash !== stored) { + return "modified"; // worst case — return immediately + } else if (state === "new") { + state = "synced"; + } + } + + return state; +} + +const STATE_LABEL: Record = { + synced: "synced ", + modified: "modified", + missing: "missing ", + new: "new ", +}; + +function renderFileState(fileName: string, state: FileState): void { + const name = fileName.replace(/\\/g, "/"); + const label = STATE_LABEL[state]; + const I = " "; + + switch (state) { + case "synced": + console.log(`${I} ${styleText("green", "✓")} ${styleText("dim", label)} ${name}`); + break; + case "modified": + console.log(`${I} ${styleText("yellow", "⚠")} ${styleText("yellow", label)} ${name} ${styleText("dim", "(locally modified, use --force to overwrite)")}`); + break; + case "missing": + console.log(`${I} ${styleText("red", "✗")} ${styleText("dim", label)} ${name} ${styleText("dim", "(was synced but removed from destination)")}`); + break; + case "new": + console.log(`${I} ${styleText("cyan", "+")} ${styleText("dim", label)} ${name} ${styleText("dim", "(not yet synced)")}`); + break; + } +} + +/** Shows the sync state of each configured file across all active platforms. */ +export async function status(): Promise { + log.header("status"); + + const config = await readConfigOrExit(); + const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg)); + + if (activePlatforms.length === 0) { + log.warn("No platforms configured. Add platforms in cortex.toml."); + log.outro("Nothing to show."); + return; + } + + const hashDB = await loadHashDB(); + const counts: Record = { synced: 0, modified: 0, missing: 0, new: 0 }; + let totalFiles = 0; + + for (const section of getSections(config)) { + const raw = await resolveEntries(section.paths); + const entries = deduplicateEntries(await expandEntries(raw)); + + if (entries.length === 0) continue; + + console.log(` ${styleText("dim", section.name)}`); + + for (const { fileName } of entries) { + const state = await resolveState(fileName, section.name, activePlatforms, hashDB); + renderFileState(fileName, state); + counts[state]++; + totalFiles++; + } + + console.log(); + } + + if (totalFiles === 0) { + log.outro("No files configured."); + return; + } + + const parts: string[] = []; + if (counts.synced) parts.push(styleText("green", `${counts.synced} synced`)); + if (counts.modified) parts.push(styleText("yellow", `${counts.modified} modified`)); + if (counts.missing) parts.push(styleText("red", `${counts.missing} missing`)); + if (counts.new) parts.push(styleText("cyan", `${counts.new} new`)); + + console.log(` ${parts.join(" · ")}`); + console.log(); +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index ea8096b..c99838c 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,12 +1,11 @@ import path from "node:path"; -import fs from "node:fs/promises"; import { styleText } from "node:util"; -import { copyFileAtomic, removeFile, removeEmptyDirs, listMdFiles, fileExists, displayPath } from "../utils/fs-utils.ts"; +import { copyFileAtomic, removeFile, removeEmptyDirs, fileExists, displayPath } from "../utils/fs-utils.ts"; import { loadHashDB, saveHashDB, md5, isDirty, normalizeKey, type HashDB } from "../core/hash-db.ts"; import { log } from "../utils/log.ts"; -import { getPlatform } from "../core/constants.ts"; +import { resolvePlatforms } from "../core/constants.ts"; import type { Platform } from "../core/constants.ts"; -import { resolveEntries, deduplicateEntries, getSections, readConfigOrExit } from "../core/resolver.ts"; +import { resolveEntries, deduplicateEntries, expandEntries, getSections, readConfigOrExit } from "../core/resolver.ts"; import type { ResolvedEntry } from "../core/resolver.ts"; import { renderTree } from "../utils/tree.ts"; import { syncMCP } from "../core/mcp.ts"; @@ -23,25 +22,6 @@ interface SyncResult { failed: number; } -/** Expands directory entries into individual .md file entries. */ -async function expandEntries(entries: ResolvedEntry[]): Promise { - const result: ResolvedEntry[] = []; - for (const entry of entries) { - const stat = await fs.stat(entry.source).catch(() => null); - if (stat?.isDirectory()) { - const files = await listMdFiles(entry.source); - const dirName = path.basename(entry.source); - for (const file of files) { - const rel = path.relative(entry.source, file); - result.push({ source: file, fileName: path.join(dirName, rel) }); - } - } else { - result.push(entry); - } - } - return result; -} - /** Removes files from the target directory that are no longer in the resolved entries. */ async function removeStaleFiles(entries: ResolvedEntry[], targetDirPath: string, hashDB: HashDB): Promise { const expectedDests = new Set(entries.map(({ fileName }) => normalizeKey(path.join(targetDirPath, fileName)))); @@ -69,31 +49,47 @@ interface CopyResult { /** Copies resolved entries to the target directory, skipping dirty or unmanaged files unless forced. */ async function copyEntries(entries: ResolvedEntry[], targetDirPath: string, hashDB: HashDB, force: boolean): Promise { - const copied: string[] = []; - const skipped: TaggedEntry[] = []; - const failed: TaggedEntry[] = []; + type EntryOutcome = + | { kind: "copied"; rel: string; key: string; hash: string } + | { kind: "skipped"; rel: string; reason: string } + | { kind: "failed"; rel: string; reason: string }; - for (const { source, fileName } of entries) { + const outcomes = await Promise.all(entries.map(async ({ source, fileName }): Promise => { const destPath = path.join(targetDirPath, fileName); const relDest = fileName.replace(/\\/g, "/"); + log.verbose(`copy: ${source} → ${displayPath(destPath)}`); try { const isManaged = normalizeKey(destPath) in hashDB; const destExists = await fileExists(destPath); if (!force && destExists && !isManaged) { - skipped.push({ path: relDest, reason: "unmanaged" }); - continue; + log.verbose(` skip: unmanaged file at destination`); + return { kind: "skipped", rel: relDest, reason: "unmanaged" }; } if (!force && await isDirty(hashDB, destPath)) { - skipped.push({ path: relDest, reason: "locally modified" }); - continue; + log.verbose(` skip: locally modified (hash mismatch)`); + return { kind: "skipped", rel: relDest, reason: "locally modified" }; } const data = await copyFileAtomic(source, destPath); - hashDB[normalizeKey(destPath)] = md5(data); - copied.push(relDest); + return { kind: "copied", rel: relDest, key: normalizeKey(destPath), hash: md5(data) }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - failed.push({ path: relDest, reason: msg }); + return { kind: "failed", rel: relDest, reason: msg }; + } + })); + + const copied: string[] = []; + const skipped: TaggedEntry[] = []; + const failed: TaggedEntry[] = []; + + for (const outcome of outcomes) { + if (outcome.kind === "copied") { + hashDB[outcome.key] = outcome.hash; + copied.push(outcome.rel); + } else if (outcome.kind === "skipped") { + skipped.push({ path: outcome.rel, reason: outcome.reason }); + } else { + failed.push({ path: outcome.rel, reason: outcome.reason }); } } @@ -165,17 +161,15 @@ function renderSectionResult(sectionName: string, { copied, skipped, failed }: C interface SyncSectionOptions { section: { name: string; paths: string[] }; + entries: ResolvedEntry[]; targetDirPath: string; hashDB: HashDB; dryRun: boolean; force: boolean; } -/** Resolves, deduplicates, and syncs a single section (skills or agents) to a platform directory. */ -async function syncSection({ section, targetDirPath, hashDB, dryRun, force }: SyncSectionOptions): Promise { - const raw = await resolveEntries(section.paths); - const entries = deduplicateEntries(await expandEntries(raw)); - +/** Syncs pre-resolved entries for a single section to a platform target directory. */ +async function syncSection({ section, entries, targetDirPath, hashDB, dryRun, force }: SyncSectionOptions): Promise { if (!dryRun) await removeStaleFiles(entries, targetDirPath, hashDB); if (entries.length === 0) { @@ -194,19 +188,6 @@ async function syncSection({ section, targetDirPath, hashDB, dryRun, force }: Sy return { copied: result.copied.length, skipped: result.skipped.length, failed: result.failed.length }; } -/** Maps platform names from config to Platform objects, warning on unknown names. */ -function resolveActivePlatforms(platformNames: string[]): Platform[] { - const platforms: Platform[] = []; - for (const name of platformNames) { - const platform = getPlatform(name); - if (!platform) { - log.warn(`Unknown platform "${name}" — skipping.`); - continue; - } - platforms.push(platform); - } - return platforms; -} /** Syncs MCP server configs into each platform's config file and prints results. */ async function renderMcpResults(mcp: Record, platforms: Platform[], dryRun: boolean): Promise { @@ -261,14 +242,24 @@ async function syncKnowledgeFiles( force: boolean, ): Promise { const totals: SyncResult = { copied: 0, skipped: 0, failed: 0 }; + const sections = getSections(config); + + // Resolve and expand entries once — same source paths apply to every platform + const resolvedSections = await Promise.all( + sections.map(async (section) => { + const raw = await resolveEntries(section.paths); + const entries = deduplicateEntries(await expandEntries(raw)); + return { section, entries }; + }), + ); for (const platform of activePlatforms) { console.log(`${styleText("cyan", "●")} ${platform.name} ${styleText("dim", `(${displayPath(platform.targetDir)}/)`)}`); console.log(); - for (const section of getSections(config)) { + for (const { section, entries } of resolvedSections) { const targetDirPath = path.join(platform.targetDir, section.name); - const result = await syncSection({ section, targetDirPath, hashDB, dryRun, force }); + const result = await syncSection({ section, entries, targetDirPath, hashDB, dryRun, force }); console.log(); totals.copied += result.copied; totals.skipped += result.skipped; @@ -289,7 +280,7 @@ export async function sync(options: SyncOptions = {}): Promise { if (dryRun) log.warn("dry-run — no changes will be made"); const config = await readConfigOrExit(); - const activePlatforms = resolveActivePlatforms(config.platforms); + const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg)); if (activePlatforms.length === 0) { log.warn("No platforms configured. Add platforms in cortex.toml."); diff --git a/src/core/constants.ts b/src/core/constants.ts index c6991f8..d58400a 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -1,5 +1,6 @@ import path from "node:path"; import os from "node:os"; +import type { CustomPlatformDef } from "./parser.ts"; export const CORTEX_DIR = path.join(os.homedir(), ".cortex"); export const AI_DIR = path.join(CORTEX_DIR, "ai"); @@ -21,3 +22,35 @@ export const PLATFORMS: Platform[] = [ export function getPlatform(name: string): Platform | undefined { return PLATFORMS.find((p) => p.name === name); } + +/** + * Resolves platform names to Platform objects, merging built-ins with custom definitions. + * Custom definitions in cortex.toml take precedence over built-ins of the same name. + * Warns and skips names that resolve to neither a built-in nor a custom definition. + */ +export function resolvePlatforms( + names: string[], + custom?: Record, + warn?: (msg: string) => void, +): Platform[] { + const platforms: Platform[] = []; + for (const name of names) { + const customDef = custom?.[name]; + if (customDef) { + platforms.push({ + name, + targetDir: path.normalize(customDef.targetDir.replace(/^~/, os.homedir())), + mcpConfigPath: path.normalize(customDef.mcpConfigPath.replace(/^~/, os.homedir())), + mcpKey: customDef.mcpKey, + }); + } else { + const builtin = getPlatform(name); + if (!builtin) { + warn?.(`Unknown platform "${name}" — skipping.`); + continue; + } + platforms.push(builtin); + } + } + return platforms; +} diff --git a/src/core/mcp.ts b/src/core/mcp.ts index afbc2d4..4b4ceb5 100644 --- a/src/core/mcp.ts +++ b/src/core/mcp.ts @@ -36,16 +36,12 @@ export async function syncMCP( resolved[name] = resolveServer(server); } - const results: McpSyncResult[] = []; + if (dryRun) { + return platforms.map((platform) => ({ platform: platform.name, configPath: platform.mcpConfigPath, ok: true })); + } - for (const platform of platforms) { + return Promise.all(platforms.map(async (platform): Promise => { const configPath = platform.mcpConfigPath; - - if (dryRun) { - results.push({ platform: platform.name, configPath, ok: true }); - continue; - } - try { // Read existing JSON (if any), preserving all other keys let existing: Record = {}; @@ -68,14 +64,12 @@ export async function syncMCP( await fs.writeFile(tmp, out, "utf-8"); await fs.rename(tmp, configPath); - results.push({ platform: platform.name, configPath, ok: true }); + return { platform: platform.name, configPath, ok: true }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - results.push({ platform: platform.name, configPath, ok: false, error: msg }); + return { platform: platform.name, configPath, ok: false, error: msg }; } - } - - return results; + })); } export { displayPath } from "../utils/fs-utils.ts"; @@ -85,9 +79,7 @@ export async function cleanMCP( serverNames: string[], platforms: Platform[], ): Promise { - const results: McpSyncResult[] = []; - - for (const platform of platforms) { + return Promise.all(platforms.map(async (platform): Promise => { const configPath = platform.mcpConfigPath; try { let existing: Record = {}; @@ -95,8 +87,7 @@ export async function cleanMCP( const raw = await fs.readFile(configPath, "utf-8"); existing = JSON.parse(raw) as Record; } catch { - results.push({ platform: platform.name, configPath, ok: true }); - continue; + return { platform: platform.name, configPath, ok: true }; } const servers = (existing[platform.mcpKey] ?? {}) as Record; @@ -110,12 +101,10 @@ export async function cleanMCP( await fs.writeFile(tmp, out, "utf-8"); await fs.rename(tmp, configPath); - results.push({ platform: platform.name, configPath, ok: true }); + return { platform: platform.name, configPath, ok: true }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - results.push({ platform: platform.name, configPath, ok: false, error: msg }); + return { platform: platform.name, configPath, ok: false, error: msg }; } - } - - return results; + })); } diff --git a/src/core/parser.ts b/src/core/parser.ts index 8bb1c4f..44e91bb 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -10,12 +10,19 @@ export interface McpServer { cwd?: string; } +export interface CustomPlatformDef { + targetDir: string; + mcpConfigPath: string; + mcpKey: string; +} + export interface CortexConfig { platforms: string[]; deps: Record; skills: { paths: string[] }; agents: { paths: string[] }; mcp: Record; + platform?: Record; } const CONFIG_PATH = path.join(CORTEX_DIR, "cortex.toml"); @@ -33,6 +40,7 @@ export async function readConfig(): Promise { skills: (data.skills as { paths: string[] }) ?? { paths: [] }, agents: (data.agents as { paths: string[] }) ?? { paths: [] }, mcp: (data.mcp as Record) ?? {}, + platform: (data.platform as Record) ?? undefined, }; } diff --git a/src/core/resolver.ts b/src/core/resolver.ts index c7657d7..1fff510 100644 --- a/src/core/resolver.ts +++ b/src/core/resolver.ts @@ -1,5 +1,6 @@ +import fs from "node:fs/promises"; import path from "node:path"; -import { expandPath, resolveGlob } from "../utils/fs-utils.ts"; +import { expandPath, resolveGlob, listMdFiles } from "../utils/fs-utils.ts"; import { readConfig } from "./parser.ts"; import { log } from "../utils/log.ts"; import { DEPS_DIR } from "./constants.ts"; @@ -11,19 +12,18 @@ export interface ResolvedEntry { /** Expands glob patterns into resolved file entries. Warns on non-wildcard paths that match nothing. */ export async function resolveEntries(patterns: string[]): Promise { - const entries: ResolvedEntry[] = []; - for (const raw of patterns) { + const batches = await Promise.all(patterns.map(async (raw) => { const expanded = expandPath(raw, DEPS_DIR); const hasWildcard = expanded.includes("*"); + log.verbose(`glob: ${raw} → ${expanded}`); const files = await resolveGlob(expanded); if (!hasWildcard && files.length === 0) { log.warn(`Path not found: ${raw}`); } - for (const source of files) { - entries.push({ source, fileName: path.basename(source) }); - } - } - return entries; + log.verbose(` matched ${files.length} file(s)`); + return files.map((source) => ({ source, fileName: path.basename(source) })); + })); + return batches.flat(); } /** Deduplicates entries by fileName, keeping the last occurrence and warning on conflicts. */ @@ -46,6 +46,25 @@ export function getSections(config: { skills: { paths: string[] }; agents: { pat ] as const; } +/** Expands directory entries into individual .md file entries. */ +export async function expandEntries(entries: ResolvedEntry[]): Promise { + const result: ResolvedEntry[] = []; + for (const entry of entries) { + const stat = await fs.stat(entry.source).catch(() => null); + if (stat?.isDirectory()) { + const files = await listMdFiles(entry.source); + const dirName = path.basename(entry.source); + for (const file of files) { + const rel = path.relative(entry.source, file); + result.push({ source: file, fileName: path.join(dirName, rel) }); + } + } else { + result.push(entry); + } + } + return result; +} + /** Reads cortex.toml or exits with a user-friendly error if it doesn't exist. */ export async function readConfigOrExit() { try { diff --git a/src/index.ts b/src/index.ts index 0034e29..1f41671 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { update } from "./commands/update.ts"; import { sync } from "./commands/sync.ts"; import { list } from "./commands/list.ts"; import { clean } from "./commands/clean.ts"; +import { status } from "./commands/status.ts"; +import { setVerbose } from "./utils/log.ts"; const HELP = ` cortex — Knowledge distribution engine @@ -18,12 +20,14 @@ Commands: init Generate ~/.cortex/cortex.toml and ensure ~/.cortex/ai structure exists update Pull latest changes for ~/.cortex/ai and all deps sync Copy knowledge files and MCP configs into global platform directories + status Show sync state of each configured file (synced, modified, new) list Show the map of linked files clean Remove all cortex-managed files from platform directories Options: --dry-run, -d Preview sync without making changes --force, -f Overwrite locally modified files + --verbose, -V Show verbose diagnostic output --version, -v Show version --help, -h Show this help message `.trim(); @@ -36,9 +40,12 @@ async function main(): Promise { version: { type: "boolean", short: "v", default: false }, "dry-run": { type: "boolean", short: "d", default: false }, force: { type: "boolean", short: "f", default: false }, + verbose: { type: "boolean", short: "V", default: false }, }, }); + if (values.verbose) setVerbose(true); + if (values.version) { const require = createRequire(import.meta.url); const pkg = require("../package.json") as { version: string }; @@ -66,6 +73,9 @@ async function main(): Promise { force: values.force, }); break; + case "status": + await status(); + break; case "list": await list(); break; diff --git a/src/utils/log.ts b/src/utils/log.ts index ffc0a7c..73a140a 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -2,6 +2,12 @@ import { styleText } from "node:util"; const I = " "; // indent +let verboseEnabled = false; + +export function setVerbose(enabled: boolean): void { + verboseEnabled = enabled; +} + export const log = { success: (msg: string) => console.log(`${I}${styleText("green", "✓")} ${msg}`), skip: (msg: string) => console.log(`${I}${styleText("dim", "–")} ${styleText("dim", msg)}`), @@ -10,6 +16,7 @@ export const log = { info: (msg: string) => console.log(`${I}${styleText("cyan", "●")} ${msg}`), dim: (msg: string) => console.log(msg.split("\n").map((l) => `${I}${styleText("dim", l)}`).join("\n")), plain: (msg: string) => console.log(`${I}${msg}`), + verbose: (msg: string) => { if (verboseEnabled) console.log(`${I}${styleText("dim", `[verbose] ${msg}`)}`); }, separator: () => console.log(), header: (cmd: string) => { console.log(); diff --git a/test/commands/clean.test.ts b/test/commands/clean.test.ts index d965eb4..25e22e7 100644 --- a/test/commands/clean.test.ts +++ b/test/commands/clean.test.ts @@ -52,10 +52,16 @@ mock.module(srcUrl("core/mcp.ts"), { cleanMCP: cleanMCPMock, }, }); +const mockPlatformMap: Record = { + copilot: { name: "copilot", targetDir: "/mock/home/.github", mcpConfigPath: "/mock/home/.github/mcp.json", mcpKey: "mcpServers" }, + gemini: { name: "gemini", targetDir: "/mock/home/.gemini", mcpConfigPath: "/mock/home/.gemini/settings.json", mcpKey: "mcpServers" }, +}; + mock.module(srcUrl("core/constants.ts"), { namedExports: { - PLATFORMS: [{ name: "copilot", targetDir: "/mock/home/.github" }, { name: "gemini", targetDir: "/mock/home/.gemini" }], - getPlatform: (name: string) => ({ copilot: { name: "copilot", targetDir: "/mock/home/.github" }, gemini: { name: "gemini", targetDir: "/mock/home/.gemini" } })[name], + PLATFORMS: Object.values(mockPlatformMap), + getPlatform: (name: string) => mockPlatformMap[name], + resolvePlatforms: (names: string[]) => names.map((n) => mockPlatformMap[n]).filter(Boolean), }, }); diff --git a/test/commands/sync.test.ts b/test/commands/sync.test.ts index 1a195f0..f82a10a 100644 --- a/test/commands/sync.test.ts +++ b/test/commands/sync.test.ts @@ -27,6 +27,7 @@ const logMock = { error: mock.fn(), info: mock.fn(), dim: mock.fn(), + verbose: mock.fn(), header: mock.fn(), separator: mock.fn(), outro: mock.fn(), @@ -37,6 +38,7 @@ mock.module(srcUrl("core/resolver.ts"), { readConfigOrExit: readConfigOrExitMock, resolveEntries: resolveEntriesMock, deduplicateEntries: (entries: Entry[]) => entries, + expandEntries: (entries: Entry[]) => Promise.resolve(entries), getSections: (config: { skills: { paths: string[] }; agents: { paths: string[] } }) => [ { name: "skills", paths: config.skills.paths }, { name: "agents", paths: config.agents.paths }, @@ -66,13 +68,24 @@ mock.module(srcUrl("core/mcp.ts"), { displayPath: (p: string) => p, }, }); +const mockPlatformMap: Record = { + copilot: { name: "copilot", targetDir: "/mock/home/.github", mcpConfigPath: "/mock/home/.github/mcp.json", mcpKey: "mcpServers" }, + gemini: { name: "gemini", targetDir: "/mock/home/.gemini", mcpConfigPath: "/mock/home/.gemini/settings.json", mcpKey: "mcpServers" }, +}; + mock.module(srcUrl("core/constants.ts"), { namedExports: { CORTEX_DIR: "/mock/.cortex", AI_DIR: "/mock/.cortex/ai", DEPS_DIR: "/mock/.cortex/deps", - PLATFORMS: [{ name: "copilot", targetDir: "/mock/home/.github" }, { name: "gemini", targetDir: "/mock/home/.gemini" }], - getPlatform: (name: string) => ({ copilot: { name: "copilot", targetDir: "/mock/home/.github" }, gemini: { name: "gemini", targetDir: "/mock/home/.gemini" } })[name], + PLATFORMS: Object.values(mockPlatformMap), + getPlatform: (name: string) => mockPlatformMap[name], + resolvePlatforms: (names: string[], _custom?: unknown, warn?: (msg: string) => void) => + names.map((n) => { + const p = mockPlatformMap[n]; + if (!p) { warn?.(`Unknown platform "${n}" — skipping.`); return undefined; } + return p; + }).filter(Boolean), }, }); diff --git a/test/core/mcp.test.ts b/test/core/mcp.test.ts new file mode 100644 index 0000000..fe42ecc --- /dev/null +++ b/test/core/mcp.test.ts @@ -0,0 +1,212 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { syncMCP, cleanMCP } from "../../src/core/mcp.ts"; +import type { Platform } from "../../src/core/constants.ts"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cortex-mcp-test-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true }); +}); + +function makePlatform(name: string): Platform { + return { + name, + targetDir: path.join(tmpDir, name), + mcpConfigPath: path.join(tmpDir, name, "mcp.json"), + mcpKey: "mcpServers", + }; +} + +async function readConfig(platform: Platform): Promise> { + return JSON.parse(await fs.readFile(platform.mcpConfigPath, "utf-8")) as Record; +} + +describe("syncMCP", () => { + it("writes servers to platform config", async () => { + const platform = makePlatform("test"); + const servers = { playwright: { command: "npx", args: ["@playwright/mcp@latest"] } }; + + const results = await syncMCP(servers, [platform], false); + + assert.equal(results.length, 1); + assert.equal(results[0]!.ok, true); + const written = await readConfig(platform); + assert.deepEqual((written.mcpServers as Record).playwright, servers.playwright); + }); + + it("preserves existing non-cortex keys", async () => { + const platform = makePlatform("test"); + await fs.mkdir(path.dirname(platform.mcpConfigPath), { recursive: true }); + await fs.writeFile(platform.mcpConfigPath, JSON.stringify({ + someOtherKey: "value", + mcpServers: { existing: { command: "node" } }, + })); + + const results = await syncMCP({ newserver: { command: "npx" } }, [platform], false); + assert.equal(results[0]!.ok, true); + + const written = await readConfig(platform); + assert.equal(written.someOtherKey, "value"); + const servers = written.mcpServers as Record; + assert.ok(servers.existing, "pre-existing server should be preserved"); + assert.ok(servers.newserver, "new server should be added"); + }); + + it("creates config file and parent dirs if they do not exist", async () => { + const platform = makePlatform("nested/test"); + const servers = { s: { command: "node", args: ["server.js"] } }; + + await syncMCP(servers, [platform], false); + + const exists = await fs.access(platform.mcpConfigPath).then(() => true).catch(() => false); + assert.ok(exists); + }); + + it("dryRun returns ok without writing any file", async () => { + const platform = makePlatform("test"); + + const results = await syncMCP({ s: { command: "x" } }, [platform], true); + + assert.equal(results[0]!.ok, true); + const exists = await fs.access(platform.mcpConfigPath).then(() => true).catch(() => false); + assert.equal(exists, false); + }); + + it("handles invalid existing JSON by starting fresh", async () => { + const platform = makePlatform("test"); + await fs.mkdir(path.dirname(platform.mcpConfigPath), { recursive: true }); + await fs.writeFile(platform.mcpConfigPath, "not valid json {{"); + + const results = await syncMCP({ s: { command: "node" } }, [platform], false); + + assert.equal(results[0]!.ok, true); + const written = await readConfig(platform); + assert.ok((written.mcpServers as Record).s); + }); + + it("syncs to multiple platforms independently", async () => { + const p1 = makePlatform("p1"); + const p2 = makePlatform("p2"); + const servers = { s: { command: "node" } }; + + const results = await syncMCP(servers, [p1, p2], false); + + assert.equal(results.length, 2); + assert.ok(results.every((r) => r.ok)); + const w1 = await readConfig(p1); + const w2 = await readConfig(p2); + assert.ok((w1.mcpServers as Record).s); + assert.ok((w2.mcpServers as Record).s); + }); + + it("writes valid JSON with trailing newline", async () => { + const platform = makePlatform("test"); + await syncMCP({ s: { command: "node" } }, [platform], false); + const raw = await fs.readFile(platform.mcpConfigPath, "utf-8"); + assert.ok(raw.endsWith("\n"), "output should end with newline"); + assert.doesNotThrow(() => JSON.parse(raw)); + }); +}); + +describe("cleanMCP", () => { + it("removes specified server keys", async () => { + const platform = makePlatform("test"); + await fs.mkdir(path.dirname(platform.mcpConfigPath), { recursive: true }); + await fs.writeFile(platform.mcpConfigPath, JSON.stringify({ + mcpServers: { a: { command: "x" }, b: { command: "y" }, c: { command: "z" } }, + })); + + await cleanMCP(["a", "c"], [platform]); + + const written = await readConfig(platform); + const servers = written.mcpServers as Record; + assert.equal(servers.a, undefined); + assert.equal(servers.c, undefined); + assert.ok(servers.b, "unrelated server b should remain"); + }); + + it("returns ok when config file does not exist", async () => { + const platform = makePlatform("test"); + const results = await cleanMCP(["s"], [platform]); + assert.equal(results[0]!.ok, true); + }); + + it("preserves non-mcpServers keys when removing", async () => { + const platform = makePlatform("test"); + await fs.mkdir(path.dirname(platform.mcpConfigPath), { recursive: true }); + await fs.writeFile(platform.mcpConfigPath, JSON.stringify({ + otherKey: "preserved", + mcpServers: { s: { command: "node" } }, + })); + + await cleanMCP(["s"], [platform]); + + const written = await readConfig(platform); + assert.equal(written.otherKey, "preserved"); + }); + + it("handles empty serverNames list without modifying existing servers", async () => { + const platform = makePlatform("test"); + await fs.mkdir(path.dirname(platform.mcpConfigPath), { recursive: true }); + const initial = { mcpServers: { s: { command: "node" } } }; + await fs.writeFile(platform.mcpConfigPath, JSON.stringify(initial)); + + const results = await cleanMCP([], [platform]); + + assert.equal(results[0]!.ok, true); + const written = await readConfig(platform); + assert.ok((written.mcpServers as Record).s, "server should remain unchanged"); + }); + + it("cleans multiple platforms independently", async () => { + const p1 = makePlatform("p1"); + const p2 = makePlatform("p2"); + for (const p of [p1, p2]) { + await fs.mkdir(path.dirname(p.mcpConfigPath), { recursive: true }); + await fs.writeFile(p.mcpConfigPath, JSON.stringify({ mcpServers: { s: { command: "node" } } })); + } + + const results = await cleanMCP(["s"], [p1, p2]); + + assert.ok(results.every((r) => r.ok)); + for (const p of [p1, p2]) { + const written = await readConfig(p); + assert.equal((written.mcpServers as Record).s, undefined); + } + }); +}); + +describe("resolveServer (via syncMCP)", () => { + it("expands ~ in cwd field", async () => { + const platform = makePlatform("test"); + await syncMCP({ s: { command: "node", cwd: "~/myproject" } }, [platform], false); + const written = await readConfig(platform); + const server = (written.mcpServers as Record).s as Record; + assert.equal(server.cwd, path.join(os.homedir(), "myproject")); + }); + + it("leaves args unchanged", async () => { + const platform = makePlatform("test"); + const args = ["@playwright/mcp@latest", "--flag"]; + await syncMCP({ s: { command: "npx", args } }, [platform], false); + const written = await readConfig(platform); + const server = (written.mcpServers as Record).s as Record; + assert.deepEqual(server.args, args); + }); + + it("leaves server without cwd field unchanged", async () => { + const platform = makePlatform("test"); + await syncMCP({ s: { command: "node" } }, [platform], false); + const written = await readConfig(platform); + const server = (written.mcpServers as Record).s as Record; + assert.equal(server.cwd, undefined); + }); +});