Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions packages/cli/src/create/__tests__/prompts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand All @@ -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');
Expand All @@ -35,11 +57,120 @@ 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 });
fs.writeFileSync(path.join(cwd, 'fate-template', 'package.json'), '{}');

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' },
],
});
});
});
61 changes: 50 additions & 11 deletions packages/cli/src/create/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
Expand All @@ -117,7 +148,7 @@ export async function checkProjectDirExists(projectDirFullPath: string, interact

switch (overwrite) {
case 'yes':
emptyDir(projectDirFullPath);
clearTargetPath(targetPath);
break;
case 'no':
cancelAndExit();
Expand All @@ -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 } {
Expand Down
Loading