import { resolve } from "path"; import { existsSync, statSync } from "fs"; import { homedir } from "os"; import { saveConfig, findConfigPath } from "../../config/loader.ts"; import { formatError, formatInfo, formatSuccess, formatWarning, } from "../formatters.ts"; import type { CLIOptions } from "./list.ts"; import { DirectoryConfigSchema, type DirectoryConfig, type GitforestConfig } from "../../types/index.ts"; export interface DirOptions extends CLIOptions { label?: string; editor?: string; } /** * Handle `gitforest dir ` commands */ export async function handleDirCommand( subcommand: string, args: string[], options: DirOptions ): Promise { switch (subcommand) { case "list": case "ls": listDirs(options); break; case "add": await addDir(args, options); break; case "remove": case "rm": await removeDir(args, options); break; case "set": await setDir(args, options); break; default: console.error(formatError(`Unknown dir subcommand: ${subcommand}`)); console.log(formatInfo("Available commands: list, add, remove, set")); console.log(formatInfo("\nUsage:")); console.log(formatInfo(" gitforest dir list List configured directories")); console.log(formatInfo(" gitforest dir add [--max-depth N] Add a directory")); console.log(formatInfo(" gitforest dir remove Remove a directory")); console.log(formatInfo(" gitforest dir set [options] Update directory settings")); process.exit(1); } } /** * Resolve a user-provided path, expanding ~ and making absolute * Validates against basic injection attempts (null bytes) */ function resolveDirPath(rawPath: string): { path: string; error?: string } { // Check for null bytes (potential path traversal attack) if (rawPath.includes("\0")) { return { path: "", error: "Path contains invalid null bytes" }; } // Expand ~ to home directory const expanded = rawPath.replace(/^~/, homedir()); // Resolve to absolute path const absolute = resolve(expanded); return { path: absolute }; } /** * Find a directory in the config by resolved path */ function findDirIndex(config: GitforestConfig, absPath: string): number { return config.directories.findIndex((d) => { const dPath = d.path.replace(/^~/, homedir()); return resolve(dPath) === absPath; }); } function parseDirectoryIndex(arg: string, directoryCount: number): number | null { if (!/^[1-9]\d*$/.test(arg)) return null; const indexNum = Number(arg); if (indexNum < 1 || indexNum > directoryCount) return null; return indexNum - 1; } /** * Format a single directory entry for display */ function formatDirEntry(dir: DirectoryConfig, index: number): string { const parts = [` ${index + 1}. ${dir.path}`]; const meta: string[] = []; meta.push(`depth: ${dir.maxDepth}`); if (dir.label) meta.push(`label: ${dir.label}`); if (dir.editor) meta.push(`editor: ${dir.editor}`); if (meta.length > 0) { parts.push(` (${meta.join(", ")})`); } return parts.join("\n"); } // ============================================================================ // Subcommands // ============================================================================ /** * List all configured directories */ export function listDirs(options: CLIOptions): void { const { config, json } = options; if (json) { console.log(JSON.stringify(config.directories, null, 2)); return; } if (config.directories.length === 0) { console.log(formatWarning("No directories configured.")); console.log(formatInfo("Run 'gitforest dir add ' to add one.")); return; } console.log(formatInfo(`\nConfigured directories (${config.directories.length}):\n`)); for (let i = 0; i < config.directories.length; i++) { const dir = config.directories[i]!; console.log(formatDirEntry(dir, i)); } const configPath = findConfigPath(); if (configPath) { console.log(`\n Config: ${configPath}`); } } /** * Add a directory to the config */ export async function addDir(args: string[], options: DirOptions): Promise { const { config } = options; if (args.length === 0) { console.error(formatError("Missing directory path")); console.log(formatInfo("Usage: gitforest dir add [--max-depth N] [--label TEXT]")); process.exit(1); } const rawPath = args[0]!; const resolved = resolveDirPath(rawPath); // Check for path validation errors if (resolved.error) { console.error(formatError(resolved.error)); process.exit(1); } const absPath = resolved.path; // Validate path exists and is a directory if (!existsSync(absPath)) { console.error(formatError(`Directory does not exist: ${absPath}`)); process.exit(1); } if (!statSync(absPath).isDirectory()) { console.error(formatError(`Not a directory: ${absPath}`)); process.exit(1); } // Check if directory is already in config const existingIndex = findDirIndex(config, absPath); if (existingIndex >= 0) { console.log(formatWarning(`Directory already configured: ${absPath}`)); console.log(formatInfo("Use 'gitforest dir set' to update its settings.")); return; } const candidate: Record = { path: absPath, maxDepth: options.maxDepth ?? 2, }; if (options.label) candidate.label = options.label; const parsed = DirectoryConfigSchema.safeParse(candidate); if (!parsed.success) { const issues = parsed.error.issues .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) .join("\n"); console.error(formatError(`Invalid directory config:\n${issues}`)); process.exit(1); } const newDir: DirectoryConfig = parsed.data; config.directories.push(newDir); await persistConfig(config); console.log(formatSuccess(`Added directory: ${absPath} (max-depth: ${newDir.maxDepth})`)); } /** * Remove a directory from the config */ export async function removeDir(args: string[], options: DirOptions): Promise { const { config } = options; if (args.length === 0) { console.error(formatError("Missing directory path or index")); console.log(formatInfo("Usage: gitforest dir remove ")); console.log(formatInfo(" Use 'gitforest dir list' to see directories and their indices.")); process.exit(1); } const arg = args[0]!; const parsedIndex = parseDirectoryIndex(arg, config.directories.length); let removeIndex: number; if (parsedIndex !== null) { removeIndex = parsedIndex; } else { // Interpret as path const resolved = resolveDirPath(arg); if (resolved.error) { console.error(formatError(resolved.error)); process.exit(1); } removeIndex = findDirIndex(config, resolved.path); if (removeIndex < 0) { console.error(formatError(`Directory not found in config: ${resolved.path}`)); console.log(formatInfo("Run 'gitforest dir list' to see configured directories.")); process.exit(1); } } // Prevent removing the last directory if (config.directories.length === 1) { console.error(formatError("Cannot remove the last directory. At least one directory is required.")); process.exit(1); } const removed = config.directories[removeIndex]!; config.directories.splice(removeIndex, 1); await persistConfig(config); console.log(formatSuccess(`Removed directory: ${removed.path}`)); } /** * Update settings for an existing directory */ export async function setDir(args: string[], options: DirOptions): Promise { const { config } = options; if (args.length === 0) { console.error(formatError("Missing directory path or index")); console.log(formatInfo("Usage: gitforest dir set [--max-depth N] [--label TEXT] [--editor CMD]")); process.exit(1); } const arg = args[0]!; const parsedIndex = parseDirectoryIndex(arg, config.directories.length); let dirIndex: number; if (parsedIndex !== null) { dirIndex = parsedIndex; } else { const resolved = resolveDirPath(arg); if (resolved.error) { console.error(formatError(resolved.error)); process.exit(1); } dirIndex = findDirIndex(config, resolved.path); if (dirIndex < 0) { console.error(formatError(`Directory not found in config: ${resolved.path}`)); console.log(formatInfo("Run 'gitforest dir list' to see configured directories.")); process.exit(1); } } const dir = config.directories[dirIndex]!; const updated = { ...dir }; let didUpdate = false; if (options.maxDepth !== undefined) { updated.maxDepth = options.maxDepth; didUpdate = true; } if (options.label !== undefined) { updated.label = options.label; didUpdate = true; } if (options.editor !== undefined) { updated.editor = options.editor; didUpdate = true; } if (!didUpdate) { console.log(formatWarning("No options specified. Nothing to update.")); console.log(formatInfo("Options: --max-depth N, --label TEXT, --editor CMD")); return; } // Re-validate so a bad CLI value (e.g. NaN, out-of-range maxDepth) doesn't // get persisted into the user's config. const parsed = DirectoryConfigSchema.safeParse(updated); if (!parsed.success) { const issues = parsed.error.issues .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) .join("\n"); console.error(formatError(`Invalid directory config:\n${issues}`)); process.exit(1); } config.directories[dirIndex] = parsed.data; Object.assign(dir, parsed.data); await persistConfig(config); console.log(formatSuccess(`Updated directory: ${dir.path}`)); console.log(formatDirEntry(dir, dirIndex)); } // ============================================================================ // Helpers // ============================================================================ /** * Save the config and print any errors */ async function persistConfig(config: GitforestConfig): Promise { const configPath = findConfigPath() ?? undefined; await saveConfig(config, configPath); }