mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix(setup): preserve existing HTTP GitNexus MCP entries (#3460)
This commit is contained in:
parent
4758df1c6c
commit
10947d4b52
6 changed files with 285 additions and 16 deletions
|
|
@ -36,6 +36,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
|
||||
|
||||
For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install).
|
||||
|
||||
### Editor Support
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
|
|
@ -398,7 +400,7 @@ An operation must name a protocol, either through its own `bindings` or through
|
|||
|
||||
Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
> **`gitnexus uninstall`** removes the named GitNexus MCP entries, hooks, and skill directories from detected editors. This includes a URL-based MCP entry you configured yourself, even if setup preserved it. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
## Remote Embeddings
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface McpJsoncTarget {
|
|||
export interface CodexMcpTarget {
|
||||
id: 'codex';
|
||||
label: string;
|
||||
/** Absolute path to ~/.codex/config.toml. */
|
||||
/** Absolute path to CODEX_HOME/config.toml (default ~/.codex/config.toml). */
|
||||
configFile: string;
|
||||
/** The TOML table header (without brackets) setup writes / uninstall strips. */
|
||||
tomlSection: string;
|
||||
|
|
@ -100,6 +100,9 @@ export interface EditorTargets {
|
|||
* `process.env.HOME` before invoking sees the right locations.
|
||||
*/
|
||||
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
|
||||
const codexHome = process.env.CODEX_HOME
|
||||
? path.resolve(process.env.CODEX_HOME)
|
||||
: path.join(home, '.codex');
|
||||
const mcpJsonc: McpJsoncTarget[] = [
|
||||
{
|
||||
id: 'cursor',
|
||||
|
|
@ -166,7 +169,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
|
|||
const codex: CodexMcpTarget = {
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
configFile: path.join(home, '.codex', 'config.toml'),
|
||||
configFile: path.join(codexHome, 'config.toml'),
|
||||
tomlSection: 'mcp_servers.gitnexus',
|
||||
};
|
||||
|
||||
|
|
@ -205,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
|
|||
// Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape
|
||||
// and hookSpecificOutput response contract, in a dedicated hooks.json
|
||||
// (https://developers.openai.com/codex/hooks).
|
||||
settingsFile: path.join(home, '.codex', 'hooks.json'),
|
||||
settingsFile: path.join(codexHome, 'hooks.json'),
|
||||
events: ['PreToolUse', 'PostToolUse'],
|
||||
needle: 'gitnexus-hook',
|
||||
scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'),
|
||||
scriptDir: path.join(codexHome, 'hooks', 'gitnexus'),
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,16 @@ import os from 'os';
|
|||
import { execFile, execFileSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
|
||||
import {
|
||||
parseTree,
|
||||
findNodeAtLocation,
|
||||
getNodeValue,
|
||||
modify,
|
||||
applyEdits,
|
||||
ParseError,
|
||||
parse as parseJsonc,
|
||||
} from 'jsonc-parser';
|
||||
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||
import { packageVersion } from '../core/package-version.js';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
import {
|
||||
|
|
@ -214,7 +223,8 @@ async function mergeJsoncFile(
|
|||
filePath: string,
|
||||
keyPath: string[],
|
||||
value: unknown,
|
||||
): Promise<boolean> {
|
||||
options?: { preserveHttpEntry?: boolean },
|
||||
): Promise<boolean | 'preserved-http'> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8');
|
||||
|
|
@ -240,6 +250,15 @@ async function mergeJsoncFile(
|
|||
const tree = parseTree(raw, parseErrors);
|
||||
|
||||
if (tree && tree.type === 'object' && parseErrors.length === 0) {
|
||||
const existingNode = options?.preserveHttpEntry ? findNodeAtLocation(tree, keyPath) : undefined;
|
||||
const existingEntry = existingNode === undefined ? undefined : getNodeValue(existingNode);
|
||||
if (
|
||||
existingEntry !== null &&
|
||||
typeof existingEntry === 'object' &&
|
||||
typeof (existingEntry as Record<string, unknown>).url === 'string'
|
||||
) {
|
||||
return 'preserved-http';
|
||||
}
|
||||
const formattingOptions = detectIndentation(raw);
|
||||
const edits = modify(raw, keyPath, value, { formattingOptions });
|
||||
const result = applyEdits(raw, edits);
|
||||
|
|
@ -306,8 +325,12 @@ async function setupCursor(result: SetupResult): Promise<void> {
|
|||
|
||||
const { file: mcpPath, keyPath } = mcpTarget('cursor');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
||||
if (ok) {
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), {
|
||||
preserveHttpEntry: true,
|
||||
});
|
||||
if (ok === 'preserved-http') {
|
||||
result.configured.push('Cursor (existing HTTP MCP entry kept)');
|
||||
} else if (ok) {
|
||||
result.configured.push('Cursor');
|
||||
} else {
|
||||
result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content');
|
||||
|
|
@ -327,8 +350,12 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
|
|||
// Claude Code stores MCP config in ~/.claude.json
|
||||
const { file: mcpPath, keyPath } = mcpTarget('claude');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
||||
if (ok) {
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), {
|
||||
preserveHttpEntry: true,
|
||||
});
|
||||
if (ok === 'preserved-http') {
|
||||
result.configured.push('Claude Code (existing HTTP MCP entry kept)');
|
||||
} else if (ok) {
|
||||
result.configured.push('Claude Code');
|
||||
} else {
|
||||
result.errors.push(
|
||||
|
|
@ -1044,13 +1071,47 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
|
|||
await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function codexHasHttpMcpEntry(configPath: string): Promise<boolean> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(configPath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return false;
|
||||
throw err;
|
||||
}
|
||||
const config = parseToml(raw);
|
||||
const servers = config.mcp_servers;
|
||||
if (servers === null || typeof servers !== 'object') return false;
|
||||
const entry = (servers as Record<string, unknown>).gitnexus;
|
||||
return (
|
||||
entry !== null &&
|
||||
typeof entry === 'object' &&
|
||||
typeof (entry as Record<string, unknown>).url === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
async function setupCodex(result: SetupResult): Promise<void> {
|
||||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
if (!(await dirExists(codexDir))) {
|
||||
const configPath = getEditorTargets().codex.configFile;
|
||||
const codexDir = path.dirname(configPath);
|
||||
if (!process.env.CODEX_HOME && !(await dirExists(codexDir))) {
|
||||
result.skipped.push('Codex (not installed)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await codexHasHttpMcpEntry(configPath)) {
|
||||
result.configured.push('Codex (existing HTTP MCP entry kept)');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(
|
||||
err instanceof TomlError
|
||||
? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged`
|
||||
: `Codex: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = getMcpEntry();
|
||||
await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], {
|
||||
|
|
@ -1064,9 +1125,8 @@ async function setupCodex(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
const configPath = getEditorTargets().codex.configFile;
|
||||
await upsertCodexConfigToml(configPath);
|
||||
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
|
||||
result.configured.push('Codex (MCP added to active config.toml)');
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Codex: ${err.message}`);
|
||||
}
|
||||
|
|
@ -1265,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
|||
* Install global Codex skills to ~/.agents/skills/gitnexus/
|
||||
*/
|
||||
async function installCodexSkills(result: SetupResult): Promise<void> {
|
||||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
const codexDir = path.dirname(getEditorTargets().codex.configFile);
|
||||
if (!(await dirExists(codexDir))) return;
|
||||
|
||||
const skillsDir = skillTarget('codex').dir;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe('setupCommand codex execution', () => {
|
|||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
let platformDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
const setPlatform = (value: NodeJS.Platform) => {
|
||||
|
|
@ -42,6 +43,8 @@ describe('setupCommand codex execution', () => {
|
|||
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
|
@ -62,6 +65,8 @@ describe('setupCommand codex execution', () => {
|
|||
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = originalCodexHome;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -110,6 +115,93 @@ describe('setupCommand codex execution', () => {
|
|||
await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps an existing HTTP entry and its bearer-token setting without invoking codex mcp add', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
const raw =
|
||||
'[mcp_servers.gitnexus]\nurl = "http://127.0.0.1:4748/mcp"\nbearer_token_env_var = "GITNEXUS_TOKEN"\n';
|
||||
await fs.writeFile(configPath, raw, 'utf-8');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
|
||||
expect(
|
||||
await fs.stat(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not mistake an HTTP entry in the default home for the active CODEX_HOME entry', async () => {
|
||||
const defaultConfig = path.join(tempHome, '.codex', 'config.toml');
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
await fs.writeFile(defaultConfig, '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n');
|
||||
await fs.mkdir(activeHome);
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
{ shell: true, windowsHide: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an HTTP entry in the active CODEX_HOME even when the default home is empty', async () => {
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
const activeConfig = path.join(activeHome, 'config.toml');
|
||||
const raw = '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n';
|
||||
await fs.mkdir(activeHome);
|
||||
await fs.writeFile(activeConfig, raw);
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(activeConfig, 'utf-8')).toBe(raw);
|
||||
});
|
||||
|
||||
it('writes the fallback entry to the active CODEX_HOME when the Codex CLI is unavailable', async () => {
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
execFileMock.mockImplementationOnce((...args: unknown[]) => {
|
||||
const callback = args.at(-1);
|
||||
if (typeof callback === 'function') callback(new Error('codex unavailable'));
|
||||
});
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
expect(await fs.readFile(path.join(activeHome, 'config.toml'), 'utf-8')).toContain(
|
||||
'[mcp_servers.gitnexus]',
|
||||
);
|
||||
await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not print TOML source or nearby credentials when the active config is malformed', async () => {
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
const configPath = path.join(activeHome, 'config.toml');
|
||||
const token = 'Bearer FAKE_PR3460_REVIEW_TOKEN';
|
||||
await fs.mkdir(activeHome);
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`[mcp_servers.gitnexus]\nhttp_headers = { Authorization = "${token}" }\ninvalid =\n`,
|
||||
);
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
const output = JSON.stringify(vi.mocked(console.log).mock.calls);
|
||||
expect(output).toMatch(/invalid config\.toml \(line 3, column \d+\)/);
|
||||
expect(output).not.toContain(token);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(configPath, 'utf-8')).toContain(token);
|
||||
});
|
||||
|
||||
it('skips Codex setup entirely when ~/.codex is missing', async () => {
|
||||
await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,40 @@ describe('setupClaudeCode', () => {
|
|||
expect(config.mcpServers.gitnexus).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps existing Claude and Cursor HTTP MCP entries while installing skills', async () => {
|
||||
const claudePath = path.join(tempHome, '.claude.json');
|
||||
const cursorDir = path.join(tempHome, '.cursor');
|
||||
const cursorPath = path.join(cursorDir, 'mcp.json');
|
||||
await fs.mkdir(cursorDir, { recursive: true });
|
||||
const claudeRaw = `{
|
||||
// Shared GitNexus server
|
||||
"mcpServers": {
|
||||
"gitnexus": { "type": "http", "url": "http://127.0.0.1:4748/mcp", "headers": { "Authorization": "Bearer $GITNEXUS_TOKEN" } }
|
||||
}
|
||||
}`;
|
||||
const cursorRaw = JSON.stringify({
|
||||
mcpServers: {
|
||||
gitnexus: {
|
||||
url: 'http://127.0.0.1:4748/mcp',
|
||||
headers: { Authorization: 'Bearer $GITNEXUS_TOKEN' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(claudePath, claudeRaw, 'utf-8');
|
||||
await fs.writeFile(cursorPath, cursorRaw, 'utf-8');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: ['claude', 'cursor'] });
|
||||
|
||||
expect(await fs.readFile(claudePath, 'utf-8')).toBe(claudeRaw);
|
||||
expect(await fs.readFile(cursorPath, 'utf-8')).toBe(cursorRaw);
|
||||
expect(logLines()).toContain('Claude Code (existing HTTP MCP entry kept)');
|
||||
expect(logLines()).toContain('Cursor (existing HTTP MCP entry kept)');
|
||||
expect(
|
||||
await fs.stat(path.join(tempHome, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md')),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles missing ~/.claude.json (creates fresh)', async () => {
|
||||
setPlatform('linux');
|
||||
|
||||
|
|
@ -800,6 +834,7 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => {
|
|||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json');
|
||||
|
||||
|
|
@ -809,6 +844,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => {
|
|||
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
|
@ -824,6 +861,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => {
|
|||
vi.restoreAllMocks();
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = originalCodexHome;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -844,6 +883,28 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => {
|
|||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('installs hooks and skills for active CODEX_HOME without touching the default home', async () => {
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
const defaultHooks = hooksJsonPath();
|
||||
await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });
|
||||
await fs.mkdir(activeHome);
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand({ codingAgent: 'codex' });
|
||||
|
||||
const hooks = JSON.parse(await fs.readFile(path.join(activeHome, 'hooks.json'), 'utf-8')).hooks;
|
||||
expect(hooks.PreToolUse).toHaveLength(1);
|
||||
expect(hooks.PostToolUse).toHaveLength(1);
|
||||
await expect(
|
||||
fs.access(path.join(activeHome, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(defaultHooks)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('is idempotent — a second setup run adds no duplicate entries', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ describe('uninstallCommand', () => {
|
|||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
let originalSkillsRoot: string | undefined;
|
||||
let originalExitCode: typeof process.exitCode;
|
||||
let skillsRoot: string;
|
||||
|
|
@ -35,6 +36,8 @@ describe('uninstallCommand', () => {
|
|||
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
originalExitCode = process.exitCode;
|
||||
|
||||
|
|
@ -57,6 +60,8 @@ describe('uninstallCommand', () => {
|
|||
vi.restoreAllMocks();
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = originalCodexHome;
|
||||
if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot;
|
||||
// The command sets process.exitCode=1 on partial failure; restore it so a
|
||||
|
|
@ -203,6 +208,52 @@ describe('uninstallCommand', () => {
|
|||
expect(result).toContain('command = "other"');
|
||||
});
|
||||
|
||||
it('dry-runs and removes only the active CODEX_HOME MCP entry', async () => {
|
||||
const activeHome = path.join(tempHome, 'active-codex');
|
||||
const activeConfig = path.join(activeHome, 'config.toml');
|
||||
const defaultConfig = path.join(tempHome, '.codex', 'config.toml');
|
||||
const activeHooks = path.join(activeHome, 'hooks.json');
|
||||
const defaultHooks = path.join(tempHome, '.codex', 'hooks.json');
|
||||
const activeScriptDir = path.join(activeHome, 'hooks', 'gitnexus');
|
||||
const defaultScriptDir = path.join(tempHome, '.codex', 'hooks', 'gitnexus');
|
||||
const activeRaw = '[mcp_servers.gitnexus]\ncommand = "active"\n';
|
||||
const defaultRaw = '[mcp_servers.gitnexus]\ncommand = "default"\n';
|
||||
const hooksRaw = JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node gitnexus-hook.cjs' }] },
|
||||
{ matcher: 'Read', hooks: [{ type: 'command', command: 'user-hook' }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
await fs.mkdir(activeHome);
|
||||
await fs.mkdir(path.dirname(defaultConfig));
|
||||
await fs.mkdir(activeScriptDir, { recursive: true });
|
||||
await fs.mkdir(defaultScriptDir, { recursive: true });
|
||||
await fs.writeFile(activeConfig, activeRaw);
|
||||
await fs.writeFile(defaultConfig, defaultRaw);
|
||||
await fs.writeFile(activeHooks, hooksRaw);
|
||||
await fs.writeFile(defaultHooks, hooksRaw);
|
||||
process.env.CODEX_HOME = activeHome;
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand();
|
||||
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeConfig);
|
||||
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeHooks);
|
||||
expect(await fs.readFile(activeConfig, 'utf-8')).toBe(activeRaw);
|
||||
expect(await fs.readFile(activeHooks, 'utf-8')).toBe(hooksRaw);
|
||||
|
||||
await uninstallCommand({ force: true });
|
||||
expect(await fs.readFile(activeConfig, 'utf-8')).not.toContain('[mcp_servers.gitnexus]');
|
||||
expect(await fs.readFile(defaultConfig, 'utf-8')).toBe(defaultRaw);
|
||||
const remaining = JSON.parse(await fs.readFile(activeHooks, 'utf-8'));
|
||||
expect(remaining.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(remaining.hooks.PreToolUse[0].hooks[0].command).toBe('user-hook');
|
||||
expect(await fs.readFile(defaultHooks, 'utf-8')).toBe(hooksRaw);
|
||||
await expect(fs.access(activeScriptDir)).rejects.toThrow();
|
||||
await expect(fs.access(defaultScriptDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a corrupt JSON config untouched', async () => {
|
||||
const claudeJson = path.join(tempHome, '.claude.json');
|
||||
const corrupt = '{ not valid json !!!';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue