From 7c57167a1c42bce450a6c3cea068067dbf4491e6 Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Mon, 9 Feb 2026 09:14:50 -0700 Subject: [PATCH] Add config command for managing CLI configuration Implement generic config management command: - tangled config list: List all available config keys with descriptions - tangled config get [key]: View current configuration (all or specific) - tangled config set [--global]: Set any config value - tangled config unset [--global]: Clear any config value The --global flag switches between local (.tangledrc in repo) and user (~/.tangledrc) config storage. Generic set/unset allows for future config keys without code changes. The list command helps users discover available configuration options. Includes comprehensive test coverage (15 tests) for all operations and edge cases. Co-Authored-By: Claude Sonnet 4.5 --- src/commands/config.ts | 204 ++++++++++++++++++++++++++ tests/commands/config.test.ts | 261 ++++++++++++++++++++++++++++++++++ 2 files changed, 465 insertions(+) create mode 100644 src/commands/config.ts create mode 100644 tests/commands/config.test.ts diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..2fb68dc --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,204 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { Command } from 'commander'; +import { simpleGit } from 'simple-git'; +import { type TangledConfig, loadConfig } from '../lib/config.js'; + +/** + * Get Git root directory + */ +async function getGitRoot(cwd: string = process.cwd()): Promise { + try { + const git = simpleGit(cwd); + const isRepo = await git.checkIsRepo(); + if (!isRepo) { + return null; + } + const root = await git.revparse(['--show-toplevel']); + return root.trim(); + } catch { + return null; + } +} + +/** + * Set a config value + */ +async function setConfigValue(key: string, value: string, global: boolean): Promise { + const configPath = global + ? join(homedir(), '.tangledrc') + : join((await getGitRoot()) || process.cwd(), '.tangledrc'); + + if (!global) { + const gitRoot = await getGitRoot(); + if (!gitRoot) { + throw new Error('Not in a Git repository. Use --global or run from a Git repository.'); + } + } + + // Load existing config + let config: TangledConfig = {}; + try { + const content = await readFile(configPath, 'utf-8'); + config = JSON.parse(content); + } catch { + // Config doesn't exist yet, start with empty object + } + + // Set the value + config[key as keyof TangledConfig] = value as never; + + // Write updated config + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); +} + +/** + * Unset a config value + */ +async function unsetConfigValue(key: string, global: boolean): Promise { + const configPath = global + ? join(homedir(), '.tangledrc') + : join((await getGitRoot()) || process.cwd(), '.tangledrc'); + + if (!global) { + const gitRoot = await getGitRoot(); + if (!gitRoot) { + throw new Error('Not in a Git repository. Use --global or run from a Git repository.'); + } + } + + // Load existing config + let config: TangledConfig = {}; + try { + const content = await readFile(configPath, 'utf-8'); + config = JSON.parse(content); + } catch { + // Config doesn't exist, nothing to unset + return; + } + + // Remove the key + delete config[key as keyof TangledConfig]; + + // If config is now empty, delete the file + if (Object.keys(config).length === 0) { + try { + await unlink(configPath); + } catch { + // File might not exist + } + } else { + // Write updated config + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); + } +} + +/** + * Available configuration keys with their descriptions + */ +const AVAILABLE_KEYS: Record = { + remote: 'Default Git remote to use when multiple tangled.org remotes exist', +}; + +/** + * Create the config command for managing Tangled CLI configuration + */ +export function createConfigCommand(): Command { + const config = new Command('config'); + config.description('Manage Tangled CLI configuration'); + + // List available config keys + config + .command('list') + .description('List all available configuration keys') + .action(async () => { + try { + const cfg = await loadConfig(); + + console.log('Available configuration keys:\n'); + for (const [key, description] of Object.entries(AVAILABLE_KEYS)) { + const value = cfg[key as keyof TangledConfig]; + const status = value ? `"${value}"` : '(not set)'; + console.log(` ${key}`); + console.log(` ${description}`); + console.log(` Current value: ${status}\n`); + } + } catch (error) { + console.error( + `Failed to list config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + // Get current config + config + .command('get [key]') + .description('Get configuration value (defaults to all)') + .action(async (key?: string) => { + try { + const cfg = await loadConfig(); + + if (!key) { + // Show all config values + const keys = Object.keys(cfg) as Array; + if (keys.length === 0) { + console.log('No configuration set'); + return; + } + for (const k of keys) { + console.log(`${k} = ${cfg[k] || '(not set)'}`); + } + } else { + // Show specific key + const value = cfg[key as keyof TangledConfig]; + console.log(`${key} = ${value || '(not set)'}`); + } + } catch (error) { + console.error( + `Failed to get config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + // Set config value + config + .command('set ') + .option('-g, --global', 'Save to user config instead of local') + .description('Set a configuration value') + .action(async (key: string, value: string, options: { global?: boolean }) => { + try { + await setConfigValue(key, value, options.global ?? false); + const scope = options.global ? 'user config (~/.tangledrc)' : 'local config (.tangledrc)'; + console.log(`✓ Set ${key} to "${value}" in ${scope}`); + } catch (error) { + console.error( + `Failed to set config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + // Unset config value + config + .command('unset ') + .option('-g, --global', 'Clear from user config instead of local') + .description('Clear a configuration value') + .action(async (key: string, options: { global?: boolean }) => { + try { + await unsetConfigValue(key, options.global ?? false); + const scope = options.global ? 'user config' : 'local config'; + console.log(`✓ Cleared ${key} from ${scope}`); + } catch (error) { + console.error( + `Failed to clear config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + return config; +} diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts new file mode 100644 index 0000000..2401ada --- /dev/null +++ b/tests/commands/config.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createConfigCommand } from '../../src/commands/config.js'; + +// Mock modules +vi.mock('node:fs/promises'); +vi.mock('simple-git'); +vi.mock('../../src/lib/config.js'); + +// Import mocked modules +import * as fs from 'node:fs/promises'; +import { simpleGit } from 'simple-git'; +import * as configModule from '../../src/lib/config.js'; + +describe('Config Command', () => { + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock console methods + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; + + // Mock process.exit to throw so tests don't actually exit + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + }); + + describe('list command', () => { + it('should list all available config keys with descriptions', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'list']); + + expect(consoleLogSpy).toHaveBeenCalledWith('Available configuration keys:\n'); + expect(consoleLogSpy).toHaveBeenCalledWith(' remote'); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Default Git remote to use') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Current value: "origin"') + ); + }); + + it('should show "(not set)" for unset keys', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({}); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'list']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Current value: (not set)') + ); + }); + }); + + describe('get command', () => { + it('should show all config values when no key specified', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'get']); + + expect(consoleLogSpy).toHaveBeenCalledWith('remote = origin'); + }); + + it('should show "No configuration set" when config is empty', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({}); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'get']); + + expect(consoleLogSpy).toHaveBeenCalledWith('No configuration set'); + }); + + it('should show specific key value', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'upstream' }); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'get', 'remote']); + + expect(consoleLogSpy).toHaveBeenCalledWith('remote = upstream'); + }); + + it('should show "(not set)" for undefined key', async () => { + vi.mocked(configModule.loadConfig).mockResolvedValue({}); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'get', 'remote']); + + expect(consoleLogSpy).toHaveBeenCalledWith('remote = (not set)'); + }); + }); + + describe('set command', () => { + it('should set local config value', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'set', 'remote', 'origin']); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/repo/.tangledrc', + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, + 'utf-8' + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Set remote to "origin" in local config') + ); + }); + + it('should set global config value with --global flag', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'set', 'remote', 'origin', '--global']); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.tangledrc'), + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, + 'utf-8' + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Set remote to "origin" in user config') + ); + }); + + it('should error when not in Git repo for local config', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + const config = createConfigCommand(); + await expect(config.parseAsync(['node', 'test', 'set', 'remote', 'origin'])).rejects.toThrow( + 'process.exit' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to set config')); + }); + + it('should preserve existing config values', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ remote: 'origin', other: 'value' }) + ); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'set', 'remote', 'upstream']); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/repo/.tangledrc', + `${JSON.stringify({ remote: 'upstream', other: 'value' }, null, 2)}\n`, + 'utf-8' + ); + }); + }); + + describe('unset command', () => { + it('should unset local config value', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'unset', 'remote']); + + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Cleared remote from local config') + ); + }); + + it('should unset global config value with --global flag', async () => { + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'unset', 'remote', '--global']); + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.tangledrc')); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Cleared remote from user config') + ); + }); + + it('should delete config file when last key is removed', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'unset', 'remote']); + + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); + }); + + it('should preserve other config values when unsetting one key', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ remote: 'origin', other: 'value' }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'unset', 'remote']); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/repo/.tangledrc', + `${JSON.stringify({ other: 'value' }, null, 2)}\n`, + 'utf-8' + ); + }); + + it('should handle unset when config does not exist', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const config = createConfigCommand(); + await config.parseAsync(['node', 'test', 'unset', 'remote']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Cleared remote from local config') + ); + }); + }); +}); -- 2.51.2