mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
* feat(cli): add `gitnexus uninstall` to reverse setup (#2060) `gitnexus uninstall` was documented in #168 but never implemented, so the CLI rejected it with "error: unknown command 'uninstall'" (#2060). Add an `uninstall` command that reverses `gitnexus setup` target-by-target: removes the GitNexus MCP server entries (Cursor, Claude Code, Antigravity, OpenCode, Codex), the installed skill directories, and the Claude Code / Antigravity hook entries plus their bundled hook scripts. Edits are surgical and idempotent — only gitnexus-owned keys/entries/dirs are touched, and JSONC comments/indentation are preserved. Defaults to a dry-run preview; `--force` applies. Per-repo indexes and the global npm package are left alone with printed hints, since both are destructive in ways setup never caused. Adds i18n entries (en + zh-CN), help wiring, README/CHANGELOG docs, and unit tests covering MCP/hook/skill/Codex-TOML removal, dry-run, corrupt-file safety, and the no-op case. * changelog changes * changelog changes * fix(cli): harden uninstall against data-loss edge cases (review #2062) Address review findings on the uninstall command: - Empty derived skill name no longer wipes the whole skills dir: a bare '.md' source file would make basename() return '', resolving to the skills dir itself. Skip empty names in derivation and reject empty/'.'/'..'/separator names in removeSkillsFrom. - Corrupt settings.json no longer orphans the hook: gate the hook-script dir removal on status !== 'corrupt' so we don't delete a script while a still-registered entry points at it (Claude + Antigravity blocks). - Hook removal is now element-granular: delete only the gitnexus command inside an entry's hooks[], removing the whole entry only when it becomes empty. Preserves a user command co-located in the same entry. - Fallback TOML stripper: also remove descendant sub-tables ([mcp_servers.gitnexus.env]), track multiline strings so a bracketed line inside a value isn't treated as a header, and stop reflowing unrelated blank lines. - Set process.exitCode=1 on partial failure; add a 10s timeout to 'codex mcp remove'. Tests expanded 7 -> 17: empty-skill guard, corrupt-settings hook preservation, shared-entry hook removal, OpenCode MCP keyPath, Antigravity MCP + AfterTool hooks, codex-remove success path, TOML sub-table + multiline-string cases, dry-run for hooks/skills, and the directory-layout skill branch. * refactor(cli): share setup/uninstall target map + harden TOML fallback (review #2062) Maintainer review follow-ups: - Extract editor target identities into editor-targets.ts (MCP paths/keyPaths, Codex TOML section, skill dirs, hook settings/events/needles/script dirs, shared detectIndentation). Both setup.ts and uninstall.ts consume it, so a target change updates both sides — killing the silent drift hazard. - Add a setup -> uninstall round-trip integration test that iterates getEditorTargets(): setup writes every target, uninstall removes all of them, and a co-located user MCP server + user hook survive. Drift tripwire in both directions. - Preview now prints the exact paths it would remove; command output + README state skills are matched by bundled gitnexus skill name. (Provenance marker deferred to a tracked follow-up.) Hardening of the hand-rolled Codex TOML fallback (found in code review): - Strip a section header that has a trailing inline comment (was matched as a header but failed the exact classify check -> section left behind while reported removed). - Preserve CRLF line endings instead of rewriting the whole file to LF. - Fix multiline-string scan: a line with an odd count of BOTH """ and ''' no longer mis-picks the delimiter and desyncs the scanner (left->right scan). - removeSkillsFrom guard also rejects absolute names. Regression tests added for each. Full setup/uninstall suite green. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
// Codex uninstall shells out to `codex mcp remove`; make it fail by default
|
|
// so the TOML-strip fallback path is exercised in tests that don't override it.
|
|
const execFileMock = vi.fn((...args: any[]) => {
|
|
const callback = args.at(-1);
|
|
if (typeof callback === 'function') {
|
|
callback(new Error('codex not found'), '', '');
|
|
}
|
|
});
|
|
|
|
vi.mock('child_process', () => ({
|
|
execFile: execFileMock,
|
|
}));
|
|
|
|
describe('uninstallCommand', () => {
|
|
let tempHome: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalSkillsRoot: string | undefined;
|
|
let originalExitCode: typeof process.exitCode;
|
|
let skillsRoot: string;
|
|
|
|
const importUninstall = async () => (await import('../../src/cli/uninstall.js')).uninstallCommand;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
|
originalExitCode = process.exitCode;
|
|
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-uninstall-'));
|
|
process.env.HOME = tempHome;
|
|
process.env.USERPROFILE = tempHome;
|
|
|
|
// Stage a fixture skills source so listGitnexusSkillNames() resolves
|
|
// deterministically without depending on __dirname under Vitest.
|
|
skillsRoot = path.join(tempHome, 'pkg-skills');
|
|
await fs.mkdir(skillsRoot, { recursive: true });
|
|
await fs.writeFile(path.join(skillsRoot, 'gitnexus-exploring.md'), '# explore', 'utf-8');
|
|
await fs.writeFile(path.join(skillsRoot, 'gitnexus-cli.md'), '# cli', 'utf-8');
|
|
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
|
|
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
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
|
|
// test exercising that path doesn't make the whole vitest run exit non-zero.
|
|
process.exitCode = originalExitCode;
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('removes the gitnexus MCP entry from ~/.claude.json, preserving others', async () => {
|
|
const claudeJson = path.join(tempHome, '.claude.json');
|
|
await fs.writeFile(
|
|
claudeJson,
|
|
JSON.stringify({
|
|
existingKey: 'keep-me',
|
|
mcpServers: {
|
|
gitnexus: { command: 'gitnexus', args: ['mcp'] },
|
|
other: { command: 'foo' },
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const config = JSON.parse(await fs.readFile(claudeJson, 'utf-8'));
|
|
expect(config.mcpServers.gitnexus).toBeUndefined();
|
|
expect(config.mcpServers.other).toEqual({ command: 'foo' });
|
|
expect(config.existingKey).toBe('keep-me');
|
|
});
|
|
|
|
it('dry run (no --force) leaves files untouched', async () => {
|
|
const claudeJson = path.join(tempHome, '.claude.json');
|
|
const raw = JSON.stringify({
|
|
mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } },
|
|
});
|
|
await fs.writeFile(claudeJson, raw, 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand();
|
|
|
|
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(raw);
|
|
});
|
|
|
|
it('removes gitnexus hook entries and the hook-script dir, preserving other hooks', async () => {
|
|
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
|
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
|
await fs.writeFile(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
|
},
|
|
{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] },
|
|
],
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
|
await fs.mkdir(hookDir, { recursive: true });
|
|
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
expect(config.hooks.PreToolUse).toHaveLength(1);
|
|
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-hook');
|
|
expect(config.hooks.PostToolUse).toHaveLength(0);
|
|
await expect(fs.access(hookDir)).rejects.toThrow();
|
|
});
|
|
|
|
it('removes installed gitnexus skill directories from ~/.claude/skills', async () => {
|
|
const skillsDir = path.join(tempHome, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
|
|
await fs.mkdir(path.join(skillsDir, 'gitnexus-cli'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), '# y', 'utf-8');
|
|
// A user's own skill that must survive.
|
|
await fs.mkdir(path.join(skillsDir, 'my-skill'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'my-skill', 'SKILL.md'), '# mine', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(skillsDir, 'gitnexus-cli'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(skillsDir, 'my-skill'))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('strips the [mcp_servers.gitnexus] section from Codex config.toml, keeping other tables', async () => {
|
|
const codexDir = path.join(tempHome, '.codex');
|
|
await fs.mkdir(codexDir, { recursive: true });
|
|
const configPath = path.join(codexDir, 'config.toml');
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
'command = "other"',
|
|
'args = ["mcp"]',
|
|
'',
|
|
'[mcp_servers.gitnexus]',
|
|
'command = "gitnexus"',
|
|
'args = ["mcp"]',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const result = await fs.readFile(configPath, 'utf-8');
|
|
expect(result).not.toContain('[mcp_servers.gitnexus]');
|
|
expect(result).toContain('[mcp_servers.other]');
|
|
expect(result).toContain('command = "other"');
|
|
});
|
|
|
|
it('leaves a corrupt JSON config untouched', async () => {
|
|
const claudeJson = path.join(tempHome, '.claude.json');
|
|
const corrupt = '{ not valid json !!!';
|
|
await fs.writeFile(claudeJson, corrupt, 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(corrupt);
|
|
});
|
|
|
|
it('is a no-op when nothing is configured', async () => {
|
|
const uninstallCommand = await importUninstall();
|
|
await expect(uninstallCommand({ force: true })).resolves.toBeUndefined();
|
|
});
|
|
|
|
// ── #1: empty derived skill name must not wipe the whole skills dir ──
|
|
it('does not wipe the skills dir when the source has a bare ".md" file', async () => {
|
|
// A stray bare ".md" → basename('.md', '.md') === '' → would resolve to
|
|
// the skills dir itself if unguarded.
|
|
await fs.writeFile(path.join(skillsRoot, '.md'), 'stray', 'utf-8');
|
|
|
|
const skillsDir = path.join(tempHome, '.claude', 'skills');
|
|
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
|
|
await fs.mkdir(path.join(skillsDir, 'user-skill'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'user-skill', 'SKILL.md'), '# mine', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
// The skills dir and the user skill survive; only the gitnexus skill went.
|
|
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(skillsDir, 'user-skill'))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
|
|
});
|
|
|
|
// ── #2: corrupt settings.json must not orphan the hook script ──
|
|
it('keeps the hook-script dir when settings.json is corrupt (avoids dangling hook)', async () => {
|
|
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
|
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
|
const corrupt = '{ not valid json !!!';
|
|
await fs.writeFile(settingsPath, corrupt, 'utf-8');
|
|
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
|
await fs.mkdir(hookDir, { recursive: true });
|
|
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
// Entry couldn't be removed → script dir must stay, and we flag failure.
|
|
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(corrupt);
|
|
await expect(fs.access(hookDir)).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
// ── #3: element-granular hook removal preserves a co-located user hook ──
|
|
it('removes only the gitnexus command from a shared hook entry, keeping user commands', async () => {
|
|
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
|
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
|
await fs.writeFile(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: [
|
|
{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' },
|
|
{ type: 'command', command: 'my-own-inline-hook' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
// Entry survives because it still holds the user's command.
|
|
expect(config.hooks.PreToolUse).toHaveLength(1);
|
|
expect(config.hooks.PreToolUse[0].hooks).toHaveLength(1);
|
|
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-inline-hook');
|
|
});
|
|
|
|
// ── OpenCode MCP uses a distinct keyPath: ['mcp', 'gitnexus'] ──
|
|
it('removes the gitnexus entry from OpenCode opencode.json (mcp.gitnexus), preserving others', async () => {
|
|
const opencodeJson = path.join(tempHome, '.config', 'opencode', 'opencode.json');
|
|
await fs.mkdir(path.dirname(opencodeJson), { recursive: true });
|
|
await fs.writeFile(
|
|
opencodeJson,
|
|
JSON.stringify({
|
|
mcp: {
|
|
gitnexus: { type: 'local', command: ['gitnexus', 'mcp'] },
|
|
other: { type: 'local', command: ['foo'] },
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const config = JSON.parse(await fs.readFile(opencodeJson, 'utf-8'));
|
|
expect(config.mcp.gitnexus).toBeUndefined();
|
|
expect(config.mcp.other).toEqual({ type: 'local', command: ['foo'] });
|
|
});
|
|
|
|
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
|
|
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
|
|
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
|
|
await fs.mkdir(path.dirname(mcpPath), { recursive: true });
|
|
await fs.writeFile(
|
|
mcpPath,
|
|
JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } } }),
|
|
'utf-8',
|
|
);
|
|
|
|
const geminiSettings = path.join(tempHome, '.gemini', 'settings.json');
|
|
await fs.writeFile(
|
|
geminiSettings,
|
|
JSON.stringify({
|
|
hooks: {
|
|
AfterTool: [
|
|
{
|
|
matcher: 'search_file_content|glob|run_shell_command',
|
|
hooks: [{ type: 'command', command: 'node ".../gitnexus-antigravity-hook.cjs"' }],
|
|
},
|
|
{ matcher: 'glob', hooks: [{ type: 'command', command: 'user-after-tool' }] },
|
|
],
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const adapterDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
|
|
await fs.mkdir(adapterDir, { recursive: true });
|
|
await fs.writeFile(path.join(adapterDir, 'gitnexus-antigravity-hook.cjs'), '// a', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
expect(JSON.parse(await fs.readFile(mcpPath, 'utf-8')).mcpServers.gitnexus).toBeUndefined();
|
|
const settings = JSON.parse(await fs.readFile(geminiSettings, 'utf-8'));
|
|
expect(settings.hooks.AfterTool).toHaveLength(1);
|
|
expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('user-after-tool');
|
|
await expect(fs.access(adapterDir)).rejects.toThrow();
|
|
});
|
|
|
|
// ── Codex success path: prefer `codex mcp remove`, don't touch the file ──
|
|
it('uses `codex mcp remove` when the binary succeeds and leaves config.toml untouched', async () => {
|
|
execFileMock.mockImplementationOnce((...args: any[]) => {
|
|
const callback = args.at(-1);
|
|
if (typeof callback === 'function') callback(null, '', '');
|
|
});
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
const raw = ['[mcp_servers.gitnexus]', 'command = "gitnexus"', 'args = ["mcp"]', ''].join('\n');
|
|
await fs.writeFile(configPath, raw, 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
expect(execFileMock).toHaveBeenCalledWith(
|
|
'codex',
|
|
['mcp', 'remove', 'gitnexus'],
|
|
expect.any(Object),
|
|
expect.any(Function),
|
|
);
|
|
// CLI path handles removal; we must not also rewrite the file.
|
|
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
|
|
});
|
|
|
|
// ── #5: TOML fallback also strips descendant sub-tables ──
|
|
it('strips a [mcp_servers.gitnexus.env] sub-table along with the parent', async () => {
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
'command = "other"',
|
|
'',
|
|
'[mcp_servers.gitnexus]',
|
|
'command = "gitnexus"',
|
|
'',
|
|
'[mcp_servers.gitnexus.env]',
|
|
'FOO = "bar"',
|
|
'',
|
|
'[mcp_servers.zeta]',
|
|
'command = "zeta"',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const out = await fs.readFile(configPath, 'utf-8');
|
|
expect(out).not.toContain('[mcp_servers.gitnexus]');
|
|
expect(out).not.toContain('[mcp_servers.gitnexus.env]');
|
|
expect(out).not.toContain('FOO = "bar"');
|
|
expect(out).toContain('[mcp_servers.other]');
|
|
expect(out).toContain('[mcp_servers.zeta]');
|
|
expect(out).toContain('command = "zeta"');
|
|
});
|
|
|
|
// ── #5: TOML fallback ignores a [header] inside a multiline string ──
|
|
it('does not truncate a multiline string that contains a [mcp_servers.gitnexus] literal', async () => {
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
'note = """',
|
|
'[mcp_servers.gitnexus]',
|
|
'not a real header',
|
|
'"""',
|
|
'command = "other"',
|
|
'',
|
|
'[mcp_servers.gitnexus]',
|
|
'command = "gitnexus"',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const out = await fs.readFile(configPath, 'utf-8');
|
|
// The multiline literal is preserved...
|
|
expect(out).toContain('not a real header');
|
|
expect(out).toContain('command = "other"');
|
|
// ...but the real section's body is gone.
|
|
expect(out).not.toContain('command = "gitnexus"');
|
|
});
|
|
|
|
// ── #5 (regression): a multiline line containing an odd count of BOTH
|
|
// delimiters must not desync the scanner (it previously stuck in multiline
|
|
// mode and failed to strip the real section). ──
|
|
it('strips the real section even when a multiline string mixes \'\'\' and """ on one line', async () => {
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
// Opens a ''' literal; the """ on this same line is data, not an opener.
|
|
'note = \'\'\'has """ inside',
|
|
'still in string [mcp_servers.gitnexus]',
|
|
"'''",
|
|
'command = "other"',
|
|
'',
|
|
'[mcp_servers.gitnexus]',
|
|
'command = "gitnexus"',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const out = await fs.readFile(configPath, 'utf-8');
|
|
// The multiline literal (incl. the fake header line) is preserved...
|
|
expect(out).toContain('still in string [mcp_servers.gitnexus]');
|
|
expect(out).toContain('command = "other"');
|
|
// ...and the real section was actually removed (the bug left it behind).
|
|
expect(out).not.toContain('command = "gitnexus"');
|
|
});
|
|
|
|
// ── sweep: a section header with a trailing inline comment is still stripped ──
|
|
it('strips a [mcp_servers.gitnexus] header that has a trailing inline comment', async () => {
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
'command = "other"',
|
|
'',
|
|
'[mcp_servers.gitnexus] # GitNexus MCP',
|
|
'command = "gitnexus"',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const out = await fs.readFile(configPath, 'utf-8');
|
|
expect(out).not.toContain('mcp_servers.gitnexus');
|
|
expect(out).not.toContain('command = "gitnexus"');
|
|
expect(out).toContain('[mcp_servers.other]');
|
|
});
|
|
|
|
// ── sweep: CRLF config.toml keeps its line endings (no silent LF rewrite) ──
|
|
it('preserves CRLF line endings when stripping the Codex section', async () => {
|
|
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
[
|
|
'[mcp_servers.other]',
|
|
'command = "other"',
|
|
'',
|
|
'[mcp_servers.gitnexus]',
|
|
'command = "gitnexus"',
|
|
'',
|
|
].join('\r\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
const out = await fs.readFile(configPath, 'utf-8');
|
|
expect(out).not.toContain('[mcp_servers.gitnexus]');
|
|
expect(out).toContain('[mcp_servers.other]');
|
|
expect(out).toContain('\r\n');
|
|
// No bare LF: every newline is part of a CRLF.
|
|
expect(out).not.toMatch(/[^\r]\n/);
|
|
});
|
|
|
|
// ── dry-run leaves hooks and skills intact ──
|
|
it('dry run does not remove hooks, hook scripts, or skills', async () => {
|
|
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
|
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
|
const settingsRaw = JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await fs.writeFile(settingsPath, settingsRaw, 'utf-8');
|
|
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
|
await fs.mkdir(hookDir, { recursive: true });
|
|
const skillsDir = path.join(tempHome, '.claude', 'skills', 'gitnexus-cli');
|
|
await fs.mkdir(skillsDir, { recursive: true });
|
|
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), '# y', 'utf-8');
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand(); // no --force
|
|
|
|
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(settingsRaw);
|
|
await expect(fs.access(hookDir)).resolves.toBeUndefined();
|
|
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
|
|
});
|
|
|
|
// ── skills: directory-layout source ({name}/SKILL.md) is recognized ──
|
|
it('removes a directory-layout skill ({name}/SKILL.md) from a target', async () => {
|
|
await fs.mkdir(path.join(skillsRoot, 'gitnexus-dir-skill'), { recursive: true });
|
|
await fs.writeFile(path.join(skillsRoot, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
|
|
|
|
// Use a non-Claude target to also exercise a second skill destination.
|
|
const opencodeSkills = path.join(tempHome, '.config', 'opencode', 'skills');
|
|
await fs.mkdir(path.join(opencodeSkills, 'gitnexus-dir-skill'), { recursive: true });
|
|
await fs.writeFile(path.join(opencodeSkills, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
|
|
await fs.mkdir(path.join(opencodeSkills, 'keep-me'), { recursive: true });
|
|
|
|
const uninstallCommand = await importUninstall();
|
|
await uninstallCommand({ force: true });
|
|
|
|
await expect(fs.access(path.join(opencodeSkills, 'gitnexus-dir-skill'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(opencodeSkills, 'keep-me'))).resolves.toBeUndefined();
|
|
});
|
|
});
|