From ffa9918daf1f5938d8917f8db13a6cb0d1c7eddd Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Tue, 11 Aug 2026 23:14:53 +0800 Subject: [PATCH 1/4] fix(create): avoid following target symlinks --- .../cli/src/create/__tests__/prompts.spec.ts | 110 +++++++++++++++++- packages/cli/src/create/prompts.ts | 49 ++++++-- 2 files changed, 146 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/create/__tests__/prompts.spec.ts b/packages/cli/src/create/__tests__/prompts.spec.ts index 89b91546a1..8fe59e8789 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,79 @@ 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('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..977cc4bc3e 100644 --- a/packages/cli/src/create/prompts.ts +++ b/packages/cli/src/create/prompts.ts @@ -85,27 +85,48 @@ 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', + }; +} + export async function checkProjectDirExists(projectDirFullPath: string, interactive?: boolean) { - if (isTargetDirAvailable(projectDirFullPath)) { + const stats = fs.lstatSync(projectDirFullPath, { throwIfNoEntry: false }); + if (!stats || (stats.isDirectory() && isEmpty(projectDirFullPath))) { return; } + const { description, removeLabel } = describeExistingTarget(projectDirFullPath, 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 +138,7 @@ export async function checkProjectDirExists(projectDirFullPath: string, interact switch (overwrite) { case 'yes': - emptyDir(projectDirFullPath); + clearTargetPath(projectDirFullPath); break; case 'no': cancelAndExit(); @@ -129,20 +150,26 @@ 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 stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + if (!stats) { + return; + } + if (!stats.isDirectory()) { + fs.rmSync(targetPath, { force: true }); return; } - for (const file of fs.readdirSync(dir)) { + for (const file of fs.readdirSync(targetPath)) { if (file === '.git') { continue; } - fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); + fs.rmSync(path.resolve(targetPath, file), { recursive: true, force: true }); } } export function isTargetDirAvailable(projectDirFullPath: string) { - return !fs.existsSync(projectDirFullPath) || isEmpty(projectDirFullPath); + const stats = fs.lstatSync(projectDirFullPath, { throwIfNoEntry: false }); + return !stats || (stats.isDirectory() && isEmpty(projectDirFullPath)); } function validateTargetDir(input?: string, cwd?: string): { directory: string; error?: string } { From a6148bc67d1be269436874e287f4e088f77020be Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Wed, 12 Aug 2026 12:52:49 +0800 Subject: [PATCH 2/4] fix(create): handle trailing separators safely --- .../cli/src/create/__tests__/prompts.spec.ts | 25 +++++++++++++++ packages/cli/src/create/prompts.ts | 32 +++++++++++++------ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/create/__tests__/prompts.spec.ts b/packages/cli/src/create/__tests__/prompts.spec.ts index 8fe59e8789..ebd4b6d8d2 100644 --- a/packages/cli/src/create/__tests__/prompts.spec.ts +++ b/packages/cli/src/create/__tests__/prompts.spec.ts @@ -112,6 +112,31 @@ describe('target directory helpers', () => { }); }); + 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'); diff --git a/packages/cli/src/create/prompts.ts b/packages/cli/src/create/prompts.ts index 977cc4bc3e..82f288efb5 100644 --- a/packages/cli/src/create/prompts.ts +++ b/packages/cli/src/create/prompts.ts @@ -104,12 +104,22 @@ function describeExistingTarget(projectDirFullPath: string, stats: fs.Stats) { }; } +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) { - const stats = fs.lstatSync(projectDirFullPath, { throwIfNoEntry: false }); - if (!stats || (stats.isDirectory() && isEmpty(projectDirFullPath))) { + const targetPath = stripTrailingPathSeparators(projectDirFullPath); + const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + if (!stats || (stats.isDirectory() && isEmpty(targetPath))) { return; } - const { description, removeLabel } = describeExistingTarget(projectDirFullPath, stats); + const { description, removeLabel } = describeExistingTarget(targetPath, stats); if (!interactive) { prompts.log.info( 'Use --directory to specify a different location or remove the directory first', @@ -138,7 +148,7 @@ export async function checkProjectDirExists(projectDirFullPath: string, interact switch (overwrite) { case 'yes': - clearTargetPath(projectDirFullPath); + clearTargetPath(targetPath); break; case 'no': cancelAndExit(); @@ -151,25 +161,27 @@ function isEmpty(path: string) { } function clearTargetPath(targetPath: string) { - const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + const strippedTargetPath = stripTrailingPathSeparators(targetPath); + const stats = fs.lstatSync(strippedTargetPath, { throwIfNoEntry: false }); if (!stats) { return; } if (!stats.isDirectory()) { - fs.rmSync(targetPath, { force: true }); + fs.rmSync(strippedTargetPath, { force: true }); return; } - for (const file of fs.readdirSync(targetPath)) { + for (const file of fs.readdirSync(strippedTargetPath)) { if (file === '.git') { continue; } - fs.rmSync(path.resolve(targetPath, file), { recursive: true, force: true }); + fs.rmSync(path.resolve(strippedTargetPath, file), { recursive: true, force: true }); } } export function isTargetDirAvailable(projectDirFullPath: string) { - const stats = fs.lstatSync(projectDirFullPath, { throwIfNoEntry: false }); - return !stats || (stats.isDirectory() && 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 } { From 568252c2b5aa8ecc66cc4766edf56fb4e4259b95 Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Tue, 25 Aug 2026 18:00:24 +0800 Subject: [PATCH 3/4] fix(create): abort when target changes before cleanup --- .../cli/src/create/__tests__/prompts.spec.ts | 56 +++++++++++++++++++ packages/cli/src/create/prompts.ts | 40 +++++++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/create/__tests__/prompts.spec.ts b/packages/cli/src/create/__tests__/prompts.spec.ts index ebd4b6d8d2..dea877802e 100644 --- a/packages/cli/src/create/__tests__/prompts.spec.ts +++ b/packages/cli/src/create/__tests__/prompts.spec.ts @@ -13,6 +13,16 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ select: mockSelect, })); +vi.mock('../../utils/prompts.ts', () => ({ + cancelAndExit: vi.fn(() => { + throw new Error('Operation cancelled'); + }), +})); + +vi.mock('../../utils/terminal.ts', () => ({ + accent: (value: string) => value, +})); + const { checkProjectDirExists, isTargetDirAvailable, suggestAvailableTargetDir } = await import('../prompts.js'); @@ -173,4 +183,50 @@ describe('target directory helpers', () => { ], }); }); + + it('does not clear a directory that replaces a target symlink during confirmation', async () => { + const cwd = makeTempDir(); + const linkedDir = makeTempDir(); + const targetPath = path.join(cwd, 'new-project'); + const replacementPath = path.join(cwd, 'replacement-project'); + const linkedSentinel = path.join(linkedDir, 'keep.txt'); + const replacementSentinel = path.join(targetPath, 'keep.txt'); + fs.writeFileSync(linkedSentinel, 'keep linked'); + fs.mkdirSync(replacementPath); + fs.writeFileSync(path.join(replacementPath, 'keep.txt'), 'keep replacement'); + fs.symlinkSync(linkedDir, targetPath, process.platform === 'win32' ? 'junction' : 'dir'); + mockSelect.mockImplementation(() => { + fs.rmSync(targetPath, { force: true }); + fs.renameSync(replacementPath, targetPath); + return Promise.resolve('yes'); + }); + + await expect(checkProjectDirExists(targetPath, true)).rejects.toThrow( + `Target path "${targetPath}" changed while waiting for confirmation. No files were removed. Please retry the command.`, + ); + + expect(fs.readFileSync(linkedSentinel, 'utf8')).toBe('keep linked'); + expect(fs.readFileSync(replacementSentinel, 'utf8')).toBe('keep replacement'); + }); + + it('does not remove a file that replaces the confirmed target path', async () => { + const cwd = makeTempDir(); + const targetPath = path.join(cwd, 'new-project'); + const originalPath = path.join(cwd, 'original-project'); + const replacementPath = path.join(cwd, 'replacement-project'); + fs.writeFileSync(targetPath, 'original'); + fs.writeFileSync(replacementPath, 'replacement'); + mockSelect.mockImplementation(() => { + fs.renameSync(targetPath, originalPath); + fs.renameSync(replacementPath, targetPath); + return Promise.resolve('yes'); + }); + + await expect(checkProjectDirExists(targetPath, true)).rejects.toThrow( + `Target path "${targetPath}" changed while waiting for confirmation. No files were removed. Please retry the command.`, + ); + + expect(fs.readFileSync(originalPath, 'utf8')).toBe('original'); + expect(fs.readFileSync(targetPath, 'utf8')).toBe('replacement'); + }); }); diff --git a/packages/cli/src/create/prompts.ts b/packages/cli/src/create/prompts.ts index 82f288efb5..2d4cc530e2 100644 --- a/packages/cli/src/create/prompts.ts +++ b/packages/cli/src/create/prompts.ts @@ -148,7 +148,7 @@ export async function checkProjectDirExists(projectDirFullPath: string, interact switch (overwrite) { case 'yes': - clearTargetPath(targetPath); + clearTargetPath(targetPath, stats); break; case 'no': cancelAndExit(); @@ -160,9 +160,37 @@ function isEmpty(path: string) { return files.length === 0 || (files.length === 1 && files[0] === '.git'); } -function clearTargetPath(targetPath: string) { +function isSameTargetEntry(expected: fs.Stats, actual: fs.Stats) { + return ( + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.isDirectory() === actual.isDirectory() && + expected.isSymbolicLink() === actual.isSymbolicLink() + ); +} + +function getUnchangedTargetStats( + targetPath: string, + expected: fs.Stats, + cleanupStarted = false, +) { + const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + if (!stats) { + return undefined; + } + if (!isSameTargetEntry(expected, stats)) { + throw new Error( + cleanupStarted + ? `Target path "${targetPath}" changed during cleanup. Cleanup was stopped. Please inspect the target and retry the command.` + : `Target path "${targetPath}" changed while waiting for confirmation. No files were removed. Please retry the command.`, + ); + } + return stats; +} + +function clearTargetPath(targetPath: string, expectedStats: fs.Stats) { const strippedTargetPath = stripTrailingPathSeparators(targetPath); - const stats = fs.lstatSync(strippedTargetPath, { throwIfNoEntry: false }); + const stats = getUnchangedTargetStats(strippedTargetPath, expectedStats); if (!stats) { return; } @@ -170,10 +198,14 @@ function clearTargetPath(targetPath: string) { fs.rmSync(strippedTargetPath, { force: true }); return; } - for (const file of fs.readdirSync(strippedTargetPath)) { + const files = fs.readdirSync(strippedTargetPath); + for (const file of files) { if (file === '.git') { continue; } + if (!getUnchangedTargetStats(strippedTargetPath, expectedStats, true)) { + return; + } fs.rmSync(path.resolve(strippedTargetPath, file), { recursive: true, force: true }); } } From 4536d3053880867df2ef1161589353e0d4b9b7f0 Mon Sep 17 00:00:00 2001 From: Leslie Lau <1178273431@qq.com> Date: Wed, 26 Aug 2026 09:50:43 +0800 Subject: [PATCH 4/4] style(create): format target helper --- packages/cli/src/create/prompts.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/src/create/prompts.ts b/packages/cli/src/create/prompts.ts index 2d4cc530e2..8a7b51b53f 100644 --- a/packages/cli/src/create/prompts.ts +++ b/packages/cli/src/create/prompts.ts @@ -169,11 +169,7 @@ function isSameTargetEntry(expected: fs.Stats, actual: fs.Stats) { ); } -function getUnchangedTargetStats( - targetPath: string, - expected: fs.Stats, - cleanupStarted = false, -) { +function getUnchangedTargetStats(targetPath: string, expected: fs.Stats, cleanupStarted = false) { const stats = fs.lstatSync(targetPath, { throwIfNoEntry: false }); if (!stats) { return undefined;