import { existsSync } from "fs"; import { join } from "path"; import { mkdir } from "fs/promises"; import { homedir } from "os"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { GitforestConfigSchema, type GitforestConfig } from "../types/index.ts"; /** * Environment variable mappings */ interface EnvMapping { env: string; path: string[]; transform?: (value: string) => unknown; } const ENV_MAPPINGS: EnvMapping[] = [ // GitHub settings { env: "GITFOREST_GITHUB_VISIBILITY", path: ["github", "defaultVisibility"], transform: (v) => v }, // Scan settings { env: "GITFOREST_CONCURRENCY", path: ["scan", "concurrency"], transform: (v) => parseInt(v, 10) }, { env: "GITFOREST_INCLUDE_HIDDEN", path: ["scan", "includeHidden"], transform: (v) => v === "true" }, // Display settings { env: "GITFOREST_SORT_BY", path: ["display", "sortBy"], transform: (v) => v }, { env: "GITFOREST_SORT_DIR", path: ["display", "sortDirection"], transform: (v) => v }, { env: "GITFOREST_SHOW_SUBMODULES", path: ["display", "showSubmodules"], transform: (v) => v === "true" }, // Cache settings { env: "GITFOREST_CACHE_TTL", path: ["cache", "ttlSeconds"], transform: (v) => parseInt(v, 10) }, { env: "GITFOREST_GITHUB_CACHE_TTL", path: ["cache", "githubTtlSeconds"], transform: (v) => parseInt(v, 10) }, { env: "GITFOREST_ENABLE_BACKGROUND_REFRESH", path: ["cache", "enableBackgroundRefresh"], transform: (v) => v === "true" }, { env: "GITFOREST_BACKGROUND_REFRESH_INTERVAL", path: ["cache", "backgroundRefreshIntervalSeconds"], transform: (v) => parseInt(v, 10) }, ]; /** * Set a nested value in an object using a path array */ function setNestedValue(obj: Record, path: string[], value: unknown): void { let current = obj; for (let i = 0; i < path.length - 1; i++) { const key = path[i]!; if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { current[key] = {}; } current = current[key] as Record; } const lastKey = path[path.length - 1]!; current[lastKey] = value; } /** * Apply environment variable overrides to config * Environment variables take precedence over file-based config */ export function applyEnvOverrides(config: GitforestConfig): GitforestConfig { const result = JSON.parse(JSON.stringify(config)) as GitforestConfig; for (const mapping of ENV_MAPPINGS) { const envValue = process.env[mapping.env]; if (envValue !== undefined && envValue !== "") { const transformedValue = mapping.transform ? mapping.transform(envValue) : envValue; setNestedValue(result as unknown as Record, mapping.path, transformedValue); } } return result; } /** * Get list of supported environment variables */ export function getSupportedEnvVars(): { env: string; path: string; description: string }[] { return ENV_MAPPINGS.map((m) => ({ env: m.env, path: m.path.join("."), description: `Override ${m.path.join(".")} config value`, })); } /** * Get possible config file locations in order of priority */ function getConfigPaths(cwd?: string): string[] { const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); const home = homedir(); const workingDir = cwd ?? process.cwd(); return [ // Current directory join(workingDir, "gitforest.config.yaml"), join(workingDir, "gitforest.config.yml"), join(workingDir, "gitforest.config.json"), join(workingDir, ".gitforest.yaml"), join(workingDir, ".gitforest.yml"), join(workingDir, ".gitforest.json"), // XDG config directory join(xdg, "gitforest", "config.yaml"), join(xdg, "gitforest", "config.yml"), join(xdg, "gitforest", "config.json"), // Home directory join(home, ".gitforest.yaml"), join(home, ".gitforest.yml"), join(home, ".gitforest.json"), ]; } /** * Find the first existing config file */ export function findConfigPath(cwd?: string): string | null { for (const path of getConfigPaths(cwd)) { if (existsSync(path)) { return path; } } return null; } /** * Load and validate configuration from file */ export async function loadConfig(configPath?: string, cwd?: string): Promise { const path = configPath ?? findConfigPath(cwd); if (!path) { const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); throw new Error( `No config file found. Create one at:\n` + ` - ${join(xdg, "gitforest", "config.yaml")}\n\n` + `Example:\n` + `directories:\n` + ` - path: ~/projects\n` + ` maxDepth: 2\n` + ` - path: ~/.dotfiles\n` + ` maxDepth: 3\n` ); } const file = Bun.file(path); const content = await file.text(); let parsed: unknown; if (path.endsWith(".yaml") || path.endsWith(".yml")) { parsed = parseYaml(content); } else if (path.endsWith(".json")) { parsed = JSON.parse(content); } else { throw new Error(`Unknown config file format: ${path}`); } const result = GitforestConfigSchema.safeParse(parsed); if (!result.success) { const issues = result.error.issues .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) .join("\n"); throw new Error(`Invalid config file at ${path}:\n${issues}`); } // Expand ~ in directory paths let config = result.data; config.directories = config.directories.map((dir) => ({ ...dir, path: dir.path.replace(/^~/, homedir()), })); // Apply environment variable overrides, then re-validate so an out-of-range // GITFOREST_CONCURRENCY etc. doesn't smuggle invalid values into the runtime. config = applyEnvOverrides(config); const reparsed = GitforestConfigSchema.safeParse(config); if (!reparsed.success) { const issues = reparsed.error.issues .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) .join("\n"); throw new Error(`Invalid environment variable override:\n${issues}`); } return reparsed.data; } /** * Get the default config path for creating a new config */ export function getDefaultConfigPath(): string { const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); return join(xdg, "gitforest", "config.yaml"); } /** * Create a default config file */ export async function createDefaultConfig(): Promise { const configPath = getDefaultConfigPath(); const configDir = join(configPath, ".."); // Ensure directory exists await mkdir(configDir, { recursive: true }); const defaultConfig = `# Gitforest Configuration # Directories to scan for projects directories: - path: ~/projects maxDepth: 2 label: Projects # Optional: Add more directories # - path: ~/.dotfiles # maxDepth: 3 # label: Dotfiles # Scan settings scan: ignore: - node_modules - .git - vendor - __pycache__ - target - dist - build includeHidden: false concurrency: 5 # GitHub settings github: defaultVisibility: private # Display settings display: showSubmodules: true sortBy: status # name | status | lastActivity sortDirection: desc # Cache settings cache: ttlSeconds: 300 # 5 minutes for local projects githubTtlSeconds: 600 # 10 minutes for GitHub repos enableBackgroundRefresh: true # Refresh data in background backgroundRefreshIntervalSeconds: 300 # 5 minutes `; await Bun.write(configPath, defaultConfig); return configPath; } /** * Save configuration to file */ export async function saveConfig(config: GitforestConfig, path?: string): Promise { const configPath = path ?? findConfigPath() ?? getDefaultConfigPath(); // Create backup of existing config if it exists if (existsSync(configPath)) { const backupPath = `${configPath}.bak`; await Bun.write(backupPath, await Bun.file(configPath).text()); } let content: string; if (configPath.endsWith(".json")) { content = JSON.stringify(config, null, 2); } else { content = stringifyYaml(config); } await Bun.write(configPath, content); }