WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement configuration management with precedence

Add config system for persisting user preferences:
- Load config with precedence: TANGLED_REMOTE env var > local
.tangledrc > user ~/.tangledrc > system /etc/tangledrc
- Uses cosmiconfig for flexible config file support
- setLocalRemote/setUserRemote: Save remote selection
- clearLocalRemote/clearUserRemote: Clear saved remote
- Integrates with Git to save local config in repository root

This allows users to configure their preferred remote once and have
it automatically selected for ambiguous cases.

Includes comprehensive test coverage (16 tests) for all precedence
scenarios and error cases.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+437
+173
src/lib/config.ts
··· 1 + /** 2 + * Configuration management for Tangled CLI 3 + * Handles loading and saving configuration with proper precedence: 4 + * 1. TANGLED_REMOTE environment variable 5 + * 2. Local config (.tangledrc in current directory or Git root) 6 + * 3. User config (~/.tangledrc or ~/.config/tangled/config) 7 + * 4. System config (/etc/tangledrc) 8 + */ 9 + 10 + import { mkdir, unlink, writeFile } from 'node:fs/promises'; 11 + import { homedir } from 'node:os'; 12 + import { dirname, join } from 'node:path'; 13 + import { cosmiconfig } from 'cosmiconfig'; 14 + import { simpleGit } from 'simple-git'; 15 + 16 + export interface TangledConfig { 17 + remote?: string; 18 + } 19 + 20 + const MODULE_NAME = 'tangled'; 21 + 22 + /** 23 + * Get the Git root directory for the current working directory 24 + * @param cwd - Current working directory 25 + * @returns Git root path or null if not in a Git repository 26 + */ 27 + async function getGitRoot(cwd: string = process.cwd()): Promise<string | null> { 28 + try { 29 + const git = simpleGit(cwd); 30 + const isRepo = await git.checkIsRepo(); 31 + if (!isRepo) { 32 + return null; 33 + } 34 + const root = await git.revparse(['--show-toplevel']); 35 + return root.trim(); 36 + } catch { 37 + return null; 38 + } 39 + } 40 + 41 + /** 42 + * Load configuration with proper precedence 43 + * Checks: env var > local config > user config > system config 44 + * @param cwd - Current working directory (defaults to process.cwd()) 45 + * @returns Configuration object 46 + */ 47 + export async function loadConfig(cwd: string = process.cwd()): Promise<TangledConfig> { 48 + // Check environment variable first 49 + if (process.env.TANGLED_REMOTE) { 50 + return { remote: process.env.TANGLED_REMOTE }; 51 + } 52 + 53 + try { 54 + const explorer = cosmiconfig(MODULE_NAME); 55 + 56 + // For local config, search from Git root if in a Git repo 57 + const gitRoot = await getGitRoot(cwd); 58 + const searchFrom = gitRoot || cwd; 59 + 60 + const result = await explorer.search(searchFrom); 61 + 62 + if (result && !result.isEmpty) { 63 + return result.config as TangledConfig; 64 + } 65 + } catch (error) { 66 + // Log warning but continue with empty config 67 + console.warn( 68 + `Warning: Failed to load config: ${error instanceof Error ? error.message : 'Unknown error'}` 69 + ); 70 + } 71 + 72 + return {}; 73 + } 74 + 75 + /** 76 + * Get the configured remote name for the current context 77 + * Returns null if no config found 78 + * @param cwd - Current working directory 79 + * @returns Remote name or null 80 + */ 81 + export async function getConfiguredRemote(cwd: string = process.cwd()): Promise<string | null> { 82 + const config = await loadConfig(cwd); 83 + return config.remote || null; 84 + } 85 + 86 + /** 87 + * Set the remote name in local config (.tangledrc in Git root) 88 + * @param remoteName - Name of the remote to use 89 + * @param cwd - Current working directory 90 + */ 91 + export async function setLocalRemote( 92 + remoteName: string, 93 + cwd: string = process.cwd() 94 + ): Promise<void> { 95 + const gitRoot = await getGitRoot(cwd); 96 + 97 + if (!gitRoot) { 98 + throw new Error('Not in a Git repository. Cannot set local config.'); 99 + } 100 + 101 + const configPath = join(gitRoot, '.tangledrc'); 102 + const config: TangledConfig = { remote: remoteName }; 103 + 104 + try { 105 + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); 106 + } catch (error) { 107 + throw new Error( 108 + `Failed to write local config: ${error instanceof Error ? error.message : 'Unknown error'}` 109 + ); 110 + } 111 + } 112 + 113 + /** 114 + * Set the remote name in user config (~/.tangledrc) 115 + * @param remoteName - Name of the remote to use 116 + */ 117 + export async function setUserRemote(remoteName: string): Promise<void> { 118 + const configPath = join(homedir(), '.tangledrc'); 119 + const config: TangledConfig = { remote: remoteName }; 120 + 121 + try { 122 + // Ensure directory exists 123 + await mkdir(dirname(configPath), { recursive: true }); 124 + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); 125 + } catch (error) { 126 + throw new Error( 127 + `Failed to write user config: ${error instanceof Error ? error.message : 'Unknown error'}` 128 + ); 129 + } 130 + } 131 + 132 + /** 133 + * Clear configured remote from local config 134 + * @param cwd - Current working directory 135 + */ 136 + export async function clearLocalRemote(cwd: string = process.cwd()): Promise<void> { 137 + const gitRoot = await getGitRoot(cwd); 138 + 139 + if (!gitRoot) { 140 + throw new Error('Not in a Git repository. Cannot clear local config.'); 141 + } 142 + 143 + const configPath = join(gitRoot, '.tangledrc'); 144 + 145 + try { 146 + await unlink(configPath); 147 + } catch (error) { 148 + // If file doesn't exist, that's fine 149 + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 150 + throw new Error( 151 + `Failed to delete local config: ${error instanceof Error ? error.message : 'Unknown error'}` 152 + ); 153 + } 154 + } 155 + } 156 + 157 + /** 158 + * Clear configured remote from user config 159 + */ 160 + export async function clearUserRemote(): Promise<void> { 161 + const configPath = join(homedir(), '.tangledrc'); 162 + 163 + try { 164 + await unlink(configPath); 165 + } catch (error) { 166 + // If file doesn't exist, that's fine 167 + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 168 + throw new Error( 169 + `Failed to delete user config: ${error instanceof Error ? error.message : 'Unknown error'}` 170 + ); 171 + } 172 + } 173 + }
+264
tests/lib/config.test.ts
··· 1 + import { homedir } from 'node:os'; 2 + import { join } from 'node:path'; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 4 + import { 5 + clearLocalRemote, 6 + clearUserRemote, 7 + getConfiguredRemote, 8 + loadConfig, 9 + setLocalRemote, 10 + setUserRemote, 11 + } from '../../src/lib/config.js'; 12 + 13 + // Mock modules 14 + vi.mock('node:fs/promises'); 15 + vi.mock('simple-git'); 16 + vi.mock('cosmiconfig'); 17 + 18 + // Import mocked modules 19 + import * as fs from 'node:fs/promises'; 20 + import { cosmiconfig } from 'cosmiconfig'; 21 + import { simpleGit } from 'simple-git'; 22 + 23 + describe('Config Management', () => { 24 + let originalEnv: string | undefined; 25 + 26 + beforeEach(() => { 27 + vi.clearAllMocks(); 28 + originalEnv = process.env.TANGLED_REMOTE; 29 + // biome-ignore lint/performance/noDelete: Need to actually delete env var, not set to undefined 30 + delete process.env.TANGLED_REMOTE; 31 + }); 32 + 33 + afterEach(() => { 34 + if (originalEnv !== undefined) { 35 + process.env.TANGLED_REMOTE = originalEnv; 36 + } else { 37 + // biome-ignore lint/performance/noDelete: Need to actually delete env var, not set to undefined 38 + delete process.env.TANGLED_REMOTE; 39 + } 40 + }); 41 + 42 + describe('loadConfig', () => { 43 + it('should return config from TANGLED_REMOTE environment variable', async () => { 44 + process.env.TANGLED_REMOTE = 'upstream'; 45 + 46 + const config = await loadConfig(); 47 + 48 + expect(config).toEqual({ remote: 'upstream' }); 49 + }); 50 + 51 + it('should load config from file when env var not set', async () => { 52 + const mockExplorer = { 53 + search: vi.fn().mockResolvedValue({ 54 + config: { remote: 'origin' }, 55 + filepath: '/test/.tangledrc', 56 + isEmpty: false, 57 + }), 58 + }; 59 + 60 + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); 61 + 62 + // Mock Git root 63 + const mockGit = { 64 + checkIsRepo: vi.fn().mockResolvedValue(true), 65 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 66 + }; 67 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 68 + 69 + const config = await loadConfig('/test/repo'); 70 + 71 + expect(config).toEqual({ remote: 'origin' }); 72 + expect(mockExplorer.search).toHaveBeenCalledWith('/test/repo'); 73 + }); 74 + 75 + it('should return empty config when no config file found', async () => { 76 + const mockExplorer = { 77 + search: vi.fn().mockResolvedValue(null), 78 + }; 79 + 80 + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); 81 + 82 + // Mock Git root 83 + const mockGit = { 84 + checkIsRepo: vi.fn().mockResolvedValue(false), 85 + }; 86 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 87 + 88 + const config = await loadConfig(); 89 + 90 + expect(config).toEqual({}); 91 + }); 92 + 93 + it('should handle cosmiconfig errors gracefully', async () => { 94 + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 95 + 96 + const mockExplorer = { 97 + search: vi.fn().mockRejectedValue(new Error('Config read error')), 98 + }; 99 + 100 + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); 101 + 102 + // Mock Git root 103 + const mockGit = { 104 + checkIsRepo: vi.fn().mockResolvedValue(false), 105 + }; 106 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 107 + 108 + const config = await loadConfig(); 109 + 110 + expect(config).toEqual({}); 111 + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to load config')); 112 + 113 + consoleWarnSpy.mockRestore(); 114 + }); 115 + }); 116 + 117 + describe('getConfiguredRemote', () => { 118 + it('should return remote name from config', async () => { 119 + process.env.TANGLED_REMOTE = 'upstream'; 120 + 121 + const remote = await getConfiguredRemote(); 122 + 123 + expect(remote).toBe('upstream'); 124 + }); 125 + 126 + it('should return null when no config found', async () => { 127 + const mockExplorer = { 128 + search: vi.fn().mockResolvedValue(null), 129 + }; 130 + 131 + vi.mocked(cosmiconfig).mockReturnValue(mockExplorer as never); 132 + 133 + // Mock not in Git repo 134 + const mockGit = { 135 + checkIsRepo: vi.fn().mockResolvedValue(false), 136 + }; 137 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 138 + 139 + const remote = await getConfiguredRemote(); 140 + 141 + expect(remote).toBeNull(); 142 + }); 143 + }); 144 + 145 + describe('setLocalRemote', () => { 146 + it('should write config to Git root directory', async () => { 147 + const mockGit = { 148 + checkIsRepo: vi.fn().mockResolvedValue(true), 149 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 150 + }; 151 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 152 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 153 + 154 + await setLocalRemote('origin', '/test/repo'); 155 + 156 + expect(fs.writeFile).toHaveBeenCalledWith( 157 + '/test/repo/.tangledrc', 158 + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 159 + 'utf-8' 160 + ); 161 + }); 162 + 163 + it('should throw error when not in Git repository', async () => { 164 + const mockGit = { 165 + checkIsRepo: vi.fn().mockResolvedValue(false), 166 + }; 167 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 168 + 169 + await expect(setLocalRemote('origin')).rejects.toThrow('Not in a Git repository'); 170 + }); 171 + 172 + it('should throw error on write failure', async () => { 173 + const mockGit = { 174 + checkIsRepo: vi.fn().mockResolvedValue(true), 175 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 176 + }; 177 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 178 + vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed')); 179 + 180 + await expect(setLocalRemote('origin')).rejects.toThrow('Failed to write local config'); 181 + }); 182 + }); 183 + 184 + describe('setUserRemote', () => { 185 + it('should write config to user home directory', async () => { 186 + vi.mocked(fs.mkdir).mockResolvedValue(undefined); 187 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 188 + 189 + await setUserRemote('origin'); 190 + 191 + const expectedPath = join(homedir(), '.tangledrc'); 192 + expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true }); 193 + expect(fs.writeFile).toHaveBeenCalledWith( 194 + expectedPath, 195 + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 196 + 'utf-8' 197 + ); 198 + }); 199 + 200 + it('should throw error on write failure', async () => { 201 + vi.mocked(fs.mkdir).mockResolvedValue(undefined); 202 + vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed')); 203 + 204 + await expect(setUserRemote('origin')).rejects.toThrow('Failed to write user config'); 205 + }); 206 + }); 207 + 208 + describe('clearLocalRemote', () => { 209 + it('should delete local config file', async () => { 210 + const mockGit = { 211 + checkIsRepo: vi.fn().mockResolvedValue(true), 212 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 213 + }; 214 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 215 + vi.mocked(fs.unlink).mockResolvedValue(undefined); 216 + 217 + await clearLocalRemote('/test/repo'); 218 + 219 + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); 220 + }); 221 + 222 + it('should not throw error if file does not exist', async () => { 223 + const mockGit = { 224 + checkIsRepo: vi.fn().mockResolvedValue(true), 225 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 226 + }; 227 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 228 + 229 + const error = new Error('File not found') as NodeJS.ErrnoException; 230 + error.code = 'ENOENT'; 231 + vi.mocked(fs.unlink).mockRejectedValue(error); 232 + 233 + await expect(clearLocalRemote()).resolves.not.toThrow(); 234 + }); 235 + 236 + it('should throw error when not in Git repository', async () => { 237 + const mockGit = { 238 + checkIsRepo: vi.fn().mockResolvedValue(false), 239 + }; 240 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 241 + 242 + await expect(clearLocalRemote()).rejects.toThrow('Not in a Git repository'); 243 + }); 244 + }); 245 + 246 + describe('clearUserRemote', () => { 247 + it('should delete user config file', async () => { 248 + vi.mocked(fs.unlink).mockResolvedValue(undefined); 249 + 250 + await clearUserRemote(); 251 + 252 + const expectedPath = join(homedir(), '.tangledrc'); 253 + expect(fs.unlink).toHaveBeenCalledWith(expectedPath); 254 + }); 255 + 256 + it('should not throw error if file does not exist', async () => { 257 + const error = new Error('File not found') as NodeJS.ErrnoException; 258 + error.code = 'ENOENT'; 259 + vi.mocked(fs.unlink).mockRejectedValue(error); 260 + 261 + await expect(clearUserRemote()).resolves.not.toThrow(); 262 + }); 263 + }); 264 + });