mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
* feat(setup): add CodeBuddy and Qoder coding-agent integrations Adds Tencent CodeBuddy and Alibaba Qoder to gitnexus setup/uninstall, fitted to the editor-targets registry and --coding-agent selection. - CodeBuddy: MCP entry written into the first existing file of its documented priority chain (~/.codebuddy/.mcp.json recommended, ~/.codebuddy/mcp.json deprecated, ~/.codebuddy.json legacy) so a populated deprecated config is never shadowed; skills to ~/.codebuddy/skills/ (https://www.codebuddy.ai/docs/cli/mcp) - Qoder: MCP entry in ~/.qoder.json, skills to ~/.qoder/skills/ (https://docs.qoder.com/cli/using-cli, /extensions/skills) - editor-targets gains optional legacyFiles; uninstall sweeps them - roster strings updated (CLI help, i18n en/zh-CN, READMEs); en/zh-CN setup descriptions were stale (missing Antigravity) and are refreshed Supersedes and credits PR #1030 by @zykai0302, re-fitted to the post-#2168 selective-agent architecture with documented config paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): assert stable zh-CN setup-description fragment * fix(setup): surface non-ENOENT config read/stat failures instead of clobbering * fix(setup): report corrupt legacy MCP files informationally during uninstall * test(setup): cover multi-candidate uninstall sweep combinations * fix(setup): detect CodeBuddy/Qoder installs via existing MCP config files * fix(setup): skip empty and non-file candidates in the MCP config chain * docs: add CodeBuddy and Qoder manual MCP configuration sections * test(ci): run the setup-uninstall round-trip in the cross-platform matrix * fix(setup): never claim "not configured" when uninstall recorded errors * refactor(cli): share the isEnoent predicate via editor-targets * refactor(setup): share chain-file install detection between CodeBuddy and Qoder --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { parse as parseJsonc } from 'jsonc-parser';
|
|
import { getEditorTargets } from '../../src/cli/editor-targets.js';
|
|
|
|
// Force the Codex path through the TOML fallback (no `codex` binary) so the
|
|
// round-trip is observable on config.toml, and make `which/where gitnexus`
|
|
// miss so getMcpEntry uses the npx form. Mirrors the unit-test mocks.
|
|
const execFileMock = vi.fn((...args: any[]) => {
|
|
const callback = args.at(-1);
|
|
if (typeof callback === 'function') callback(new Error('not available'), '', '');
|
|
});
|
|
const execFileSyncMock = vi.fn(() => {
|
|
throw new Error('not found');
|
|
});
|
|
vi.mock('child_process', () => ({
|
|
execFile: execFileMock,
|
|
execFileSync: execFileSyncMock,
|
|
}));
|
|
|
|
/** Read a value at a JSON key path, or undefined if any segment is missing. */
|
|
function valueAtPath(obj: any, keyPath: string[]): unknown {
|
|
return keyPath.reduce((o: any, k) => (o == null ? undefined : o[k]), obj);
|
|
}
|
|
|
|
/** Does any of `events` hold a hook entry whose command contains `needle`? */
|
|
function hasHookNeedle(settings: any, events: string[], needle: string): boolean {
|
|
return events.some(
|
|
(ev) =>
|
|
Array.isArray(settings?.hooks?.[ev]) &&
|
|
settings.hooks[ev].some(
|
|
(entry: any) =>
|
|
Array.isArray(entry?.hooks) &&
|
|
entry.hooks.some(
|
|
(h: any) => typeof h?.command === 'string' && h.command.includes(needle),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function exists(p: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJsonc(p: string): Promise<any> {
|
|
return parseJsonc(await fs.readFile(p, 'utf-8'));
|
|
}
|
|
|
|
/**
|
|
* setup → uninstall round-trip. This is the drift tripwire for #2062: it
|
|
* iterates over getEditorTargets() (the shared source of truth that both
|
|
* setup.ts and uninstall.ts consume), so if one side gains/loses/relocates a
|
|
* target without the other following, this fails in CI — in both directions.
|
|
*/
|
|
describe('setup → uninstall round-trip', () => {
|
|
let tempHome: string;
|
|
let skillsRoot: string;
|
|
const saved: Record<string, string | undefined> = {};
|
|
let savedExitCode: typeof process.exitCode;
|
|
|
|
// Two fixture skills exercise both source layouts (flat + directory).
|
|
const flatSkill = 'gitnexus-roundtrip-flat';
|
|
const dirSkill = 'gitnexus-roundtrip-dir';
|
|
const skillNames = [flatSkill, dirSkill];
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
saved.HOME = process.env.HOME;
|
|
saved.USERPROFILE = process.env.USERPROFILE;
|
|
saved.SKILLS = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
|
savedExitCode = process.exitCode;
|
|
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-roundtrip-'));
|
|
process.env.HOME = tempHome;
|
|
process.env.USERPROFILE = tempHome;
|
|
|
|
// Mark every editor as "installed" so setup configures all of them.
|
|
for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder']) {
|
|
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
|
|
}
|
|
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
|
|
await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true });
|
|
|
|
// Fixture skills consumed by both setup (install) and uninstall (derive).
|
|
skillsRoot = path.join(tempHome, 'pkg-skills');
|
|
await fs.mkdir(path.join(skillsRoot, dirSkill), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(skillsRoot, `${flatSkill}.md`),
|
|
`---\nname: ${flatSkill}\ndescription: flat\n---\n\n# Flat`,
|
|
'utf-8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(skillsRoot, dirSkill, 'SKILL.md'),
|
|
`---\nname: ${dirSkill}\ndescription: dir\n---\n\n# Dir`,
|
|
'utf-8',
|
|
);
|
|
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
|
|
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
process.env.HOME = saved.HOME;
|
|
process.env.USERPROFILE = saved.USERPROFILE;
|
|
if (saved.SKILLS === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
|
else process.env.GITNEXUS_TEST_SKILLS_ROOT = saved.SKILLS;
|
|
process.exitCode = savedExitCode;
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('setup writes every target and uninstall removes all of them', async () => {
|
|
const targets = getEditorTargets(tempHome);
|
|
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand();
|
|
|
|
// ── After setup: every target artifact is present ──
|
|
for (const t of targets.mcpJsonc) {
|
|
const cfg = await readJsonc(t.file);
|
|
expect(valueAtPath(cfg, t.keyPath), `setup should write ${t.label} MCP`).toBeDefined();
|
|
}
|
|
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).toContain(
|
|
`[${targets.codex.tomlSection}]`,
|
|
);
|
|
for (const t of targets.skills) {
|
|
for (const name of skillNames) {
|
|
expect(
|
|
await exists(path.join(t.dir, name, 'SKILL.md')),
|
|
`setup should install ${name} into ${t.label}`,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
for (const h of targets.hooks) {
|
|
const settings = await readJsonc(h.settingsFile);
|
|
expect(
|
|
hasHookNeedle(settings, h.events, h.needle),
|
|
`setup should register ${h.label} hook`,
|
|
).toBe(true);
|
|
expect(await exists(h.scriptDir), `setup should install ${h.label} hook scripts`).toBe(true);
|
|
}
|
|
|
|
// ── Round-trip: uninstall removes everything setup wrote ──
|
|
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
|
await uninstallCommand({ force: true });
|
|
|
|
for (const t of targets.mcpJsonc) {
|
|
const cfg = await readJsonc(t.file);
|
|
expect(valueAtPath(cfg, t.keyPath), `uninstall should remove ${t.label} MCP`).toBeUndefined();
|
|
}
|
|
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).not.toContain(
|
|
`[${targets.codex.tomlSection}]`,
|
|
);
|
|
for (const t of targets.skills) {
|
|
for (const name of skillNames) {
|
|
expect(
|
|
await exists(path.join(t.dir, name)),
|
|
`uninstall should remove ${name} from ${t.label}`,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
for (const h of targets.hooks) {
|
|
const settings = await readJsonc(h.settingsFile);
|
|
expect(
|
|
hasHookNeedle(settings, h.events, h.needle),
|
|
`uninstall should remove ${h.label} hook`,
|
|
).toBe(false);
|
|
expect(await exists(h.scriptDir), `uninstall should remove ${h.label} hook scripts`).toBe(
|
|
false,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('round-trips a CodeBuddy entry living in the home-level legacy ~/.codebuddy.json (chain position 3)', async () => {
|
|
const targets = getEditorTargets(tempHome);
|
|
const codebuddy = targets.mcpJsonc.find((t) => t.id === 'codebuddy')!;
|
|
const legacyHomeFile = codebuddy.legacyFiles![1];
|
|
|
|
await fs.writeFile(
|
|
legacyHomeFile,
|
|
JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }),
|
|
'utf-8',
|
|
);
|
|
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand();
|
|
|
|
const cfg = await readJsonc(legacyHomeFile);
|
|
expect(valueAtPath(cfg, codebuddy.keyPath)).toBeDefined();
|
|
expect(await exists(codebuddy.file)).toBe(false);
|
|
|
|
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
|
await uninstallCommand({ force: true });
|
|
|
|
const after = await readJsonc(legacyHomeFile);
|
|
expect(valueAtPath(after, codebuddy.keyPath)).toBeUndefined();
|
|
expect(after.mcpServers.mine).toEqual({ command: 'mine' });
|
|
});
|
|
|
|
it('round-trips a CodeBuddy entry living in the deprecated mcp.json (legacyFiles sweep)', async () => {
|
|
const targets = getEditorTargets(tempHome);
|
|
const codebuddy = targets.mcpJsonc.find((t) => t.id === 'codebuddy')!;
|
|
const deprecatedFile = codebuddy.legacyFiles![0];
|
|
|
|
// A populated deprecated config makes setup write there (CodeBuddy reads
|
|
// only the first existing file in its chain), not the recommended path.
|
|
await fs.writeFile(
|
|
deprecatedFile,
|
|
JSON.stringify({ mcpServers: { mine: { command: 'mine' } } }),
|
|
'utf-8',
|
|
);
|
|
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand();
|
|
|
|
const cfg = await readJsonc(deprecatedFile);
|
|
expect(valueAtPath(cfg, codebuddy.keyPath)).toBeDefined();
|
|
expect(await exists(codebuddy.file)).toBe(false);
|
|
|
|
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
|
await uninstallCommand({ force: true });
|
|
|
|
const after = await readJsonc(deprecatedFile);
|
|
expect(valueAtPath(after, codebuddy.keyPath)).toBeUndefined();
|
|
expect(after.mcpServers.mine).toEqual({ command: 'mine' });
|
|
});
|
|
|
|
it('uninstall preserves a co-located user MCP server and hook', async () => {
|
|
const targets = getEditorTargets(tempHome);
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand();
|
|
|
|
// Add a user-owned MCP server alongside gitnexus in Cursor's config, and a
|
|
// user hook alongside gitnexus in Claude's PreToolUse.
|
|
const cursor = targets.mcpJsonc.find((t) => t.id === 'cursor')!;
|
|
const cursorCfg = await readJsonc(cursor.file);
|
|
cursorCfg.mcpServers.mine = { command: 'mine' };
|
|
await fs.writeFile(cursor.file, JSON.stringify(cursorCfg, null, 2), 'utf-8');
|
|
|
|
const claudeHook = targets.hooks.find((h) => h.id === 'claude')!;
|
|
const settings = await readJsonc(claudeHook.settingsFile);
|
|
settings.hooks.PreToolUse.push({
|
|
matcher: 'Read',
|
|
hooks: [{ type: 'command', command: 'my-own-hook' }],
|
|
});
|
|
await fs.writeFile(claudeHook.settingsFile, JSON.stringify(settings, null, 2), 'utf-8');
|
|
|
|
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
|
await uninstallCommand({ force: true });
|
|
|
|
const afterCursor = await readJsonc(cursor.file);
|
|
expect(afterCursor.mcpServers.gitnexus).toBeUndefined();
|
|
expect(afterCursor.mcpServers.mine).toEqual({ command: 'mine' });
|
|
|
|
const afterSettings = await readJsonc(claudeHook.settingsFile);
|
|
const userHookSurvives = afterSettings.hooks.PreToolUse.some((e: any) =>
|
|
e.hooks?.some((h: any) => h.command === 'my-own-hook'),
|
|
);
|
|
expect(userHookSurvives).toBe(true);
|
|
expect(hasHookNeedle(afterSettings, claudeHook.events, claudeHook.needle)).toBe(false);
|
|
});
|
|
});
|