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') + ); + }); + }); +});