diff --git a/packages/cli/src/create/__tests__/prompts.spec.ts b/packages/cli/src/create/__tests__/prompts.spec.ts index 89b91546a1..ebd4b6d8d2 100644 --- a/packages/cli/src/create/__tests__/prompts.spec.ts +++ b/packages/cli/src/create/__tests__/prompts.spec.ts @@ -2,9 +2,19 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { isTargetDirAvailable, suggestAvailableTargetDir } from '../prompts.js'; +const { mockSelect } = vi.hoisted(() => ({ + mockSelect: vi.fn(), +})); + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + isCancel: () => false, + select: mockSelect, +})); + +const { checkProjectDirExists, isTargetDirAvailable, suggestAvailableTargetDir } = + await import('../prompts.js'); const tempDirs: string[] = []; @@ -21,11 +31,23 @@ function makeTempDir() { } describe('target directory helpers', () => { + beforeEach(() => { + mockSelect.mockReset(); + }); + it('reports missing directories as available', () => { const cwd = makeTempDir(); expect(isTargetDirAvailable(path.join(cwd, 'new-project'))).toBe(true); }); + it('reports empty directories as available', () => { + const cwd = makeTempDir(); + const targetDir = path.join(cwd, 'empty-project'); + fs.mkdirSync(targetDir); + + expect(isTargetDirAvailable(targetDir)).toBe(true); + }); + it('reports non-empty directories as unavailable', () => { const cwd = makeTempDir(); const targetDir = path.join(cwd, 'existing-project'); @@ -35,6 +57,15 @@ describe('target directory helpers', () => { expect(isTargetDirAvailable(targetDir)).toBe(false); }); + it('reports a symlink to an empty directory as unavailable', () => { + const cwd = makeTempDir(); + const linkedDir = makeTempDir(); + const targetDir = path.join(cwd, 'new-project'); + fs.symlinkSync(linkedDir, targetDir, process.platform === 'win32' ? 'junction' : 'dir'); + + expect(isTargetDirAvailable(targetDir)).toBe(false); + }); + it('suggests a different target directory when the default already exists', () => { const cwd = makeTempDir(); fs.mkdirSync(path.join(cwd, 'fate-template'), { recursive: true }); @@ -42,4 +73,104 @@ describe('target directory helpers', () => { expect(suggestAvailableTargetDir('fate-template', cwd)).not.toBe('fate-template'); }); + + it('clears a regular target directory while preserving its .git directory', async () => { + const cwd = makeTempDir(); + const targetDir = path.join(cwd, 'existing-project'); + fs.mkdirSync(path.join(targetDir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(targetDir, 'src')); + fs.writeFileSync(path.join(targetDir, '.git', 'config'), 'keep'); + fs.writeFileSync(path.join(targetDir, 'src', 'main.ts'), 'remove'); + fs.writeFileSync(path.join(targetDir, 'package.json'), '{}'); + mockSelect.mockResolvedValue('yes'); + + await checkProjectDirExists(targetDir, true); + + expect(fs.readdirSync(targetDir)).toEqual(['.git']); + expect(fs.readFileSync(path.join(targetDir, '.git', 'config'), 'utf8')).toBe('keep'); + }); + + it('removes a target symlink without deleting files in the linked directory', async () => { + const cwd = makeTempDir(); + const linkedDir = makeTempDir(); + const targetDir = path.join(cwd, 'new-project'); + const sentinel = path.join(linkedDir, 'keep.txt'); + fs.writeFileSync(sentinel, 'keep'); + fs.symlinkSync(linkedDir, targetDir, process.platform === 'win32' ? 'junction' : 'dir'); + mockSelect.mockResolvedValue('yes'); + + await checkProjectDirExists(targetDir, true); + + expect(fs.existsSync(sentinel)).toBe(true); + expect(fs.lstatSync(targetDir, { throwIfNoEntry: false })).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledWith({ + message: `Target path "${targetDir}" is a symbolic link. Please choose how to proceed:`, + options: [ + { label: 'Cancel operation', value: 'no' }, + { label: 'Remove symbolic link and continue', value: 'yes' }, + ], + }); + }); + + it('removes a target symlink with a trailing separator without deleting linked files', async () => { + const cwd = makeTempDir(); + const linkedDir = makeTempDir(); + const targetDir = path.join(cwd, 'new-project'); + const targetDirWithSeparator = `${targetDir}${path.sep}`; + const sentinel = path.join(linkedDir, 'keep.txt'); + fs.writeFileSync(sentinel, 'keep'); + fs.symlinkSync(linkedDir, targetDir, process.platform === 'win32' ? 'junction' : 'dir'); + mockSelect.mockResolvedValue('yes'); + + expect(isTargetDirAvailable(targetDirWithSeparator)).toBe(false); + + await checkProjectDirExists(targetDirWithSeparator, true); + + expect(fs.existsSync(sentinel)).toBe(true); + expect(fs.lstatSync(targetDir, { throwIfNoEntry: false })).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledWith({ + message: `Target path "${targetDir}" is a symbolic link. Please choose how to proceed:`, + options: [ + { label: 'Cancel operation', value: 'no' }, + { label: 'Remove symbolic link and continue', value: 'yes' }, + ], + }); + }); + + it('recognizes and removes a dangling target symlink', async () => { + const cwd = makeTempDir(); + const targetDir = path.join(cwd, 'new-project'); + fs.symlinkSync( + path.join(cwd, 'missing-directory'), + targetDir, + process.platform === 'win32' ? 'junction' : 'dir', + ); + mockSelect.mockResolvedValue('yes'); + + expect(isTargetDirAvailable(targetDir)).toBe(false); + + await checkProjectDirExists(targetDir, true); + + expect(fs.lstatSync(targetDir, { throwIfNoEntry: false })).toBeUndefined(); + }); + + it('recognizes and removes an existing file at the target path', async () => { + const cwd = makeTempDir(); + const targetPath = path.join(cwd, 'new-project'); + fs.writeFileSync(targetPath, 'remove'); + mockSelect.mockResolvedValue('yes'); + + expect(isTargetDirAvailable(targetPath)).toBe(false); + + await checkProjectDirExists(targetPath, true); + + expect(fs.existsSync(targetPath)).toBe(false); + expect(mockSelect).toHaveBeenCalledWith({ + message: `Target path "${targetPath}" already exists. Please choose how to proceed:`, + options: [ + { label: 'Cancel operation', value: 'no' }, + { label: 'Remove existing path and continue', value: 'yes' }, + ], + }); + }); }); diff --git a/packages/cli/src/create/prompts.ts b/packages/cli/src/create/prompts.ts index 861c430362..82f288efb5 100644 --- a/packages/cli/src/create/prompts.ts +++ b/packages/cli/src/create/prompts.ts @@ -85,27 +85,58 @@ export function suggestAvailableTargetDir(defaultTargetDir: string, cwd: string) return suggestedTargetDir; } +function describeExistingTarget(projectDirFullPath: string, stats: fs.Stats) { + if (stats.isSymbolicLink()) { + return { + description: `Target path "${projectDirFullPath}" is a symbolic link`, + removeLabel: 'Remove symbolic link and continue', + }; + } + if (stats.isDirectory()) { + return { + description: `Target directory "${projectDirFullPath}" is not empty`, + removeLabel: 'Remove existing files and continue', + }; + } + return { + description: `Target path "${projectDirFullPath}" already exists`, + removeLabel: 'Remove existing path and continue', + }; +} + +function stripTrailingPathSeparators(targetPath: string) { + const root = path.parse(targetPath).root; + let end = targetPath.length; + while (end > root.length && targetPath[end - 1] === path.sep) { + end--; + } + return targetPath.slice(0, end); +} + export async function checkProjectDirExists(projectDirFullPath: string, interactive?: boolean) { - if (isTargetDirAvailable(projectDirFullPath)) { + const targetPath = stripTrailingPathSeparators(projectDirFullPath); + const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + if (!stats || (stats.isDirectory() && isEmpty(targetPath))) { return; } + const { description, removeLabel } = describeExistingTarget(targetPath, stats); if (!interactive) { prompts.log.info( 'Use --directory to specify a different location or remove the directory first', ); - cancelAndExit(`Target directory "${projectDirFullPath}" is not empty`, 1); + cancelAndExit(description, 1); } - // Handle directory if it exists and is not empty + // Handle an existing target that cannot be reused as-is. const overwrite = await prompts.select({ - message: `Target directory "${projectDirFullPath}" is not empty. Please choose how to proceed:`, + message: `${description}. Please choose how to proceed:`, options: [ { label: 'Cancel operation', value: 'no', }, { - label: 'Remove existing files and continue', + label: removeLabel, value: 'yes', }, ], @@ -117,7 +148,7 @@ export async function checkProjectDirExists(projectDirFullPath: string, interact switch (overwrite) { case 'yes': - emptyDir(projectDirFullPath); + clearTargetPath(targetPath); break; case 'no': cancelAndExit(); @@ -129,20 +160,28 @@ function isEmpty(path: string) { return files.length === 0 || (files.length === 1 && files[0] === '.git'); } -function emptyDir(dir: string) { - if (!fs.existsSync(dir)) { +function clearTargetPath(targetPath: string) { + const strippedTargetPath = stripTrailingPathSeparators(targetPath); + const stats = fs.lstatSync(strippedTargetPath, { throwIfNoEntry: false }); + if (!stats) { + return; + } + if (!stats.isDirectory()) { + fs.rmSync(strippedTargetPath, { force: true }); return; } - for (const file of fs.readdirSync(dir)) { + for (const file of fs.readdirSync(strippedTargetPath)) { if (file === '.git') { continue; } - fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); + fs.rmSync(path.resolve(strippedTargetPath, file), { recursive: true, force: true }); } } export function isTargetDirAvailable(projectDirFullPath: string) { - return !fs.existsSync(projectDirFullPath) || isEmpty(projectDirFullPath); + const targetPath = stripTrailingPathSeparators(projectDirFullPath); + const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + return !stats || (stats.isDirectory() && isEmpty(targetPath)); } function validateTargetDir(input?: string, cwd?: string): { directory: string; error?: string } {