diff --git a/src/utils/setup.ts b/src/utils/setup.ts index 31dd7ad..9baedf0 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -8,10 +8,62 @@ import { createSpinner } from "./spinner.js"; import { confirmCommands } from "./tui.js"; interface WorktreeSetupData { - "setup-worktree"?: string[]; + "setup-worktree"?: string[] | string; + "setup-worktree-unix"?: string[] | string; + "setup-worktree-windows"?: string[] | string; [key: string]: unknown; } +/** + * Determine if we're running on a Unix-like system + */ +export function isUnix(): boolean { + return process.platform !== 'win32'; +} + +/** + * Resolve setup commands from a value that can be either: + * - An array of shell commands + * - A filepath to a script (relative to .cursor/ directory) + */ +export function resolveSetupValue( + value: string[] | string, + repoRoot: string +): string[] { + if (Array.isArray(value)) { + return value; + } + // Single string = script filepath relative to .cursor/ directory + const scriptPath = join(repoRoot, '.cursor', value); + return [scriptPath]; +} + +/** + * Extract commands from parsed worktrees.json data with OS-specific priority + */ +export function extractCommands( + data: WorktreeSetupData | string[], + repoRoot: string +): string[] { + // Handle plain array format (Cursor's legacy format) + if (Array.isArray(data)) { + return data; + } + + // Priority: OS-specific key > generic key + const osKey = isUnix() ? 'setup-worktree-unix' : 'setup-worktree-windows'; + + if (data[osKey] !== undefined) { + return resolveSetupValue(data[osKey], repoRoot); + } + + if (data['setup-worktree'] !== undefined) { + return resolveSetupValue(data['setup-worktree'], repoRoot); + } + + return []; +} + /** * Load and parse setup commands from worktrees.json */ @@ -23,13 +75,7 @@ async function loadSetupCommands(repoRoot: string): Promise<{ commands: string[] const content = await readFile(cursorSetupPath, "utf-8"); const data = JSON.parse(content) as WorktreeSetupData | string[]; - let commands: string[] = []; - if (Array.isArray(data)) { - commands = data; - } else if (data && typeof data === 'object' && Array.isArray(data["setup-worktree"])) { - commands = data["setup-worktree"]; - } - + const commands = extractCommands(data, repoRoot); if (commands.length > 0) { return { commands, filePath: cursorSetupPath }; } @@ -37,20 +83,14 @@ async function loadSetupCommands(repoRoot: string): Promise<{ commands: string[] // Not found, try fallback } - // Check for worktrees.json + // Check for worktrees.json at repo root const fallbackSetupPath = join(repoRoot, "worktrees.json"); try { await stat(fallbackSetupPath); const content = await readFile(fallbackSetupPath, "utf-8"); const data = JSON.parse(content) as WorktreeSetupData | string[]; - let commands: string[] = []; - if (Array.isArray(data)) { - commands = data; - } else if (data && typeof data === 'object' && Array.isArray(data["setup-worktree"])) { - commands = data["setup-worktree"]; - } - + const commands = extractCommands(data, repoRoot); if (commands.length > 0) { return { commands, filePath: fallbackSetupPath }; } @@ -143,7 +183,6 @@ export async function runSetupScripts(worktreePath: string): Promise { } let setupFilePath: string | null = null; - let setupData: WorktreeSetupData | string[] | null = null; // Check for Cursor's worktrees.json first const cursorSetupPath = join(repoRoot, ".cursor", "worktrees.json"); @@ -151,7 +190,7 @@ export async function runSetupScripts(worktreePath: string): Promise { await stat(cursorSetupPath); setupFilePath = cursorSetupPath; } catch { - // Check for worktrees.json + // Check for worktrees.json at repo root const fallbackSetupPath = join(repoRoot, "worktrees.json"); try { await stat(fallbackSetupPath); @@ -169,15 +208,9 @@ export async function runSetupScripts(worktreePath: string): Promise { try { console.log(chalk.blue(`Found setup file: ${setupFilePath}, executing setup commands...`)); const setupContent = await readFile(setupFilePath, "utf-8"); - setupData = JSON.parse(setupContent); - - let commands: string[] = []; - if (setupData && typeof setupData === 'object' && !Array.isArray(setupData) && Array.isArray(setupData["setup-worktree"])) { - commands = setupData["setup-worktree"]; - } else if (setupFilePath.includes("worktrees.json") && Array.isArray(setupData)) { - // Handle Cursor's format if it's just an array - commands = setupData; - } + const setupData = JSON.parse(setupContent) as WorktreeSetupData | string[]; + + const commands = extractCommands(setupData, repoRoot); if (commands.length === 0) { console.warn(chalk.yellow(`${setupFilePath} does not contain valid setup commands.`)); diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..d967794 --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { isUnix, resolveSetupValue, extractCommands } from '../src/utils/setup.js'; + +/** + * Setup Tests + * + * These tests verify the setup command parsing and OS-specific key handling + * for worktrees.json configuration files. + */ + +/** + * Helper to mock process.platform for cross-platform testing + */ +const mockPlatform = (platform: NodeJS.Platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); +}; + +describe('Setup Utilities', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isUnix', () => { + it('should return true on Unix platforms (macOS)', () => { + mockPlatform('darwin'); + expect(isUnix()).toBe(true); + }); + + it('should return true on Unix platforms (Linux)', () => { + mockPlatform('linux'); + expect(isUnix()).toBe(true); + }); + + it('should return false on Windows', () => { + mockPlatform('win32'); + expect(isUnix()).toBe(false); + }); + }); + + describe('resolveSetupValue', () => { + const repoRoot = '/test/repo'; + + it('should return array values as-is', () => { + const commands = ['npm install', 'npm run build']; + const result = resolveSetupValue(commands, repoRoot); + expect(result).toEqual(commands); + }); + + it('should convert string filepath to array with resolved path', () => { + const scriptPath = './scripts/setup.sh'; + const result = resolveSetupValue(scriptPath, repoRoot); + expect(result).toEqual([join(repoRoot, '.cursor', scriptPath)]); + }); + + it('should handle script path without leading ./', () => { + const scriptPath = 'scripts/setup.sh'; + const result = resolveSetupValue(scriptPath, repoRoot); + expect(result).toEqual([join(repoRoot, '.cursor', scriptPath)]); + }); + }); + + describe('extractCommands', () => { + const repoRoot = '/test/repo'; + + describe('plain array format (legacy)', () => { + it('should return the array directly', () => { + const data = ['npm install', 'npm run build']; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(data); + }); + }); + + describe('object format with setup-worktree key', () => { + it('should extract commands from setup-worktree array', () => { + const data = { + 'setup-worktree': ['npm install', 'npm run build'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(['npm install', 'npm run build']); + }); + + it('should handle setup-worktree as script filepath', () => { + const data = { + 'setup-worktree': './scripts/setup.sh' + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual([join(repoRoot, '.cursor', './scripts/setup.sh')]); + }); + }); + + describe('OS-specific key priority', () => { + it('should prefer setup-worktree-unix on Unix systems', () => { + mockPlatform('darwin'); + + const data = { + 'setup-worktree': ['fallback command'], + 'setup-worktree-unix': ['unix command'], + 'setup-worktree-windows': ['windows command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(['unix command']); + }); + + it('should prefer setup-worktree-windows on Windows systems', () => { + mockPlatform('win32'); + + const data = { + 'setup-worktree': ['fallback command'], + 'setup-worktree-unix': ['unix command'], + 'setup-worktree-windows': ['windows command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(['windows command']); + }); + + it('should fall back to setup-worktree when OS-specific key is missing on Unix', () => { + mockPlatform('darwin'); + + const data = { + 'setup-worktree': ['fallback command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(['fallback command']); + }); + + it('should fall back to setup-worktree when OS-specific key is missing on Windows', () => { + mockPlatform('win32'); + + const data = { + 'setup-worktree': ['fallback command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual(['fallback command']); + }); + + it('should handle OS-specific key as script filepath on Unix', () => { + mockPlatform('darwin'); + + const data = { + 'setup-worktree-unix': './scripts/setup-unix.sh', + 'setup-worktree': ['fallback command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual([join(repoRoot, '.cursor', './scripts/setup-unix.sh')]); + }); + + it('should handle OS-specific key as script filepath on Windows', () => { + mockPlatform('win32'); + + const data = { + 'setup-worktree-windows': './scripts/setup-windows.bat', + 'setup-worktree': ['fallback command'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual([join(repoRoot, '.cursor', './scripts/setup-windows.bat')]); + }); + }); + + describe('empty/missing data', () => { + it('should return empty array for empty object', () => { + const data = {}; + const result = extractCommands(data, repoRoot); + expect(result).toEqual([]); + }); + + it('should return empty array when no setup keys are present', () => { + const data = { + 'some-other-key': ['value'] + }; + const result = extractCommands(data, repoRoot); + expect(result).toEqual([]); + }); + }); + }); +}); + +describe('Setup File Loading', () => { + let testDir: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `wt-setup-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(testDir, { recursive: true }); + await mkdir(join(testDir, '.cursor'), { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('file location priority', () => { + it('should parse .cursor/worktrees.json with Unix-specific keys', async () => { + mockPlatform('darwin'); + + const config = { + 'setup-worktree-unix': ['echo "Unix setup"'], + 'setup-worktree-windows': ['echo Windows setup'], + 'setup-worktree': ['echo "Fallback"'] + }; + await writeFile( + join(testDir, '.cursor', 'worktrees.json'), + JSON.stringify(config) + ); + + const commands = extractCommands(config, testDir); + expect(commands).toEqual(['echo "Unix setup"']); + }); + + it('should parse .cursor/worktrees.json with Windows-specific keys', async () => { + mockPlatform('win32'); + + const config = { + 'setup-worktree-unix': ['echo "Unix setup"'], + 'setup-worktree-windows': ['echo Windows setup'], + 'setup-worktree': ['echo "Fallback"'] + }; + await writeFile( + join(testDir, '.cursor', 'worktrees.json'), + JSON.stringify(config) + ); + + const commands = extractCommands(config, testDir); + expect(commands).toEqual(['echo Windows setup']); + }); + + it('should parse worktrees.json with plain array format', async () => { + const config = ['npm install', 'npm run build']; + await writeFile( + join(testDir, 'worktrees.json'), + JSON.stringify(config) + ); + + const commands = extractCommands(config, testDir); + expect(commands).toEqual(['npm install', 'npm run build']); + }); + + it('should parse worktrees.json with script filepath on Unix', async () => { + mockPlatform('darwin'); + + const config = { + 'setup-worktree-unix': './scripts/setup.sh' + }; + await writeFile( + join(testDir, '.cursor', 'worktrees.json'), + JSON.stringify(config) + ); + + const commands = extractCommands(config, testDir); + expect(commands).toEqual([join(testDir, '.cursor', './scripts/setup.sh')]); + }); + + it('should parse worktrees.json with script filepath on Windows', async () => { + mockPlatform('win32'); + + const config = { + 'setup-worktree-windows': './scripts/setup.bat' + }; + await writeFile( + join(testDir, '.cursor', 'worktrees.json'), + JSON.stringify(config) + ); + + const commands = extractCommands(config, testDir); + expect(commands).toEqual([join(testDir, '.cursor', './scripts/setup.bat')]); + }); + }); +});