installer-targets.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. /**
  2. * Multi-target installer tests.
  3. *
  4. * Each `AgentTarget` is exercised against the same contract:
  5. * - `install` writes the expected files
  6. * - re-running `install` is byte-identical (idempotent)
  7. * - sibling MCP servers / unrelated config is preserved
  8. * - `uninstall` reverses `install`
  9. * - `printConfig` returns parseable, non-empty content
  10. *
  11. * For agent-config destinations we redirect HOME to a tmpdir via
  12. * `os.homedir` spying, and CWD via `process.chdir` — same pattern as
  13. * the legacy `installer.test.ts`. No real `~/.claude/` etc. ever
  14. * touched.
  15. */
  16. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  17. import * as fs from 'fs';
  18. import * as path from 'path';
  19. import * as os from 'os';
  20. import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
  21. import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
  22. function mkTmpDir(label: string): string {
  23. return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
  24. }
  25. // `os.homedir` is non-configurable on Node, so we redirect it via the
  26. // `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
  27. // `os.homedir()` reads first. Same trick the rest of the suite uses
  28. // when it needs a mock home.
  29. function setHome(dir: string): { restore: () => void } {
  30. const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
  31. process.env.HOME = dir;
  32. process.env.USERPROFILE = dir;
  33. return {
  34. restore() {
  35. if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
  36. if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
  37. },
  38. };
  39. }
  40. describe('Installer targets — contract', () => {
  41. let tmpHome: string;
  42. let tmpCwd: string;
  43. let origCwd: string;
  44. let homeRestore: { restore: () => void };
  45. beforeEach(() => {
  46. tmpHome = mkTmpDir('home');
  47. tmpCwd = mkTmpDir('cwd');
  48. origCwd = process.cwd();
  49. process.chdir(tmpCwd);
  50. homeRestore = setHome(tmpHome);
  51. });
  52. afterEach(() => {
  53. homeRestore.restore();
  54. process.chdir(origCwd);
  55. fs.rmSync(tmpHome, { recursive: true, force: true });
  56. fs.rmSync(tmpCwd, { recursive: true, force: true });
  57. });
  58. for (const target of ALL_TARGETS) {
  59. describe(target.id, () => {
  60. const supportedLocations = (['global', 'local'] as const).filter((l) =>
  61. target.supportsLocation(l),
  62. );
  63. for (const location of supportedLocations) {
  64. describe(`location=${location}`, () => {
  65. it('install writes files; detect.alreadyConfigured becomes true', () => {
  66. expect(target.detect(location).alreadyConfigured).toBe(false);
  67. const result = target.install(location, { autoAllow: true });
  68. expect(result.files.length).toBeGreaterThan(0);
  69. for (const file of result.files) {
  70. if (file.action !== 'unchanged') {
  71. expect(fs.existsSync(file.path)).toBe(true);
  72. }
  73. }
  74. expect(target.detect(location).alreadyConfigured).toBe(true);
  75. });
  76. it('re-running install is idempotent (no actions other than unchanged)', () => {
  77. target.install(location, { autoAllow: true });
  78. const second = target.install(location, { autoAllow: true });
  79. for (const file of second.files) {
  80. expect(file.action).toBe('unchanged');
  81. }
  82. });
  83. it('install preserves a pre-existing sibling MCP server (where applicable)', () => {
  84. // Plant a sibling entry in the same JSON config, install,
  85. // and verify the sibling survives. Skip for Codex (TOML)
  86. // and any target with no JSON config — they get covered
  87. // by their own dedicated tests below.
  88. const paths = target.describePaths(location);
  89. // Match .json or .jsonc — opencode prefers .jsonc.
  90. const jsonPath = paths.find((p) => /\.jsonc?$/.test(p));
  91. if (!jsonPath) return;
  92. // Seed pre-existing config.
  93. fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
  94. const seed: Record<string, any> = { mcpServers: { other: { command: 'x' } } };
  95. // opencode uses `mcp` not `mcpServers`. Match its shape too.
  96. if (target.id === 'opencode') {
  97. delete seed.mcpServers;
  98. seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
  99. }
  100. fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
  101. target.install(location, { autoAllow: true });
  102. const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
  103. if (target.id === 'opencode') {
  104. expect(after.mcp.other).toBeDefined();
  105. expect(after.mcp.codegraph).toBeDefined();
  106. } else {
  107. expect(after.mcpServers.other).toBeDefined();
  108. expect(after.mcpServers.codegraph).toBeDefined();
  109. }
  110. });
  111. it('uninstall reverses install (alreadyConfigured returns to false)', () => {
  112. target.install(location, { autoAllow: true });
  113. expect(target.detect(location).alreadyConfigured).toBe(true);
  114. target.uninstall(location);
  115. expect(target.detect(location).alreadyConfigured).toBe(false);
  116. });
  117. it('printConfig returns non-empty output without writing anything', () => {
  118. const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
  119. const out = target.printConfig(location);
  120. expect(out.length).toBeGreaterThan(0);
  121. const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
  122. expect(after.sort()).toEqual(before.sort());
  123. });
  124. });
  125. }
  126. });
  127. }
  128. });
  129. describe('Installer targets — partial-state idempotency', () => {
  130. let tmpHome: string;
  131. let tmpCwd: string;
  132. let origCwd: string;
  133. let homeRestore: { restore: () => void };
  134. beforeEach(() => {
  135. tmpHome = mkTmpDir('home');
  136. tmpCwd = mkTmpDir('cwd');
  137. origCwd = process.cwd();
  138. process.chdir(tmpCwd);
  139. homeRestore = setHome(tmpHome);
  140. });
  141. afterEach(() => {
  142. homeRestore.restore();
  143. process.chdir(origCwd);
  144. fs.rmSync(tmpHome, { recursive: true, force: true });
  145. fs.rmSync(tmpCwd, { recursive: true, force: true });
  146. });
  147. it('codex: install after only config.toml exists — second pass is fully unchanged', () => {
  148. const codex = getTarget('codex')!;
  149. // First install creates both files.
  150. codex.install('global', { autoAllow: false });
  151. // Delete the AGENTS.md to simulate partial state (user wiped one file).
  152. const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md');
  153. expect(fs.existsSync(agentsMd)).toBe(true);
  154. fs.unlinkSync(agentsMd);
  155. // Reinstall — TOML stays unchanged, AGENTS.md is recreated.
  156. const second = codex.install('global', { autoAllow: false });
  157. const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
  158. const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!;
  159. expect(tomlEntry.action).toBe('unchanged');
  160. expect(mdEntry.action).toBe('created');
  161. // Third install — both unchanged (full idempotency restored).
  162. const third = codex.install('global', { autoAllow: false });
  163. for (const f of third.files) expect(f.action).toBe('unchanged');
  164. });
  165. it('opencode: prefers .jsonc when both .json and .jsonc exist', () => {
  166. const opencode = getTarget('opencode')!;
  167. const dir = path.join(tmpHome, '.config', 'opencode');
  168. fs.mkdirSync(dir, { recursive: true });
  169. fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
  170. fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
  171. const result = opencode.install('global', { autoAllow: true });
  172. const written = result.files.find((f) => /\.jsonc$/.test(f.path))!;
  173. expect(written).toBeDefined();
  174. expect(written.action).not.toBe('not-found');
  175. // The .json file is left alone.
  176. const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
  177. expect(jsonText).not.toContain('codegraph');
  178. });
  179. it('opencode: uses .json when only .json exists (no .jsonc)', () => {
  180. const opencode = getTarget('opencode')!;
  181. const dir = path.join(tmpHome, '.config', 'opencode');
  182. fs.mkdirSync(dir, { recursive: true });
  183. fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
  184. const result = opencode.install('global', { autoAllow: true });
  185. expect(result.files[0].path).toMatch(/opencode\.json$/);
  186. expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false);
  187. });
  188. it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => {
  189. const opencode = getTarget('opencode')!;
  190. const result = opencode.install('global', { autoAllow: true });
  191. expect(result.files[0].path).toMatch(/opencode\.jsonc$/);
  192. expect(result.files[0].action).toBe('created');
  193. });
  194. it('opencode: preserves line and block comments through install + idempotent re-run', () => {
  195. const opencode = getTarget('opencode')!;
  196. const dir = path.join(tmpHome, '.config', 'opencode');
  197. fs.mkdirSync(dir, { recursive: true });
  198. const file = path.join(dir, 'opencode.jsonc');
  199. const original = [
  200. '{',
  201. ' // top-level note about my opencode setup',
  202. ' "$schema": "https://opencode.ai/config.json",',
  203. ' /* multi-line block comment',
  204. ' describing the providers section */',
  205. ' "providers": {',
  206. ' "anthropic": { "model": "claude-opus-4-7" } // pinned',
  207. ' }',
  208. '}',
  209. '',
  210. ].join('\n');
  211. fs.writeFileSync(file, original);
  212. opencode.install('global', { autoAllow: true });
  213. const afterInstall = fs.readFileSync(file, 'utf-8');
  214. expect(afterInstall).toContain('// top-level note about my opencode setup');
  215. expect(afterInstall).toContain('/* multi-line block comment');
  216. expect(afterInstall).toContain('// pinned');
  217. expect(afterInstall).toContain('"codegraph"');
  218. expect(afterInstall).toContain('"providers"');
  219. // Idempotent re-run reports unchanged, file is byte-identical.
  220. const second = opencode.install('global', { autoAllow: true });
  221. expect(second.files[0].action).toBe('unchanged');
  222. expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
  223. });
  224. it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => {
  225. const opencode = getTarget('opencode')!;
  226. opencode.install('global', { autoAllow: true });
  227. const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md');
  228. expect(fs.existsSync(agentsMd)).toBe(true);
  229. const body = fs.readFileSync(agentsMd, 'utf-8');
  230. expect(body).toContain('<!-- CODEGRAPH_START -->');
  231. expect(body).toContain('<!-- CODEGRAPH_END -->');
  232. expect(body).toContain('codegraph_callers');
  233. });
  234. it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => {
  235. const opencode = getTarget('opencode')!;
  236. const dir = path.join(tmpHome, '.config', 'opencode');
  237. fs.mkdirSync(dir, { recursive: true });
  238. const agentsMd = path.join(dir, 'AGENTS.md');
  239. fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
  240. opencode.install('global', { autoAllow: true });
  241. const body = fs.readFileSync(agentsMd, 'utf-8');
  242. expect(body).toContain('# My personal opencode instructions');
  243. expect(body).toContain('Always respond in pirate.');
  244. expect(body).toContain('<!-- CODEGRAPH_START -->');
  245. });
  246. it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => {
  247. const opencode = getTarget('opencode')!;
  248. const dir = path.join(tmpHome, '.config', 'opencode');
  249. fs.mkdirSync(dir, { recursive: true });
  250. const agentsMd = path.join(dir, 'AGENTS.md');
  251. fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
  252. opencode.install('global', { autoAllow: true });
  253. opencode.uninstall('global');
  254. const body = fs.readFileSync(agentsMd, 'utf-8');
  255. expect(body).toContain('# My personal opencode instructions');
  256. expect(body).toContain('Always respond in pirate.');
  257. expect(body).not.toContain('CODEGRAPH_START');
  258. expect(body).not.toContain('codegraph_callers');
  259. });
  260. it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => {
  261. const opencode = getTarget('opencode')!;
  262. const result = opencode.install('local', { autoAllow: true });
  263. const paths = result.files.map((f) => f.path);
  264. // macOS realpath shenanigans (/var vs /private/var) — suffix match.
  265. expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true);
  266. expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
  267. });
  268. it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
  269. const opencode = getTarget('opencode')!;
  270. const dir = path.join(tmpHome, '.config', 'opencode');
  271. fs.mkdirSync(dir, { recursive: true });
  272. const file = path.join(dir, 'opencode.jsonc');
  273. fs.writeFileSync(file, [
  274. '{',
  275. ' // important comment',
  276. ' "$schema": "https://opencode.ai/config.json",',
  277. ' "mcp": {',
  278. ' "other": { "type": "local", "command": ["x"], "enabled": true }',
  279. ' }',
  280. '}',
  281. '',
  282. ].join('\n'));
  283. opencode.install('global', { autoAllow: true });
  284. const afterInstall = fs.readFileSync(file, 'utf-8');
  285. expect(afterInstall).toContain('"codegraph"');
  286. expect(afterInstall).toContain('"other"');
  287. opencode.uninstall('global');
  288. const afterUninstall = fs.readFileSync(file, 'utf-8');
  289. expect(afterUninstall).not.toContain('codegraph');
  290. expect(afterUninstall).toContain('// important comment');
  291. expect(afterUninstall).toContain('"other"');
  292. });
  293. it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
  294. const codex = getTarget('codex')!;
  295. codex.install('global', { autoAllow: false });
  296. const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
  297. const original = fs.readFileSync(tomlPath, 'utf-8');
  298. // User edits the block to add a custom key.
  299. const edited = original.replace(
  300. 'args = ["serve", "--mcp"]',
  301. 'args = ["serve", "--mcp"]\nenabled = true',
  302. );
  303. fs.writeFileSync(tomlPath, edited);
  304. // Re-install: our serializer doesn't know `enabled = true`, so
  305. // the block no longer matches the canonical form — we'll
  306. // overwrite it. This is the documented contract: we own the
  307. // codegraph block exclusively.
  308. const second = codex.install('global', { autoAllow: false });
  309. const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
  310. expect(tomlEntry.action).toBe('updated');
  311. const after = fs.readFileSync(tomlPath, 'utf-8');
  312. expect(after).not.toContain('enabled = true');
  313. });
  314. it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => {
  315. const claude = getTarget('claude')!;
  316. const result = claude.install('local', { autoAllow: false });
  317. // The MCP entry lands in ./.mcp.json — the file Claude Code reads.
  318. expect(result.files.some((f) => f.path.endsWith('/.mcp.json'))).toBe(true);
  319. expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true);
  320. expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
  321. const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
  322. expect(cfg.mcpServers.codegraph).toBeDefined();
  323. });
  324. it('claude: global install targets ~/.claude.json (user scope)', () => {
  325. const claude = getTarget('claude')!;
  326. claude.install('global', { autoAllow: false });
  327. const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
  328. expect(cfg.mcpServers.codegraph).toBeDefined();
  329. });
  330. it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
  331. const claude = getTarget('claude')!;
  332. const legacy = path.join(tmpCwd, '.claude.json');
  333. fs.writeFileSync(
  334. legacy,
  335. JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
  336. );
  337. claude.install('local', { autoAllow: false });
  338. // codegraph now lives in .mcp.json; the legacy file (which held only
  339. // codegraph) is gone.
  340. const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
  341. expect(mcp.mcpServers.codegraph).toBeDefined();
  342. expect(fs.existsSync(legacy)).toBe(false);
  343. });
  344. it('claude: legacy ./.claude.json migration preserves sibling servers and unrelated keys', () => {
  345. const claude = getTarget('claude')!;
  346. const legacy = path.join(tmpCwd, '.claude.json');
  347. fs.writeFileSync(
  348. legacy,
  349. JSON.stringify({
  350. mcpServers: {
  351. codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] },
  352. other: { command: 'x' },
  353. },
  354. somethingElse: true,
  355. }, null, 2),
  356. );
  357. claude.install('local', { autoAllow: false });
  358. // Only codegraph is stripped from the legacy file; siblings survive.
  359. const after = JSON.parse(fs.readFileSync(legacy, 'utf-8'));
  360. expect(after.mcpServers.codegraph).toBeUndefined();
  361. expect(after.mcpServers.other).toBeDefined();
  362. expect(after.somethingElse).toBe(true);
  363. const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
  364. expect(mcp.mcpServers.codegraph).toBeDefined();
  365. });
  366. it('claude: uninstall strips codegraph from ./.mcp.json and a legacy ./.claude.json', () => {
  367. const claude = getTarget('claude')!;
  368. // A user left with both the working .mcp.json and a stale .claude.json.
  369. fs.writeFileSync(
  370. path.join(tmpCwd, '.mcp.json'),
  371. JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' } } }, null, 2),
  372. );
  373. fs.writeFileSync(
  374. path.join(tmpCwd, '.claude.json'),
  375. JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' }, other: { command: 'x' } } }, null, 2),
  376. );
  377. claude.uninstall('local');
  378. const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
  379. expect(mcp.mcpServers).toBeUndefined();
  380. const legacy = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude.json'), 'utf-8'));
  381. expect(legacy.mcpServers.codegraph).toBeUndefined();
  382. expect(legacy.mcpServers.other).toBeDefined();
  383. });
  384. });
  385. describe('Installer targets — registry', () => {
  386. it('getTarget returns the right target for each id', () => {
  387. expect(getTarget('claude')?.id).toBe('claude');
  388. expect(getTarget('cursor')?.id).toBe('cursor');
  389. expect(getTarget('codex')?.id).toBe('codex');
  390. expect(getTarget('opencode')?.id).toBe('opencode');
  391. expect(getTarget('not-a-real-target')).toBeUndefined();
  392. });
  393. it('resolveTargetFlag handles auto/all/none/csv', () => {
  394. expect(resolveTargetFlag('none', 'global')).toEqual([]);
  395. expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length);
  396. const csv = resolveTargetFlag('claude,cursor', 'global');
  397. expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
  398. });
  399. it('resolveTargetFlag throws on unknown id', () => {
  400. expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
  401. });
  402. });
  403. describe('Installer targets — TOML serializer (Codex backbone)', () => {
  404. it('builds a [mcp_servers.codegraph] block with command + args', () => {
  405. const block = buildTomlTable('mcp_servers.codegraph', {
  406. command: 'codegraph',
  407. args: ['serve', '--mcp'],
  408. });
  409. expect(block).toContain('[mcp_servers.codegraph]');
  410. expect(block).toContain('command = "codegraph"');
  411. expect(block).toContain('args = ["serve", "--mcp"]');
  412. });
  413. it('upsert inserts into empty content', () => {
  414. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  415. const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block);
  416. expect(action).toBe('inserted');
  417. expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true);
  418. });
  419. it('upsert is idempotent — second call returns unchanged', () => {
  420. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  421. const first = upsertTomlTable('', 'mcp_servers.codegraph', block);
  422. const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block);
  423. expect(second.action).toBe('unchanged');
  424. expect(second.content).toBe(first.content);
  425. });
  426. it('upsert replaces an existing block in place, preserving sibling tables', () => {
  427. const existing = [
  428. '[other_table]',
  429. 'foo = "bar"',
  430. '',
  431. '[mcp_servers.codegraph]',
  432. 'command = "old-codegraph"',
  433. 'args = ["old"]',
  434. '',
  435. '[zzz]',
  436. 'baz = "qux"',
  437. '',
  438. ].join('\n');
  439. const newBlock = buildTomlTable('mcp_servers.codegraph', {
  440. command: 'codegraph',
  441. args: ['serve', '--mcp'],
  442. });
  443. const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock);
  444. expect(action).toBe('replaced');
  445. expect(content).toContain('[other_table]');
  446. expect(content).toContain('foo = "bar"');
  447. expect(content).toContain('[zzz]');
  448. expect(content).toContain('baz = "qux"');
  449. expect(content).toContain('command = "codegraph"');
  450. expect(content).not.toContain('old-codegraph');
  451. });
  452. it('removeTomlTable strips the block and preserves siblings', () => {
  453. const existing = [
  454. '[other_table]',
  455. 'foo = "bar"',
  456. '',
  457. '[mcp_servers.codegraph]',
  458. 'command = "codegraph"',
  459. 'args = ["serve"]',
  460. ].join('\n');
  461. const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
  462. expect(action).toBe('removed');
  463. expect(content).toContain('[other_table]');
  464. expect(content).toContain('foo = "bar"');
  465. expect(content).not.toContain('mcp_servers.codegraph');
  466. });
  467. it('removeTomlTable on missing table returns not-found, no content change', () => {
  468. const existing = '[other]\nfoo = "bar"\n';
  469. const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
  470. expect(action).toBe('not-found');
  471. expect(content).toBe(existing);
  472. });
  473. it('upsert preserves an array-of-tables sibling [[foo]]', () => {
  474. const existing = [
  475. '[[foo]]',
  476. 'name = "a"',
  477. '',
  478. '[[foo]]',
  479. 'name = "b"',
  480. '',
  481. ].join('\n');
  482. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  483. const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
  484. expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2);
  485. expect(content).toContain('[mcp_servers.codegraph]');
  486. });
  487. });
  488. function listAllFiles(dir: string): string[] {
  489. if (!fs.existsSync(dir)) return [];
  490. const out: string[] = [];
  491. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  492. const full = path.join(dir, entry.name);
  493. if (entry.isDirectory()) out.push(...listAllFiles(full));
  494. else out.push(full);
  495. }
  496. return out;
  497. }