From ede75f2ded39b40f92efba5efb59f982c5c0d44c Mon Sep 17 00:00:00 2001 From: Timm Stokke Date: Mon, 29 Dec 2025 21:11:14 -0800 Subject: [PATCH 1/3] refactor: add shared path and version utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract common utilities to src/shared/pathUtils.ts: - expandTilde: tilde expansion for paths - parseVersion: parse semver strings to arrays - compareVersions: compare version strings (1/-1/0) These consolidate duplicated logic found across multiple files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/__tests__/shared/pathUtils.test.ts | 167 +++++++++++++++++++++++++ src/shared/index.ts | 1 + src/shared/pathUtils.ts | 105 ++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 src/__tests__/shared/pathUtils.test.ts create mode 100644 src/shared/pathUtils.ts diff --git a/src/__tests__/shared/pathUtils.test.ts b/src/__tests__/shared/pathUtils.test.ts new file mode 100644 index 00000000..54b60beb --- /dev/null +++ b/src/__tests__/shared/pathUtils.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for shared path and version utility functions + * + * @file src/shared/pathUtils.ts + * + * These utilities consolidate duplicated logic found across: + * - agent-detector.ts (expandTilde) + * - ssh-command-builder.ts (expandPath) + * - ssh-config-parser.ts (expandPath) + * - ssh-remote-manager.ts (expandPath) + * - process-manager.ts (inline tilde expansion) + * - update-checker.ts (version comparison) + */ + +import { describe, it, expect, vi } from 'vitest'; +import * as os from 'os'; +import { expandTilde, parseVersion, compareVersions } from '../../shared/pathUtils'; + +// Mock os.homedir for consistent test behavior +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + homedir: vi.fn(() => '/Users/testuser'), + }; +}); + +describe('expandTilde', () => { + describe('basic tilde expansion', () => { + it('should expand ~/path to home directory + path', () => { + expect(expandTilde('~/Documents')).toBe('/Users/testuser/Documents'); + }); + + it('should expand ~ alone to home directory', () => { + expect(expandTilde('~')).toBe('/Users/testuser'); + }); + + it('should expand ~/path/to/file correctly', () => { + expect(expandTilde('~/.ssh/id_rsa')).toBe('/Users/testuser/.ssh/id_rsa'); + }); + + it('should preserve paths without tilde', () => { + expect(expandTilde('/absolute/path')).toBe('/absolute/path'); + expect(expandTilde('relative/path')).toBe('relative/path'); + expect(expandTilde('./local/path')).toBe('./local/path'); + }); + + it('should not expand tilde in middle of path', () => { + expect(expandTilde('/path/with~tilde')).toBe('/path/with~tilde'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(expandTilde('')).toBe(''); + }); + + it('should handle paths with spaces', () => { + expect(expandTilde('~/My Documents/file.txt')).toBe('/Users/testuser/My Documents/file.txt'); + }); + + it('should handle deeply nested paths', () => { + expect(expandTilde('~/.local/share/fnm/node-versions/v20.0.0/installation/bin')) + .toBe('/Users/testuser/.local/share/fnm/node-versions/v20.0.0/installation/bin'); + }); + }); + + describe('cross-platform consistency', () => { + it('should handle Windows-style home (when provided)', () => { + vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser'); + const result = expandTilde('~/.config'); + expect(result).toContain('testuser'); + expect(result).toContain('.config'); + }); + }); +}); + +describe('parseVersion', () => { + it('should parse version with v prefix', () => { + expect(parseVersion('v22.10.0')).toEqual([22, 10, 0]); + }); + + it('should parse version without v prefix', () => { + expect(parseVersion('0.14.0')).toEqual([0, 14, 0]); + }); + + it('should handle single digit versions', () => { + expect(parseVersion('v8.0.0')).toEqual([8, 0, 0]); + }); + + it('should handle versions with more than 3 parts', () => { + expect(parseVersion('1.2.3.4')).toEqual([1, 2, 3, 4]); + }); + + it('should handle non-numeric parts as 0', () => { + expect(parseVersion('1.beta.3')).toEqual([1, 0, 3]); + }); +}); + +describe('compareVersions', () => { + describe('basic version comparison', () => { + it('should return 1 when a > b', () => { + expect(compareVersions('v22.0.0', 'v20.0.0')).toBe(1); + }); + + it('should return -1 when a < b', () => { + expect(compareVersions('v20.0.0', 'v22.0.0')).toBe(-1); + }); + + it('should return 0 for equal versions', () => { + expect(compareVersions('v20.10.0', 'v20.10.0')).toBe(0); + }); + }); + + describe('minor version comparison', () => { + it('should compare by minor version when major is equal', () => { + expect(compareVersions('v20.11.0', 'v20.10.0')).toBe(1); + expect(compareVersions('v20.10.0', 'v20.11.0')).toBe(-1); + }); + + it('should handle v18.20 vs v18.2 correctly (20 > 2)', () => { + expect(compareVersions('v18.20.0', 'v18.2.0')).toBe(1); + }); + }); + + describe('patch version comparison', () => { + it('should compare by patch when major and minor are equal', () => { + expect(compareVersions('v20.10.5', 'v20.10.3')).toBe(1); + expect(compareVersions('v20.10.3', 'v20.10.5')).toBe(-1); + }); + }); + + describe('array sorting', () => { + it('should sort ascending when used directly', () => { + const versions = ['v22.21.0', 'v18.17.0', 'v20.10.0']; + const sorted = [...versions].sort(compareVersions); + expect(sorted).toEqual(['v18.17.0', 'v20.10.0', 'v22.21.0']); + }); + + it('should sort descending when args are flipped', () => { + const versions = ['v18.17.0', 'v22.21.0', 'v20.10.0', 'v18.2.0', 'v21.0.0']; + const sorted = [...versions].sort((a, b) => compareVersions(b, a)); + expect(sorted).toEqual(['v22.21.0', 'v21.0.0', 'v20.10.0', 'v18.17.0', 'v18.2.0']); + }); + + it('should handle single-digit versions', () => { + const versions = ['v8.0.0', 'v16.0.0', 'v4.0.0', 'v12.0.0']; + const sorted = [...versions].sort((a, b) => compareVersions(b, a)); + expect(sorted).toEqual(['v16.0.0', 'v12.0.0', 'v8.0.0', 'v4.0.0']); + }); + }); + + describe('edge cases', () => { + it('should handle versions without v prefix', () => { + expect(compareVersions('22.0.0', '20.0.0')).toBe(1); + }); + + it('should handle mixed v prefix', () => { + expect(compareVersions('v22.0.0', '20.0.0')).toBe(1); + }); + + it('should handle versions with different part counts', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0')).toBe(1); + }); + }); +}); diff --git a/src/shared/index.ts b/src/shared/index.ts index 4017f906..18e2c9a4 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -13,3 +13,4 @@ export * from './gitUtils'; export * from './emojiUtils'; export * from './treeUtils'; export * from './stringUtils'; +export * from './pathUtils'; diff --git a/src/shared/pathUtils.ts b/src/shared/pathUtils.ts new file mode 100644 index 00000000..098adcbe --- /dev/null +++ b/src/shared/pathUtils.ts @@ -0,0 +1,105 @@ +/** + * Shared path and version utility functions + * + * This module provides utilities used across multiple parts of the application. + * + * Consolidates duplicated logic from: + * - agent-detector.ts (expandTilde) + * - ssh-command-builder.ts (expandPath) + * - ssh-config-parser.ts (expandPath) + * - ssh-remote-manager.ts (expandPath) + * - process-manager.ts (inline tilde expansion) + * - update-checker.ts (version comparison) + */ + +import * as os from 'os'; +import * as path from 'path'; + +/** + * Expand tilde (~) to home directory in paths. + * + * Node.js fs functions don't understand shell tilde expansion, + * so this function provides consistent tilde handling across the codebase. + * + * @param filePath - Path that may start with ~ or ~/ + * @returns Expanded absolute path with ~ replaced by home directory + * + * @example + * ```typescript + * expandTilde('~/.ssh/id_rsa') // '/Users/username/.ssh/id_rsa' + * expandTilde('~') // '/Users/username' + * expandTilde('/absolute/path') // '/absolute/path' (unchanged) + * ``` + */ +export function expandTilde(filePath: string): string { + if (!filePath) { + return filePath; + } + + if (filePath === '~') { + return os.homedir(); + } + + if (filePath.startsWith('~/')) { + return path.join(os.homedir(), filePath.slice(2)); + } + + return filePath; +} + +/** + * Parse version string to comparable array of numbers. + * + * @param version - Version string (e.g., "v22.10.0" or "0.14.0") + * @returns Array of version numbers (e.g., [22, 10, 0]) + * + * @example + * ```typescript + * parseVersion('v22.10.0') // [22, 10, 0] + * parseVersion('0.14.0') // [0, 14, 0] + * ``` + */ +export function parseVersion(version: string): number[] { + const cleaned = version.replace(/^v/, ''); + return cleaned.split('.').map(n => parseInt(n, 10) || 0); +} + +/** + * Compare two version strings. + * + * Returns: 1 if a > b, -1 if a < b, 0 if equal. + * Handles versions with or without 'v' prefix. + * + * @param a - First version string + * @param b - Second version string + * @returns 1 if a > b, -1 if a < b, 0 if equal + * + * @example + * ```typescript + * compareVersions('v22.0.0', 'v20.0.0') // 1 (a > b) + * compareVersions('v18.0.0', 'v20.0.0') // -1 (a < b) + * compareVersions('v20.0.0', 'v20.0.0') // 0 (equal) + * + * // For descending sort (highest first): + * versions.sort((a, b) => compareVersions(b, a)) + * + * // For ascending sort (lowest first): + * versions.sort(compareVersions) + * ``` + */ +export function compareVersions(a: string, b: string): number { + const partsA = parseVersion(a); + const partsB = parseVersion(b); + + const maxLength = Math.max(partsA.length, partsB.length); + + for (let i = 0; i < maxLength; i++) { + const numA = partsA[i] || 0; + const numB = partsB[i] || 0; + + if (numA > numB) return 1; + if (numA < numB) return -1; + } + + return 0; +} From e744ba41cbd174f079499759e70d4c5f0dcf6ca6 Mon Sep 17 00:00:00 2001 From: Timm Stokke Date: Mon, 29 Dec 2025 21:11:31 -0800 Subject: [PATCH 2/3] refactor: use shared compareVersions utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace duplicated version comparison logic with shared utility: - process-manager.ts: use compareVersions for Node version sorting - update-checker.ts: remove local parseVersion/compareVersions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main/process-manager.ts | 30 ++++-------------------------- src/main/update-checker.ts | 32 ++------------------------------ 2 files changed, 6 insertions(+), 56 deletions(-) diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index 9e6c2bb6..ab3bd8d6 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -10,6 +10,7 @@ import { getOutputParser, type ParsedEvent, type AgentOutputParser } from './par import { aggregateModelUsage } from './parsers/usage-aggregator'; import { matchSshErrorPattern } from './parsers/error-patterns'; import type { AgentError } from '../shared/types'; +import { compareVersions } from '../shared/pathUtils'; import { getAgentCapabilities } from './agent-capabilities'; // Re-export parser types for consumers @@ -224,15 +225,7 @@ function detectNodeVersionManagerPaths(): string[] { if (fs.existsSync(versionsDir)) { const versions = fs.readdirSync(versionsDir).filter(v => v.startsWith('v')); if (versions.length > 0) { - // Sort and get latest - versions.sort((a, b) => { - const aParts = a.slice(1).split('.').map(Number); - const bParts = b.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i]; - } - return 0; - }); + versions.sort((a, b) => compareVersions(b, a)); version = versions[0]; } } @@ -251,15 +244,7 @@ function detectNodeVersionManagerPaths(): string[] { try { const versions = fs.readdirSync(versionsDir).filter(v => v.startsWith('v')); if (versions.length > 0) { - // Sort and get latest - versions.sort((a, b) => { - const aParts = a.slice(1).split('.').map(Number); - const bParts = b.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i]; - } - return 0; - }); + versions.sort((a, b) => compareVersions(b, a)); const versionBin = path.join(versionsDir, versions[0], 'bin'); if (fs.existsSync(versionBin)) { detectedPaths.push(versionBin); @@ -293,14 +278,7 @@ function detectNodeVersionManagerPaths(): string[] { try { const versions = fs.readdirSync(fnmNodeVersions).filter(v => v.startsWith('v')); if (versions.length > 0) { - versions.sort((a, b) => { - const aParts = a.slice(1).split('.').map(Number); - const bParts = b.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i]; - } - return 0; - }); + versions.sort((a, b) => compareVersions(b, a)); const versionBin = path.join(fnmNodeVersions, versions[0], 'installation', 'bin'); if (fs.existsSync(versionBin)) { detectedPaths.push(versionBin); diff --git a/src/main/update-checker.ts b/src/main/update-checker.ts index 62d98588..f29756f3 100644 --- a/src/main/update-checker.ts +++ b/src/main/update-checker.ts @@ -3,6 +3,8 @@ * Fetches release information from GitHub API to check for updates */ +import { compareVersions } from '../shared/pathUtils'; + // GitHub repository information const GITHUB_OWNER = 'pedramamini'; const GITHUB_REPO = 'Maestro'; @@ -74,36 +76,6 @@ function hasAssetsForPlatform(release: Release): boolean { } } -/** - * Parse version string to comparable array - * e.g., "0.7.0" -> [0, 7, 0] - */ -function parseVersion(version: string): number[] { - // Remove 'v' prefix if present - const cleaned = version.replace(/^v/, ''); - return cleaned.split('.').map(n => parseInt(n, 10) || 0); -} - -/** - * Compare two versions - * Returns: 1 if a > b, -1 if a < b, 0 if equal - */ -function compareVersions(a: string, b: string): number { - const partsA = parseVersion(a); - const partsB = parseVersion(b); - - const maxLength = Math.max(partsA.length, partsB.length); - - for (let i = 0; i < maxLength; i++) { - const numA = partsA[i] || 0; - const numB = partsB[i] || 0; - - if (numA > numB) return 1; - if (numA < numB) return -1; - } - - return 0; -} /** * Fetch all releases from GitHub API From d4e4cf96db393ce59234d75f434615053500eb55 Mon Sep 17 00:00:00 2001 From: Timm Stokke Date: Mon, 29 Dec 2025 21:34:11 -0800 Subject: [PATCH 3/3] refactor: consolidate tilde expansion into shared expandTilde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes 4 duplicate implementations of tilde expansion (~/) and consolidates them into a single shared function in pathUtils.ts. Changes: - Add optional homeDir param to expandTilde for dependency injection - Refactor agent-detector.ts to use shared expandTilde - Refactor ssh-command-builder.ts to use shared expandTilde - Refactor ssh-config-parser.ts to use shared expandTilde - Refactor ssh-remote-manager.ts to use shared expandTilde - Update ssh-command-builder tests to mock os.homedir() Net reduction: 38 lines of code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../main/utils/ssh-command-builder.test.ts | 20 ++++++++++++----- src/main/agent-detector.ts | 14 ++---------- src/main/ssh-remote-manager.ts | 22 ++++--------------- src/main/utils/ssh-command-builder.ts | 20 +++-------------- src/main/utils/ssh-config-parser.ts | 16 ++------------ src/shared/pathUtils.ts | 10 ++++++--- 6 files changed, 32 insertions(+), 70 deletions(-) diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index b8ba0b9a..ca59581c 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -1,20 +1,28 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { buildSshCommand, buildRemoteCommand, } from '../../../main/utils/ssh-command-builder'; import type { SshRemoteConfig } from '../../../shared/types'; +import * as os from 'os'; + +// Mock os.homedir() for consistent path expansion tests +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + homedir: vi.fn(() => '/Users/testuser'), + }; +}); describe('ssh-command-builder', () => { - const originalEnv = process.env; - beforeEach(() => { - // Mock HOME for consistent path expansion tests - process.env = { ...originalEnv, HOME: '/Users/testuser' }; + // Reset mock to ensure consistent behavior + vi.mocked(os.homedir).mockReturnValue('/Users/testuser'); }); afterEach(() => { - process.env = originalEnv; + vi.clearAllMocks(); }); // Base config for testing diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index 0a2a3a07..94c4563c 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities'; +import { expandTilde } from '../shared/pathUtils'; // Re-export AgentCapabilities for convenience export { AgentCapabilities } from './agent-capabilities'; @@ -289,17 +290,6 @@ export class AgentDetector { return agents; } - /** - * Expand tilde (~) to home directory in paths. - * This is necessary because Node.js fs functions don't understand shell tilde expansion. - */ - private expandTilde(filePath: string): string { - if (filePath.startsWith('~/') || filePath === '~') { - return path.join(os.homedir(), filePath.slice(1)); - } - return filePath; - } - /** * Check if a custom path points to a valid executable * On Windows, also tries .cmd and .exe extensions if the path doesn't exist as-is @@ -308,7 +298,7 @@ export class AgentDetector { const isWindows = process.platform === 'win32'; // Expand tilde to home directory (Node.js fs doesn't understand ~) - const expandedPath = this.expandTilde(customPath); + const expandedPath = expandTilde(customPath); // Helper to check if a specific path exists and is a file const checkPath = async (pathToCheck: string): Promise => { diff --git a/src/main/ssh-remote-manager.ts b/src/main/ssh-remote-manager.ts index fe2031b9..784bdf50 100644 --- a/src/main/ssh-remote-manager.ts +++ b/src/main/ssh-remote-manager.ts @@ -6,9 +6,9 @@ */ import * as fs from 'fs'; -import * as path from 'path'; import { SshRemoteConfig, SshRemoteTestResult } from '../shared/types'; import { execFileNoThrow, ExecResult } from './utils/execFile'; +import { expandTilde } from '../shared/pathUtils'; /** * Validation result for SSH remote configuration. @@ -125,7 +125,7 @@ export class SshRemoteManager { // Private key file existence check (only if path is provided) if (config.privateKeyPath && config.privateKeyPath.trim() !== '') { - const keyPath = this.expandPath(config.privateKeyPath); + const keyPath = expandTilde(config.privateKeyPath); if (!this.deps.checkFileAccess(keyPath)) { errors.push(`Private key not readable: ${config.privateKeyPath}`); } @@ -234,11 +234,11 @@ export class SshRemoteManager { if (config.useSshConfig) { // Only add key if explicitly provided (as override) if (config.privateKeyPath && config.privateKeyPath.trim()) { - args.push('-i', this.expandPath(config.privateKeyPath)); + args.push('-i', expandTilde(config.privateKeyPath)); } } else { // Direct connection: require private key - args.push('-i', this.expandPath(config.privateKeyPath)); + args.push('-i', expandTilde(config.privateKeyPath)); } // Default SSH options @@ -267,20 +267,6 @@ export class SshRemoteManager { return args; } - /** - * Expand tilde (~) in paths to the user's home directory. - * - * @param filePath Path that may start with ~ - * @returns Expanded absolute path - */ - private expandPath(filePath: string): string { - if (filePath.startsWith('~')) { - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - return path.join(homeDir, filePath.slice(1)); - } - return filePath; - } - /** * Parse SSH error messages to provide user-friendly descriptions. * diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index adeaa8ed..f1470ec2 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -9,7 +9,7 @@ import { SshRemoteConfig } from '../../shared/types'; import { shellEscape, buildShellCommand } from './shell-escape'; -import * as path from 'path'; +import { expandTilde } from '../../shared/pathUtils'; /** * Result of building an SSH command. @@ -46,20 +46,6 @@ const DEFAULT_SSH_OPTIONS: Record = { ConnectTimeout: '10', // Connection timeout in seconds }; -/** - * Expand tilde (~) in paths to the user's home directory. - * - * @param filePath Path that may start with ~ - * @returns Expanded absolute path - */ -function expandPath(filePath: string): string { - if (filePath.startsWith('~')) { - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - return path.join(homeDir, filePath.slice(1)); - } - return filePath; -} - /** * Build the remote shell command string from command, args, cwd, and env. * @@ -186,11 +172,11 @@ export function buildSshCommand( if (config.useSshConfig) { // Only specify identity file if explicitly provided (override SSH config) if (config.privateKeyPath && config.privateKeyPath.trim()) { - args.push('-i', expandPath(config.privateKeyPath)); + args.push('-i', expandTilde(config.privateKeyPath)); } } else { // Direct connection: require private key - args.push('-i', expandPath(config.privateKeyPath)); + args.push('-i', expandTilde(config.privateKeyPath)); } // Default SSH options for non-interactive operation diff --git a/src/main/utils/ssh-config-parser.ts b/src/main/utils/ssh-config-parser.ts index a1b22689..126a3282 100644 --- a/src/main/utils/ssh-config-parser.ts +++ b/src/main/utils/ssh-config-parser.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { expandTilde } from '../../shared/pathUtils'; /** * Parsed SSH config host entry. @@ -84,19 +85,6 @@ function getDefaultDeps(): SshConfigParserDeps { }; } -/** - * Expand tilde (~) in paths to the user's home directory. - */ -function expandPath(filePath: string, homeDir: string): string { - if (filePath.startsWith('~/')) { - return path.join(homeDir, filePath.slice(2)); - } - if (filePath === '~') { - return homeDir; - } - return filePath; -} - /** * Normalize an IdentityFile path. * Handles ~ expansion and resolves %d, %h, %r tokens. @@ -107,7 +95,7 @@ function normalizeIdentityFile( user: string | undefined, homeDir: string ): string { - let normalized = expandPath(identityFile, homeDir); + let normalized = expandTilde(identityFile, homeDir); // Replace common SSH config tokens normalized = normalized.replace(/%d/g, homeDir); diff --git a/src/shared/pathUtils.ts b/src/shared/pathUtils.ts index 098adcbe..f693a075 100644 --- a/src/shared/pathUtils.ts +++ b/src/shared/pathUtils.ts @@ -22,6 +22,7 @@ import * as path from 'path'; * so this function provides consistent tilde handling across the codebase. * * @param filePath - Path that may start with ~ or ~/ + * @param homeDir - Optional custom home directory (for testing/dependency injection) * @returns Expanded absolute path with ~ replaced by home directory * * @example @@ -29,19 +30,22 @@ import * as path from 'path'; * expandTilde('~/.ssh/id_rsa') // '/Users/username/.ssh/id_rsa' * expandTilde('~') // '/Users/username' * expandTilde('/absolute/path') // '/absolute/path' (unchanged) + * expandTilde('~/config', '/custom/home') // '/custom/home/config' * ``` */ -export function expandTilde(filePath: string): string { +export function expandTilde(filePath: string, homeDir?: string): string { if (!filePath) { return filePath; } + const home = homeDir ?? os.homedir(); + if (filePath === '~') { - return os.homedir(); + return home; } if (filePath.startsWith('~/')) { - return path.join(os.homedir(), filePath.slice(2)); + return path.join(home, filePath.slice(2)); } return filePath;