diff --git a/package.json b/package.json index 286fd07..bc3c948 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devvami", "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal", - "version": "1.5.2", + "version": "1.6.0", "author": "", "type": "module", "bin": { diff --git a/src/commands/sync-config-ai/index.js b/src/commands/sync-config-ai/index.js index ea9e100..513895f 100644 --- a/src/commands/sync-config-ai/index.js +++ b/src/commands/sync-config-ai/index.js @@ -9,7 +9,6 @@ import { deactivateEntry, activateEntry, deleteEntry, - syncAIConfigToChezmoi, } from '../../services/ai-config-store.js' import {deployEntry, undeployEntry, reconcileOnScan} from '../../services/ai-env-deployer.js' import {loadConfig} from '../../services/config.js' @@ -58,9 +57,38 @@ function buildMCPParams(values) { params.env = /** @type {Record} */ (values.env) } + // Persist description in the store (not deployed to env files) + if (values.description && typeof values.description === 'string' && values.description.trim()) { + params.description = values.description.trim() + } + return params } +/** + * Build clean params for any entry type from raw form values. + * Strips form-only fields (name, environments, scope) to avoid polluting stored params. + * @param {string} type - Category type + * @param {Record} values - Raw form output from extractValues + * @returns {object} + */ +function buildEntryParams(type, values) { + if (type === 'mcp') return buildMCPParams(values) + if (type === 'command') { + return {content: values.content, ...(values.description ? {description: values.description} : {})} + } + if (type === 'rule') { + return {content: values.content, ...(values.description ? {description: values.description} : {})} + } + if (type === 'skill') { + return {content: values.content, ...(values.description ? {description: values.description} : {})} + } + if (type === 'agent') { + return {instructions: values.instructions, ...(values.description ? {description: values.description} : {})} + } + return values +} + export default class SyncConfigAi extends Command { static description = 'Manage AI coding tool configurations across environments via TUI' @@ -197,21 +225,24 @@ export default class SyncConfigAi extends Command { const currentStore = await loadAIConfig() if (action.type === 'create') { - const isMCP = action.tabKey === 'mcp' const created = await addEntry({ name: action.values.name, type: action.tabKey || 'mcp', + scope: action.values.scope || 'project', environments: action.values.environments || [], - params: isMCP ? buildMCPParams(action.values) : action.values, + params: buildEntryParams(action.tabKey, action.values), }) await deployEntry(created, detectedEnvs, process.cwd()) - await syncAIConfigToChezmoi() } else if (action.type === 'edit') { const entry = currentStore.entries.find((e) => e.id === action.id) - const isMCP = entry?.type === 'mcp' - const updated = await updateEntry(action.id, {params: isMCP ? buildMCPParams(action.values) : action.values}) + if (!entry) return + const updated = await updateEntry(action.id, { + name: action.values.name, + environments: action.values.environments || [], + scope: action.values.scope || entry.scope || 'project', + params: buildEntryParams(entry.type, action.values), + }) await deployEntry(updated, detectedEnvs, process.cwd()) - await syncAIConfigToChezmoi() } else if (action.type === 'delete') { await deleteEntry(action.id) await undeployEntry( @@ -219,26 +250,23 @@ export default class SyncConfigAi extends Command { detectedEnvs, process.cwd(), ) - await syncAIConfigToChezmoi() } else if (action.type === 'deactivate') { const entry = await deactivateEntry(action.id) await undeployEntry(entry, detectedEnvs, process.cwd()) - await syncAIConfigToChezmoi() } else if (action.type === 'activate') { const entry = await activateEntry(action.id) await deployEntry(entry, detectedEnvs, process.cwd()) - await syncAIConfigToChezmoi() } else if (action.type === 'import-native') { // T017: Import native entry into dvmi-managed sync const ne = action.nativeEntry const created = await addEntry({ name: ne.name, type: ne.type, + scope: ne.level, environments: [ne.environmentId], params: ne.params, }) await deployEntry(created, detectedEnvs, process.cwd()) - await syncAIConfigToChezmoi() } else if (action.type === 'redeploy') { // T018: Re-deploy managed entry to overwrite drifted file const entry = currentStore.entries.find((e) => e.id === action.id) @@ -248,7 +276,6 @@ export default class SyncConfigAi extends Command { const drift = driftInfos.find((d) => d.entryId === action.id) if (drift) { await updateEntry(action.id, {params: drift.actual}) - await syncAIConfigToChezmoi() } } }, diff --git a/src/services/ai-config-store.js b/src/services/ai-config-store.js index c8a5204..3408332 100644 --- a/src/services/ai-config-store.js +++ b/src/services/ai-config-store.js @@ -51,20 +51,26 @@ const UNSAFE_CHARS = /[/\\:*?"<>|]/ /** @returns {AIConfigStore} */ function defaultStore() { - return {version: 2, entries: []} + return {version: 3, entries: []} } /** * Migrate an AI config store to the current schema version. * v1 → v2 is a no-op data migration; it only bumps the version field. + * v2 → v3 adds `scope: 'project'` to every entry. * @param {AIConfigStore} store * @returns {AIConfigStore} */ function migrateStore(store) { - if (store.version === 1) { - return {...store, version: 2} + let migrated = store + if (migrated.version <= 2) { + migrated = { + ...migrated, + version: 3, + entries: migrated.entries.map((e) => ({...e, scope: e.scope || 'project'})), + } } - return store + return migrated } // ────────────────────────────────────────────────────────────────────────────── @@ -174,7 +180,7 @@ export async function saveAIConfig(store, configPath = process.env.DVMI_AI_CONFI * @returns {Promise} */ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFIG_PATH ?? AI_CONFIG_PATH) { - const {name, type, environments, params} = entryData + const {name, type, scope = 'project', environments, params} = entryData validateName(name) validateEnvironments(environments, type) @@ -197,6 +203,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI name, type, active: true, + scope, environments, params, createdAt: now, @@ -212,7 +219,7 @@ export async function addEntry(entryData, configPath = process.env.DVMI_AI_CONFI /** * Update an existing entry by id. * @param {string} id - UUID of the entry to update - * @param {{ name?: string, environments?: EnvironmentId[], params?: MCPParams|CommandParams|SkillParams|AgentParams, active?: boolean }} changes + * @param {{ name?: string, scope?: 'project'|'global', environments?: EnvironmentId[], params?: MCPParams|CommandParams|SkillParams|AgentParams, active?: boolean }} changes * @param {string} [configPath] * @returns {Promise} */ @@ -252,6 +259,7 @@ export async function updateEntry(id, changes, configPath = process.env.DVMI_AI_ const updated = { ...existing, ...(changes.name !== undefined ? {name: changes.name} : {}), + ...(changes.scope !== undefined ? {scope: changes.scope} : {}), ...(changes.environments !== undefined ? {environments: changes.environments} : {}), ...(changes.params !== undefined ? {params: changes.params} : {}), ...(changes.active !== undefined ? {active: changes.active} : {}), diff --git a/src/services/ai-env-deployer.js b/src/services/ai-env-deployer.js index 9600b9f..1db0f7d 100644 --- a/src/services/ai-env-deployer.js +++ b/src/services/ai-env-deployer.js @@ -18,59 +18,63 @@ import yaml from 'js-yaml' // ────────────────────────────────────────────────────────────────────────────── /** - * For each environment, the target JSON file path (relative to cwd or absolute) - * and the root key that holds the MCP server map. - * - * @type {Record string, mcpKey: string, isYaml?: boolean }>} + * @typedef {{ resolvePath: (cwd: string) => string, mcpKey: string, isYaml?: boolean }} MCPTargetConfig + * @type {Record} */ const MCP_TARGETS = { 'vscode-copilot': { - resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'), - mcpKey: 'servers', + project: {resolvePath: (cwd) => join(cwd, '.vscode', 'mcp.json'), mcpKey: 'servers'}, }, 'claude-code': { - resolvePath: (cwd) => join(cwd, '.mcp.json'), - mcpKey: 'mcpServers', + project: {resolvePath: (cwd) => join(cwd, '.mcp.json'), mcpKey: 'mcpServers'}, + global: {resolvePath: (_cwd) => join(homedir(), '.claude.json'), mcpKey: 'mcpServers'}, }, 'claude-desktop': { - resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), - mcpKey: 'mcpServers', + global: {resolvePath: (_cwd) => join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), mcpKey: 'mcpServers'}, }, opencode: { - resolvePath: (cwd) => join(cwd, 'opencode.json'), - mcpKey: 'mcp', + project: {resolvePath: (cwd) => join(cwd, 'opencode.json'), mcpKey: 'mcp'}, + global: {resolvePath: (_cwd) => join(homedir(), '.config', 'opencode', 'opencode.json'), mcpKey: 'mcp'}, }, 'gemini-cli': { - resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'), - mcpKey: 'mcpServers', + global: {resolvePath: (_cwd) => join(homedir(), '.gemini', 'settings.json'), mcpKey: 'mcpServers'}, }, 'copilot-cli': { - resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'), - mcpKey: 'mcpServers', + global: {resolvePath: (_cwd) => join(homedir(), '.copilot', 'mcp-config.json'), mcpKey: 'mcpServers'}, }, cursor: { - resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'), - mcpKey: 'mcpServers', + project: {resolvePath: (cwd) => join(cwd, '.cursor', 'mcp.json'), mcpKey: 'mcpServers'}, + global: {resolvePath: (_cwd) => join(homedir(), '.cursor', 'mcp.json'), mcpKey: 'mcpServers'}, }, windsurf: { - resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), - mcpKey: 'mcpServers', + global: {resolvePath: (_cwd) => join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), mcpKey: 'mcpServers'}, }, 'continue-dev': { - resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'), - mcpKey: 'mcpServers', - isYaml: true, + project: {resolvePath: (cwd) => join(cwd, '.continue', 'config.yaml'), mcpKey: 'mcpServers', isYaml: true}, + global: {resolvePath: (_cwd) => join(homedir(), '.continue', 'config.yaml'), mcpKey: 'mcpServers', isYaml: true}, }, zed: { - resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'), - mcpKey: 'context_servers', + global: {resolvePath: (_cwd) => join(homedir(), '.config', 'zed', 'settings.json'), mcpKey: 'context_servers'}, }, 'amazon-q': { - resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'), - mcpKey: 'mcpServers', + project: {resolvePath: (cwd) => join(cwd, '.amazonq', 'mcp.json'), mcpKey: 'mcpServers'}, + global: {resolvePath: (_cwd) => join(homedir(), '.aws', 'amazonq', 'mcp.json'), mcpKey: 'mcpServers'}, }, } +/** + * Resolve the target config for an MCP entry based on scope. + * Falls back to the available scope if the chosen one is not available. + * @param {EnvironmentId} envId + * @param {'project'|'global'} scope + * @returns {MCPTargetConfig|null} + */ +function resolveTarget(envId, scope) { + const target = MCP_TARGETS[envId] + if (!target) return null + return target[scope] || target.project || target.global || null +} + /** * Resolve the target file path for a file-based entry (command, skill, agent). * @@ -426,7 +430,8 @@ function buildOpenCodeMCPObject(params) { export async function deployMCPEntry(entry, envId, cwd) { if (!entry || entry.type !== 'mcp' || !entry.params) return - const target = MCP_TARGETS[envId] + const scope = entry.scope || 'project' + const target = resolveTarget(envId, scope) if (!target) return const filePath = target.resolvePath(cwd) @@ -457,10 +462,11 @@ export async function deployMCPEntry(entry, envId, cwd) { * @param {string} entryName - Name of the MCP server to remove * @param {EnvironmentId} envId - Target environment identifier * @param {string} cwd - Project working directory + * @param {'project'|'global'} [scope='project'] - Scope to resolve the target config file * @returns {Promise} */ -export async function undeployMCPEntry(entryName, envId, cwd) { - const target = MCP_TARGETS[envId] +export async function undeployMCPEntry(entryName, envId, cwd, scope = 'project') { + const target = resolveTarget(envId, scope) if (!target) return const filePath = target.resolvePath(cwd) @@ -585,7 +591,8 @@ export async function deployEntry(entry, detectedEnvs, cwd) { // Skip if the environment has unreadable JSON config files that correspond // to the MCP target path (we don't want to overwrite corrupt files) if (detectedEnv && entry.type === 'mcp') { - const target = MCP_TARGETS[envId] + const scope = entry.scope || 'project' + const target = resolveTarget(envId, scope) if (target) { const targetPath = target.resolvePath(cwd) if (detectedEnv.unreadable.includes(targetPath)) continue @@ -619,7 +626,7 @@ export async function undeployEntry(entry, detectedEnvs, cwd) { if (!detectedIds.has(envId)) continue if (entry.type === 'mcp') { - await undeployMCPEntry(entry.name, envId, cwd) + await undeployMCPEntry(entry.name, envId, cwd, entry.scope || 'project') } else { await undeployFileEntry(entry.name, entry.type, envId, cwd) } diff --git a/src/services/ai-env-scanner.js b/src/services/ai-env-scanner.js index c174669..e331563 100644 --- a/src/services/ai-env-scanner.js +++ b/src/services/ai-env-scanner.js @@ -776,9 +776,10 @@ export function parseNativeEntries(envDef, cwd, managedEntries) { * @param {string} entryName * @param {EnvironmentId} envId * @param {string} cwd + * @param {'project'|'global'} [scope='project'] * @returns {object|null} */ -function readDeployedMCPEntry(entryName, envId, cwd) { +function readDeployedMCPEntry(entryName, envId, cwd, scope = 'project') { const home = homedir() /** @type {string|null} */ @@ -789,15 +790,15 @@ function readDeployedMCPEntry(entryName, envId, cwd) { switch (envId) { case 'vscode-copilot': filePath = join(cwd, '.vscode', 'mcp.json'); mcpKey = 'servers'; break - case 'claude-code': filePath = join(cwd, '.mcp.json'); break - case 'opencode': filePath = join(cwd, 'opencode.json'); break + case 'claude-code': filePath = scope === 'global' ? join(home, '.claude.json') : join(cwd, '.mcp.json'); break + case 'opencode': filePath = scope === 'global' ? join(home, '.config', 'opencode', 'opencode.json') : join(cwd, 'opencode.json'); break case 'gemini-cli': filePath = join(home, '.gemini', 'settings.json'); break case 'copilot-cli': filePath = join(home, '.copilot', 'mcp-config.json'); break - case 'cursor': filePath = join(cwd, '.cursor', 'mcp.json'); break + case 'cursor': filePath = scope === 'global' ? join(home, '.cursor', 'mcp.json') : join(cwd, '.cursor', 'mcp.json'); break case 'windsurf': filePath = join(home, '.codeium', 'windsurf', 'mcp_config.json'); break - case 'continue-dev': filePath = join(cwd, '.continue', 'config.yaml'); isYaml = true; break + case 'continue-dev': filePath = scope === 'global' ? join(home, '.continue', 'config.yaml') : join(cwd, '.continue', 'config.yaml'); isYaml = true; break case 'zed': filePath = join(home, '.config', 'zed', 'settings.json'); mcpKey = 'context_servers'; break - case 'amazon-q': filePath = join(cwd, '.amazonq', 'mcp.json'); break + case 'amazon-q': filePath = scope === 'global' ? join(home, '.aws', 'amazonq', 'mcp.json') : join(cwd, '.amazonq', 'mcp.json'); break default: return null } @@ -912,7 +913,7 @@ export function detectDrift(detectedEnvs, managedEntries, cwd = process.cwd()) { const params = /** @type {any} */ (entry.params) if (entry.type === 'mcp') { - const actual = readDeployedMCPEntry(entry.name, envId, cwd) + const actual = readDeployedMCPEntry(entry.name, envId, cwd, entry.scope || 'project') if (actual === null) continue // not deployed yet — not drift // Build expected server object — must match what buildMCPServerObject produces. diff --git a/src/services/nvd.js b/src/services/nvd.js index ee69d38..9c93ebc 100644 --- a/src/services/nvd.js +++ b/src/services/nvd.js @@ -3,7 +3,7 @@ import {DvmiError} from '../utils/errors.js' /** @import { CveSearchResult, CveDetail } from '../types.js' */ -const NVD_BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0' +const NVD_BASE_URL = process.env.NVD_BASE_URL || 'https://services.nvd.nist.gov/rest/json/cves/2.0' /** NVD attribution required in all interactive output. */ export const NVD_ATTRIBUTION = 'This product uses data from the NVD API but is not endorsed or certified by the NVD.' diff --git a/src/types.js b/src/types.js index 7605ef4..19eb10d 100644 --- a/src/types.js +++ b/src/types.js @@ -351,6 +351,7 @@ * @property {string[]} [args] - Command arguments * @property {Record} [env] - Environment variables * @property {string} [url] - Server URL (required for sse/streamable-http transport) + * @property {string} [description] - Human-readable description (stored in config, not deployed to env files) */ /** @@ -383,6 +384,7 @@ * @property {string} name - Unique within its type; used as filename/key when deploying * @property {CategoryType} type - Category type * @property {boolean} active - true = deployed to environments, false = removed but kept in store + * @property {'project'|'global'} scope - Where to deploy: project-level or global-level config files * @property {EnvironmentId[]} environments - Target environments for deployment * @property {MCPParams|CommandParams|RuleParams|SkillParams|AgentParams} params - Type-specific parameters * @property {string} createdAt - ISO 8601 timestamp diff --git a/src/utils/tui/form.js b/src/utils/tui/form.js index df8c961..41b6050 100644 --- a/src/utils/tui/form.js +++ b/src/utils/tui/form.js @@ -837,6 +837,14 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) { focusedOptionIndex: 0, required: true, }), + /** @type {SelectorField} */ ({ + type: 'selector', + label: 'Scope', + key: 'scope', + options: ['project', 'global'], + selectedIndex: entry?.scope === 'global' ? 1 : 0, + required: true, + }), /** @type {SelectorField} */ ({ type: 'selector', label: 'Transport', @@ -988,6 +996,14 @@ export function getCommandFormFields(entry = null, compatibleEnvs = []) { focusedOptionIndex: 0, required: true, }), + /** @type {SelectorField} */ ({ + type: 'selector', + label: 'Scope', + key: 'scope', + options: ['project', 'global'], + selectedIndex: entry?.scope === 'global' ? 1 : 0, + required: true, + }), /** @type {TextField} */ ({ type: 'text', label: 'Description', @@ -1043,6 +1059,14 @@ export function getSkillFormFields(entry = null, compatibleEnvs = []) { focusedOptionIndex: 0, required: true, }), + /** @type {SelectorField} */ ({ + type: 'selector', + label: 'Scope', + key: 'scope', + options: ['project', 'global'], + selectedIndex: entry?.scope === 'global' ? 1 : 0, + required: true, + }), /** @type {TextField} */ ({ type: 'text', label: 'Description', @@ -1107,6 +1131,14 @@ export function getRuleFormFields(entry = null, compatibleEnvs = []) { focusedOptionIndex: 0, required: true, }), + /** @type {SelectorField} */ ({ + type: 'selector', + label: 'Scope', + key: 'scope', + options: ['project', 'global'], + selectedIndex: entry?.scope === 'global' ? 1 : 0, + required: true, + }), /** @type {TextField} */ ({ type: 'text', label: 'Description', @@ -1162,6 +1194,14 @@ export function getAgentFormFields(entry = null, compatibleEnvs = []) { focusedOptionIndex: 0, required: true, }), + /** @type {SelectorField} */ ({ + type: 'selector', + label: 'Scope', + key: 'scope', + options: ['project', 'global'], + selectedIndex: entry?.scope === 'global' ? 1 : 0, + required: true, + }), /** @type {TextField} */ ({ type: 'text', label: 'Description', diff --git a/src/utils/tui/tab-tui.js b/src/utils/tui/tab-tui.js index 21d7351..2a3b5b2 100644 --- a/src/utils/tui/tab-tui.js +++ b/src/utils/tui/tab-tui.js @@ -260,7 +260,7 @@ export function buildCategoriesTab(tabState, viewportHeight, formatManaged, form // ── Native section ── if (nativeEntries.length > 0) { - lines.push(chalk.bold.cyan(' ── Native (read-only) ──────────────────────────────')) + lines.push(chalk.bold.cyan(` ── Native (read-only) [${nativeEntries.length} unmanaged] ──────────────`)) const nativeLines = formatNative(nativeEntries, termCols) const HEADER = 2 @@ -458,6 +458,11 @@ export function handleCategoriesKeypress(state, key) { // Native section actions if (section === 'native') { + // I (shift+i / uppercase I): batch import all native entries + if (key.sequence === 'I' && nativeEntries.length > 0) { + return {...state, _importAllNative: [...nativeEntries]} + } + // i: import single native entry if (key.name === 'i' && nativeEntries.length > 0) { const nativeEntry = nativeEntries[selectedIndex] if (nativeEntry) return {...state, _importNative: nativeEntry} @@ -713,7 +718,7 @@ export async function startTabTUI(opts) { const nativeFmt = formatNative ?? formatNativeEntriesTableFallback contentLines = buildCategoriesTab(tabState, contentViewportHeight, formatCats, nativeFmt, termCols) const sectionHint = tabState.nativeEntries.length > 0 ? ' Tab section' : '' - const nativeHint = tabState.section === 'native' ? ' i import' : ' n new Enter edit d toggle Del delete r reveal' + const nativeHint = tabState.section === 'native' ? ' i import I import all' : ' n new Enter edit d toggle Del delete r reveal' hintStr = chalk.dim(` ↑↓ navigate ←→ tabs${nativeHint}${sectionHint} q exit`) } } @@ -961,6 +966,35 @@ export async function startTabTUI(opts) { return } + // Batch import all native entries + if (result._importAllNative) { + const nativeToImport = result._importAllNative + catTabStates = {...catTabStates, [tabKey]: {...result, _importAllNative: null}} + render() + try { + for (const ne of nativeToImport) { + await onAction({type: 'import-native', nativeEntry: ne}) + } + if (opts.refreshEntries) { + allEntries = await opts.refreshEntries() + syncTabEntries() + catTabStates = { + ...catTabStates, + [tabKey]: { + ...catTabStates[tabKey], + nativeEntries: [], + section: 'managed', + selectedIndex: 0, + }, + } + render() + } + } catch { + render() + } + return + } + // T018: Re-deploy after drift resolution if (result._redeploy) { const idToRedeploy = result._redeploy diff --git a/tests/integration/json-output.test.js b/tests/integration/json-output.test.js index 3648dd3..43a30b0 100644 --- a/tests/integration/json-output.test.js +++ b/tests/integration/json-output.test.js @@ -1,8 +1,16 @@ import {describe, it, expect} from 'vitest' -import {runCli, runCliJson} from './helpers.js' +import {runCli, runCliJson, createMockServer, jsonResponse} from './helpers.js' import {mkdtemp, rm} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {readFileSync} from 'node:fs' +import {resolve, dirname} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const searchFixture = JSON.parse( + readFileSync(resolve(__dirname, '../fixtures/nvd-responses/search-results.json'), 'utf8'), +) // Tests that call the real ClickUp API require a token in the keychain. // In CI there are no real credentials, so we skip those tests. @@ -58,26 +66,45 @@ describe('--json flag', () => { }) it('vuln search --json returns valid JSON shape or non-zero exit in offline env', async () => { - const {stdout, stderr, exitCode} = await runCli(['vuln', 'search', 'openssl', '--json']) - if (exitCode === 0) { - const data = JSON.parse(stdout) - expect(data).toHaveProperty('keyword', 'openssl') - expect(data).toHaveProperty('results') - expect(Array.isArray(data.results)).toBe(true) - } else { - expect(stderr).toBeTruthy() + const server = await createMockServer((req, res) => { + jsonResponse(res, searchFixture) + }) + + try { + const {stdout, stderr, exitCode} = await runCli(['vuln', 'search', 'openssl', '--json'], {NVD_BASE_URL: server.url}) + if (exitCode === 0) { + const data = JSON.parse(stdout) + expect(data).toHaveProperty('keyword', 'openssl') + expect(data).toHaveProperty('results') + expect(Array.isArray(data.results)).toBe(true) + } else { + expect(stderr).toBeTruthy() + } + } finally { + await server.stop() } }) it('vuln detail --json returns valid JSON shape or non-zero exit in offline env', async () => { - const {stdout, stderr, exitCode} = await runCli(['vuln', 'detail', 'CVE-2021-44228', '--json']) - if (exitCode === 0) { - const data = JSON.parse(stdout) - expect(data).toHaveProperty('id', 'CVE-2021-44228') - expect(data).toHaveProperty('severity') - expect(data).toHaveProperty('references') - } else { - expect(stderr).toBeTruthy() + const detailFixture = JSON.parse( + readFileSync(resolve(__dirname, '../fixtures/nvd-responses/cve-detail.json'), 'utf8'), + ) + const server = await createMockServer((req, res) => { + jsonResponse(res, detailFixture) + }) + + try { + const {stdout, stderr, exitCode} = await runCli(['vuln', 'detail', 'CVE-2021-44228', '--json'], {NVD_BASE_URL: server.url}) + if (exitCode === 0) { + const data = JSON.parse(stdout) + expect(data).toHaveProperty('id', 'CVE-2021-44228') + expect(data).toHaveProperty('severity') + expect(data).toHaveProperty('references') + } else { + expect(stderr).toBeTruthy() + } + } finally { + await server.stop() } }) diff --git a/tests/integration/vuln-detail.test.js b/tests/integration/vuln-detail.test.js index 4e9348c..1a24aae 100644 --- a/tests/integration/vuln-detail.test.js +++ b/tests/integration/vuln-detail.test.js @@ -42,14 +42,22 @@ describe('dvmi vuln detail', () => { }) it('outputs valid JSON structure with --json flag', async () => { - const {stdout, stderr, exitCode} = await runCli(['vuln', 'detail', 'CVE-2021-44228', '--json'], {}) - if (exitCode === 0) { - const data = JSON.parse(stdout) - expect(data).toHaveProperty('id', 'CVE-2021-44228') - expect(data).toHaveProperty('severity') - expect(data).toHaveProperty('references') - } else { - expect(stderr).toBeTruthy() + const server = await createMockServer((req, res) => { + jsonResponse(res, detailFixture) + }) + + try { + const {stdout, stderr, exitCode} = await runCli(['vuln', 'detail', 'CVE-2021-44228', '--json'], {NVD_BASE_URL: server.url}) + if (exitCode === 0) { + const data = JSON.parse(stdout) + expect(data).toHaveProperty('id', 'CVE-2021-44228') + expect(data).toHaveProperty('severity') + expect(data).toHaveProperty('references') + } else { + expect(stderr).toBeTruthy() + } + } finally { + await server.stop() } }) }) diff --git a/tests/integration/vuln-search.test.js b/tests/integration/vuln-search.test.js index 1f1dd47..08567f5 100644 --- a/tests/integration/vuln-search.test.js +++ b/tests/integration/vuln-search.test.js @@ -81,19 +81,23 @@ describe('dvmi vuln search', () => { } }) - it('outputs valid JSON with --json flag via MSW mock', async () => { - // The vitest MSW intercepts fetch — but this is an integration test (runs via execaNode) - // so we cannot rely on MSW. Instead just check the flag is accepted and JSON is returned - // even if the NVD call fails (no network in CI). We use DVMI_NO_NVD env to short-circuit. - const {stdout, stderr, exitCode} = await runCli(['vuln', 'search', 'openssl', '--json'], {}) - // May fail with network error in offline env; just check the flag is parsed - if (exitCode === 0) { - const data = JSON.parse(stdout) - expect(data).toHaveProperty('keyword', 'openssl') - expect(data).toHaveProperty('results') - expect(Array.isArray(data.results)).toBe(true) - } else { - expect(stderr).toBeTruthy() + it('outputs valid JSON with --json flag via mock server', async () => { + const server = await createMockServer((req, res) => { + jsonResponse(res, searchFixture) + }) + + try { + const {stdout, stderr, exitCode} = await runCli(['vuln', 'search', 'openssl', '--json'], {NVD_BASE_URL: server.url}) + if (exitCode === 0) { + const data = JSON.parse(stdout) + expect(data).toHaveProperty('keyword', 'openssl') + expect(data).toHaveProperty('results') + expect(Array.isArray(data.results)).toBe(true) + } else { + expect(stderr).toBeTruthy() + } + } finally { + await server.stop() } }) }) diff --git a/tests/unit/services/ai-config-store.test.js b/tests/unit/services/ai-config-store.test.js index 7e0bcd4..e50c81e 100644 --- a/tests/unit/services/ai-config-store.test.js +++ b/tests/unit/services/ai-config-store.test.js @@ -63,7 +63,7 @@ afterEach(async () => { describe('loadAIConfig', () => { it('returns defaults when file does not exist', async () => { const store = await loadAIConfig(tmpPath) - expect(store).toEqual({version: 2, entries: []}) + expect(store).toEqual({version: 3, entries: []}) }) it('returns parsed content from an existing valid file', async () => { @@ -87,12 +87,43 @@ describe('loadAIConfig', () => { await writeFile(tmpPath, JSON.stringify(data), 'utf8') const store = await loadAIConfig(tmpPath) - expect(store.version).toBe(2) + expect(store.version).toBe(3) expect(store.entries).toHaveLength(1) expect(store.entries[0].name).toBe('existing-mcp') }) }) +// ────────────────────────────────────────────────────────────────────────────── +// v2 to v3 migration +// ────────────────────────────────────────────────────────────────────────────── + +describe('v2 to v3 migration', () => { + it('adds scope: project to all entries and bumps version to 3', async () => { + const dir = join(tmpPath, '..') + await mkdir(dir, {recursive: true}) + const data = { + version: 2, + entries: [ + { + id: '00000000-0000-0000-0000-000000000001', + name: 'legacy-mcp', + type: 'mcp', + active: true, + environments: ['claude-code'], + params: {transport: 'stdio', command: 'npx'}, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + } + await writeFile(tmpPath, JSON.stringify(data), 'utf8') + + const store = await loadAIConfig(tmpPath) + expect(store.version).toBe(3) + expect(store.entries[0].scope).toBe('project') + }) +}) + // ────────────────────────────────────────────────────────────────────────────── // addEntry // ────────────────────────────────────────────────────────────────────────────── @@ -150,6 +181,16 @@ describe('addEntry', () => { expect(entry.id).toBeTruthy() expect(entry.environments).toContain('gemini-cli') }) + + it('creates an entry with the specified scope', async () => { + const entry = await addEntry({...MCP_ENTRY, scope: 'global'}, tmpPath) + expect(entry.scope).toBe('global') + }) + + it('defaults scope to project when not specified', async () => { + const entry = await addEntry(MCP_ENTRY, tmpPath) + expect(entry.scope).toBe('project') + }) }) // ────────────────────────────────────────────────────────────────────────────── @@ -233,6 +274,14 @@ describe('updateEntry', () => { await expect(updateEntry('non-existent-id', {name: 'x'}, tmpPath)).rejects.toThrow(DvmiError) await expect(updateEntry('non-existent-id', {name: 'x'}, tmpPath)).rejects.toThrow(/not found/) }) + + it('updates scope when provided', async () => { + const original = await addEntry(MCP_ENTRY, tmpPath) + expect(original.scope).toBe('project') + + const updated = await updateEntry(original.id, {scope: 'global'}, tmpPath) + expect(updated.scope).toBe('global') + }) }) // ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/services/ai-env-deployer.test.js b/tests/unit/services/ai-env-deployer.test.js index 458de64..ab7f737 100644 --- a/tests/unit/services/ai-env-deployer.test.js +++ b/tests/unit/services/ai-env-deployer.test.js @@ -49,6 +49,7 @@ function makeMCPEntry(overrides = {}) { name: 'test-server', type: 'mcp', active: true, + scope: 'project', environments: ['claude-code'], params: {transport: 'stdio', command: 'npx', args: ['-y', 'test-pkg'], env: {}}, createdAt: '2026-01-01T00:00:00Z', @@ -68,6 +69,7 @@ function makeCommandEntry(overrides = {}) { name: 'my-command', type: 'command', active: true, + scope: 'project', environments: ['claude-code'], params: {content: '# My Command\nDo something useful.', description: 'A test command'}, createdAt: '2026-01-01T00:00:00Z', @@ -87,6 +89,7 @@ function makeSkillEntry(overrides = {}) { name: 'my-skill', type: /** @type {import('../../../src/types.js').CategoryType} */ ('skill'), active: true, + scope: 'project', environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), params: {content: '# My Skill\nSkill content here.', description: 'A test skill'}, createdAt: '2026-01-01T00:00:00Z', @@ -106,6 +109,7 @@ function makeAgentEntry(overrides = {}) { name: 'my-agent', type: /** @type {import('../../../src/types.js').CategoryType} */ ('agent'), active: true, + scope: 'project', environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), params: {instructions: 'Agent instructions here.', description: 'A test agent'}, createdAt: '2026-01-01T00:00:00Z', @@ -125,6 +129,7 @@ function makeRuleEntry(overrides = {}) { name: 'my-rule', type: /** @type {import('../../../src/types.js').CategoryType} */ ('rule'), active: true, + scope: 'project', environments: /** @type {import('../../../src/types.js').EnvironmentId[]} */ (['claude-code']), params: {content: 'Rule content here.', description: 'A test rule'}, createdAt: '2026-01-01T00:00:00Z', @@ -326,41 +331,37 @@ describe('deployMCPEntry', () => { }) }) - it('handles continue-dev: writes YAML to ~/.continue/config.yaml with "mcpServers" key', async () => { - const continuePath = join(homedir(), '.continue', 'config.yaml') + it('handles continue-dev: writes YAML to .continue/config.yaml with "mcpServers" key (project scope)', async () => { + const continuePath = join(cwd, '.continue', 'config.yaml') - await withRestoredFile(continuePath, async () => { - const entry = makeMCPEntry({name: 'continue-mcp', environments: ['continue-dev']}) - await deployMCPEntry(entry, 'continue-dev', cwd) + const entry = makeMCPEntry({name: 'continue-mcp', environments: ['continue-dev']}) + await deployMCPEntry(entry, 'continue-dev', cwd) - expect(existsSync(continuePath)).toBe(true) - const raw = await readFile(continuePath, 'utf8') - // Must be YAML, not JSON - expect(raw).not.toMatch(/^\s*\{/) - const parsed = /** @type {any} */ (yaml.load(raw)) - expect(parsed).toHaveProperty('mcpServers') - expect(parsed.mcpServers).toHaveProperty('continue-mcp') - expect(parsed.mcpServers['continue-mcp']).toMatchObject({command: 'npx'}) - }) + expect(existsSync(continuePath)).toBe(true) + const raw = await readFile(continuePath, 'utf8') + // Must be YAML, not JSON + expect(raw).not.toMatch(/^\s*\{/) + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed).toHaveProperty('mcpServers') + expect(parsed.mcpServers).toHaveProperty('continue-mcp') + expect(parsed.mcpServers['continue-mcp']).toMatchObject({command: 'npx'}) }) it('handles continue-dev: merges into existing YAML without clobbering other entries', async () => { - const continuePath = join(homedir(), '.continue', 'config.yaml') + const continuePath = join(cwd, '.continue', 'config.yaml') - await withRestoredFile(continuePath, async () => { - // Pre-populate with an existing entry - const existing = {mcpServers: {'existing-server': {command: 'node', args: []}}} - await mkdir(join(homedir(), '.continue'), {recursive: true}) - await writeFile(continuePath, yaml.dump(existing), 'utf8') + // Pre-populate with an existing entry + const existing = {mcpServers: {'existing-server': {command: 'node', args: []}}} + await mkdir(join(cwd, '.continue'), {recursive: true}) + await writeFile(continuePath, yaml.dump(existing), 'utf8') - const entry = makeMCPEntry({name: 'new-continue-mcp', environments: ['continue-dev']}) - await deployMCPEntry(entry, 'continue-dev', cwd) + const entry = makeMCPEntry({name: 'new-continue-mcp', environments: ['continue-dev']}) + await deployMCPEntry(entry, 'continue-dev', cwd) - const raw = await readFile(continuePath, 'utf8') - const parsed = /** @type {any} */ (yaml.load(raw)) - expect(parsed.mcpServers).toHaveProperty('existing-server') - expect(parsed.mcpServers).toHaveProperty('new-continue-mcp') - }) + const raw = await readFile(continuePath, 'utf8') + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed.mcpServers).toHaveProperty('existing-server') + expect(parsed.mcpServers).toHaveProperty('new-continue-mcp') }) it('handles zed: writes to ~/.config/zed/settings.json with "context_servers" key', async () => { @@ -494,6 +495,49 @@ describe('deployMCPEntry', () => { }) }) +// ────────────────────────────────────────────────────────────────────────────── +// deployMCPEntry with scope +// ────────────────────────────────────────────────────────────────────────────── + +describe('deployMCPEntry with scope', () => { + it('deploys to project path when scope is project', async () => { + const entry = makeMCPEntry({name: 'proj-mcp', environments: ['claude-code'], scope: 'project'}) + await deployMCPEntry(entry, 'claude-code', cwd) + + expect(existsSync(join(cwd, '.mcp.json'))).toBe(true) + const json = await readJson(join(cwd, '.mcp.json')) + expect(json.mcpServers).toHaveProperty('proj-mcp') + }) + + it('deploys to global path when scope is global for dual-path environments', async () => { + const globalPath = join(homedir(), '.cursor', 'mcp.json') + + await withRestoredFile(globalPath, async () => { + const entry = makeMCPEntry({name: 'global-cursor', environments: ['cursor'], scope: 'global'}) + await deployMCPEntry(entry, 'cursor', cwd) + + expect(existsSync(globalPath)).toBe(true) + const json = await readJson(globalPath) + expect(json.mcpServers).toHaveProperty('global-cursor') + // Should NOT write to project path + expect(existsSync(join(cwd, '.cursor', 'mcp.json'))).toBe(false) + }) + }) + + it('falls back to available scope when chosen scope is not available', async () => { + const geminiPath = join(homedir(), '.gemini', 'settings.json') + + await withRestoredFile(geminiPath, async () => { + const entry = makeMCPEntry({name: 'fallback-mcp', environments: ['gemini-cli'], scope: 'project'}) + await deployMCPEntry(entry, 'gemini-cli', cwd) + + expect(existsSync(geminiPath)).toBe(true) + const json = await readJson(geminiPath) + expect(json.mcpServers).toHaveProperty('fallback-mcp') + }) + }) +}) + // ────────────────────────────────────────────────────────────────────────────── // undeployMCPEntry // ────────────────────────────────────────────────────────────────────────────── @@ -556,28 +600,26 @@ describe('undeployMCPEntry', () => { expect(json.mcpServers).toHaveProperty('server-keep') }) - it('removes an entry from continue-dev YAML while preserving others', async () => { - const continuePath = join(homedir(), '.continue', 'config.yaml') + it('removes an entry from continue-dev YAML while preserving others (project scope)', async () => { + const continuePath = join(cwd, '.continue', 'config.yaml') - await withRestoredFile(continuePath, async () => { - const initial = { - mcpServers: { - 'server-keep': {command: 'node', args: []}, - 'server-remove': {command: 'npx', args: []}, - }, - } - await mkdir(join(homedir(), '.continue'), {recursive: true}) - await writeFile(continuePath, yaml.dump(initial), 'utf8') + const initial = { + mcpServers: { + 'server-keep': {command: 'node', args: []}, + 'server-remove': {command: 'npx', args: []}, + }, + } + await mkdir(join(cwd, '.continue'), {recursive: true}) + await writeFile(continuePath, yaml.dump(initial), 'utf8') - await undeployMCPEntry('server-remove', 'continue-dev', cwd) + await undeployMCPEntry('server-remove', 'continue-dev', cwd) - const raw = await readFile(continuePath, 'utf8') - const parsed = /** @type {any} */ (yaml.load(raw)) - expect(parsed.mcpServers).not.toHaveProperty('server-remove') - expect(parsed.mcpServers).toHaveProperty('server-keep') - // Should still be valid YAML, not JSON - expect(raw).not.toMatch(/^\s*\{/) - }) + const raw = await readFile(continuePath, 'utf8') + const parsed = /** @type {any} */ (yaml.load(raw)) + expect(parsed.mcpServers).not.toHaveProperty('server-remove') + expect(parsed.mcpServers).toHaveProperty('server-keep') + // Should still be valid YAML, not JSON + expect(raw).not.toMatch(/^\s*\{/) }) it('removes an entry from amazon-q .amazonq/mcp.json', async () => { diff --git a/tests/unit/utils/tui/form.test.js b/tests/unit/utils/tui/form.test.js index 0ea0178..abc4f83 100644 --- a/tests/unit/utils/tui/form.test.js +++ b/tests/unit/utils/tui/form.test.js @@ -730,7 +730,7 @@ describe('getMCPFormFields', () => { it('returns correct number of fields', () => { const fields = getMCPFormFields() - expect(fields.length).toBe(8) // name, environments, transport, command, args, url, env, description + expect(fields.length).toBe(9) // name, environments, scope, transport, command, args, url, env, description }) })