diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..53d738a --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,173 @@ +/** + * Configuration management for Tangled CLI + * Handles loading and saving configuration with proper precedence: + * 1. TANGLED_REMOTE environment variable + * 2. Local config (.tangledrc in current directory or Git root) + * 3. User config (~/.tangledrc or ~/.config/tangled/config) + * 4. System config (/etc/tangledrc) + */ + +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { cosmiconfig } from 'cosmiconfig'; +import { simpleGit } from 'simple-git'; + +export interface TangledConfig { + remote?: string; +} + +const MODULE_NAME = 'tangled'; + +/** + * Get the Git root directory for the current working directory + * @param cwd - Current working directory + * @returns Git root path or null if not in a Git repository + */ +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; + } +} + +/** + * Load configuration with proper precedence + * Checks: env var > local config > user config > system config + * @param cwd - Current working directory (defaults to process.cwd()) + * @returns Configuration object + */ +export async function loadConfig(cwd: string = process.cwd()): Promise { + // Check environment variable first + if (process.env.TANGLED_REMOTE) { + return { remote: process.env.TANGLED_REMOTE }; + } + + try { + const explorer = cosmiconfig(MODULE_NAME); + + // For local config, search from Git root if in a Git repo + const gitRoot = await getGitRoot(cwd); + const searchFrom = gitRoot || cwd; + + const result = await explorer.search(searchFrom); + + if (result && !result.isEmpty) { + return result.config as TangledConfig; + } + } catch (error) { + // Log warning but continue with empty config + console.warn( + `Warning: Failed to load config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + return {}; +} + +/** + * Get the configured remote name for the current context + * Returns null if no config found + * @param cwd - Current working directory + * @returns Remote name or null + */ +export async function getConfiguredRemote(cwd: string = process.cwd()): Promise { + const config = await loadConfig(cwd); + return config.remote || null; +} + +/** + * Set the remote name in local config (.tangledrc in Git root) + * @param remoteName - Name of the remote to use + * @param cwd - Current working directory + */ +export async function setLocalRemote( + remoteName: string, + cwd: string = process.cwd() +): Promise { + const gitRoot = await getGitRoot(cwd); + + if (!gitRoot) { + throw new Error('Not in a Git repository. Cannot set local config.'); + } + + const configPath = join(gitRoot, '.tangledrc'); + const config: TangledConfig = { remote: remoteName }; + + try { + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); + } catch (error) { + throw new Error( + `Failed to write local config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Set the remote name in user config (~/.tangledrc) + * @param remoteName - Name of the remote to use + */ +export async function setUserRemote(remoteName: string): Promise { + const configPath = join(homedir(), '.tangledrc'); + const config: TangledConfig = { remote: remoteName }; + + try { + // Ensure directory exists + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); + } catch (error) { + throw new Error( + `Failed to write user config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Clear configured remote from local config + * @param cwd - Current working directory + */ +export async function clearLocalRemote(cwd: string = process.cwd()): Promise { + const gitRoot = await getGitRoot(cwd); + + if (!gitRoot) { + throw new Error('Not in a Git repository. Cannot clear local config.'); + } + + const configPath = join(gitRoot, '.tangledrc'); + + try { + await unlink(configPath); + } catch (error) { + // If file doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error( + `Failed to delete local config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } +} + +/** + * Clear configured remote from user config + */ +export async function clearUserRemote(): Promise { + const configPath = join(homedir(), '.tangledrc'); + + try { + await unlink(configPath); + } catch (error) { + // If file doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error( + `Failed to delete user config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } +} diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 0000000..9b9803a --- /dev/null +++ b/tests/lib/config.test.ts @@ -0,0 +1,264 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearLocalRemote, + clearUserRemote, + getConfiguredRemote, + loadConfig, + setLocalRemote, + setUserRemote, +} from '../../src/lib/config.js'; + +// Mock modules +vi.mock('node:fs/promises'); +vi.mock('simple-git'); +vi.mock('cosmiconfig'); + +// Import mocked modules +import * as fs from 'node:fs/promises'; +import { cosmiconfig } from 'cosmiconfig'; +import { simpleGit } from 'simple-git'; + +describe('Config Management', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + originalEnv = process.env.TANGLED_REMOTE; + // biome-ignore lint/performance/noDelete: Need to actually delete env var, not set to undefined + delete process.env.TANGLED_REMOTE; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.TANGLED_REMOTE = originalEnv; + } else { + // biome-ignore lint/performance/noDelete: Need to actually delete env var, not set to undefined + delete process.env.TANGLED_REMOTE; + } + }); + + describe('loadConfig', () => { + it('should return config from TANGLED_REMOTE environment variable', async () => { + process.env.TANGLED_REMOTE = 'upstream'; + + const config = await loadConfig(); + + expect(config).toEqual({ remote: 'upstream' }); + }); + + it('should load config from file when env var not set', async () => { + const mockExplorer = { + search: vi.fn().mockResolvedValue({ + config: { remote: 'origin' }, + filepath: '/test/.tangledrc', + isEmpty: false, + }), + }; + + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); + + // Mock Git root + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(true), + revparse: vi.fn().mockResolvedValue('/test/repo\n'), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + const config = await loadConfig('/test/repo'); + + expect(config).toEqual({ remote: 'origin' }); + expect(mockExplorer.search).toHaveBeenCalledWith('/test/repo'); + }); + + it('should return empty config when no config file found', async () => { + const mockExplorer = { + search: vi.fn().mockResolvedValue(null), + }; + + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); + + // Mock Git root + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + const config = await loadConfig(); + + expect(config).toEqual({}); + }); + + it('should handle cosmiconfig errors gracefully', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mockExplorer = { + search: vi.fn().mockRejectedValue(new Error('Config read error')), + }; + + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); + + // Mock Git root + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + const config = await loadConfig(); + + expect(config).toEqual({}); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to load config')); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('getConfiguredRemote', () => { + it('should return remote name from config', async () => { + process.env.TANGLED_REMOTE = 'upstream'; + + const remote = await getConfiguredRemote(); + + expect(remote).toBe('upstream'); + }); + + it('should return null when no config found', async () => { + const mockExplorer = { + search: vi.fn().mockResolvedValue(null), + }; + + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); + + // Mock not in Git repo + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + const remote = await getConfiguredRemote(); + + expect(remote).toBeNull(); + }); + }); + + describe('setLocalRemote', () => { + it('should write config to Git root directory', 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.writeFile).mockResolvedValue(undefined); + + await setLocalRemote('origin', '/test/repo'); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/repo/.tangledrc', + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, + 'utf-8' + ); + }); + + it('should throw error when not in Git repository', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + await expect(setLocalRemote('origin')).rejects.toThrow('Not in a Git repository'); + }); + + it('should throw error on write failure', 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.writeFile).mockRejectedValue(new Error('Write failed')); + + await expect(setLocalRemote('origin')).rejects.toThrow('Failed to write local config'); + }); + }); + + describe('setUserRemote', () => { + it('should write config to user home directory', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await setUserRemote('origin'); + + const expectedPath = join(homedir(), '.tangledrc'); + expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPath, + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, + 'utf-8' + ); + }); + + it('should throw error on write failure', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed')); + + await expect(setUserRemote('origin')).rejects.toThrow('Failed to write user config'); + }); + }); + + describe('clearLocalRemote', () => { + it('should delete local config file', 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.unlink).mockResolvedValue(undefined); + + await clearLocalRemote('/test/repo'); + + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); + }); + + it('should not throw error if file 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); + + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.unlink).mockRejectedValue(error); + + await expect(clearLocalRemote()).resolves.not.toThrow(); + }); + + it('should throw error when not in Git repository', async () => { + const mockGit = { + checkIsRepo: vi.fn().mockResolvedValue(false), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as never); + + await expect(clearLocalRemote()).rejects.toThrow('Not in a Git repository'); + }); + }); + + describe('clearUserRemote', () => { + it('should delete user config file', async () => { + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await clearUserRemote(); + + const expectedPath = join(homedir(), '.tangledrc'); + expect(fs.unlink).toHaveBeenCalledWith(expectedPath); + }); + + it('should not throw error if file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.unlink).mockRejectedValue(error); + + await expect(clearUserRemote()).resolves.not.toThrow(); + }); + }); +});