From 3c4ae689b831458cbd0739e60ddb3c2fa3b01a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Sat, 1 Aug 2026 03:39:42 +0800 Subject: [PATCH] fix(create): scaffold libraries in git directories --- .../snapshots.toml | 9 ++ .../create_library_in_git_directory.md | 32 +++++ .../cli/src/create/__tests__/builtin.spec.ts | 125 +++++++++++++++++- .../cli/src/create/__tests__/prompts.spec.ts | 17 +++ packages/cli/src/create/command.ts | 2 + packages/cli/src/create/templates/builtin.ts | 23 +++- 6 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots/create_library_in_git_directory.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots.toml new file mode 100644 index 0000000000..d65f5f7924 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "create_library_in_git_directory" +vp = "local" +steps = [ + { argv = ["git", "init"], snapshot = false }, + { argv = ["vp", "create", "vite:library", "--directory", ".", "--no-interactive", "--no-git", "--no-hooks", "--no-agent", "--no-editor"], comment = "create a library in a directory containing only .git", timeout = 120000 }, + { argv = ["vpt", "stat-file", ".git", "--assert", "dir"], comment = "existing git metadata is preserved" }, + { argv = ["vpt", "stat-file", "package.json", "--assert", "file"], comment = "library template was created" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots/create_library_in_git_directory.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots/create_library_in_git_directory.md new file mode 100644 index 0000000000..beede5fd74 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/create_library_in_git_directory/snapshots/create_library_in_git_directory.md @@ -0,0 +1,32 @@ +# create_library_in_git_directory + +## `git init` + + +## `vp create vite:library --directory . --no-interactive --no-git --no-hooks --no-agent --no-editor` + +create a library in a directory containing only .git + +``` + +Using package name: workspace +◇ Scaffolded . with TypeScript library +• Node pnpm +→ Next: vp run +``` + +## `vpt stat-file .git --assert dir` + +existing git metadata is preserved + +``` +.git: dir +``` + +## `vpt stat-file package.json --assert file` + +library template was created + +``` +package.json: file +``` diff --git a/packages/cli/src/create/__tests__/builtin.spec.ts b/packages/cli/src/create/__tests__/builtin.spec.ts index d63370f272..ae0c8f8dde 100644 --- a/packages/cli/src/create/__tests__/builtin.spec.ts +++ b/packages/cli/src/create/__tests__/builtin.spec.ts @@ -1,11 +1,18 @@ -import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { executeBuiltinTemplate } from '../templates/builtin.js'; -const { mockLogError } = vi.hoisted(() => ({ mockLogError: vi.fn() })); +const { mockLogError, mockRunRemoteTemplateCommand } = vi.hoisted(() => ({ + mockLogError: vi.fn(), + mockRunRemoteTemplateCommand: vi.fn(), +})); vi.mock('../templates/remote.js', () => ({ - runRemoteTemplateCommand: vi.fn(), + runRemoteTemplateCommand: mockRunRemoteTemplateCommand, })); vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ @@ -25,17 +32,39 @@ const baseTemplateInfo = { interactive: false, }; +const tempDirs: string[] = []; + +beforeEach(() => { + mockLogError.mockClear(); + mockRunRemoteTemplateCommand.mockReset(); +}); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeWorkspaceInfo() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-library-')); + tempDirs.push(rootDir); + return { + rootDir, + parentDirs: [], + packageManager: 'pnpm', + downloadPackageManager: { binPrefix: '' }, + } as any; +} + describe('executeBuiltinTemplate', () => { it('returns exitCode 1 for unknown vite: template', async () => { - const { runRemoteTemplateCommand } = await import('../templates/remote.js'); - const result = await executeBuiltinTemplate(workspaceInfo, { ...baseTemplateInfo, command: 'vite:test', }); expect(result.exitCode).toBe(1); - expect(runRemoteTemplateCommand).not.toHaveBeenCalled(); + expect(mockRunRemoteTemplateCommand).not.toHaveBeenCalled(); }); it('shows error message with template name and --list hint', async () => { @@ -63,4 +92,88 @@ describe('executeBuiltinTemplate', () => { expect(mockLogError).not.toHaveBeenCalled(); }); + + it('uses degit --force for a worktree directory and preserves .git', async () => { + const workspace = makeWorkspaceInfo(); + const gitFile = path.join(workspace.rootDir, '.git'); + fs.writeFileSync(gitFile, 'gitdir: ../.git/worktrees/library'); + mockRunRemoteTemplateCommand.mockImplementation(async (_workspace: unknown, cwd: string) => { + fs.writeFileSync(path.join(cwd, 'package.json'), '{"name":"template"}\n'); + return { exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }); + + const result = await executeBuiltinTemplate(workspace, { + ...baseTemplateInfo, + command: 'vite:library', + targetDir: '.', + }); + + expect(result).toMatchObject({ exitCode: 0, projectDir: '.' }); + expect(fs.readFileSync(gitFile, 'utf8')).toContain('gitdir:'); + expect( + JSON.parse(fs.readFileSync(path.join(workspace.rootDir, 'package.json'), 'utf8')), + ).toEqual({ + name: 'wage-meeting', + }); + expect(mockRunRemoteTemplateCommand).toHaveBeenCalledOnce(); + expect(mockRunRemoteTemplateCommand.mock.calls[0][2]).toMatchObject({ + command: 'degit', + args: ['sxzz/tsdown-templates/vite-plus', '.', '--force'], + }); + }); + + it('does not force degit into a directory containing user files', async () => { + const workspace = makeWorkspaceInfo(); + const userFile = path.join(workspace.rootDir, 'keep.txt'); + fs.writeFileSync(userFile, 'keep me'); + + const result = await executeBuiltinTemplate(workspace, { + ...baseTemplateInfo, + command: 'vite:library', + targetDir: '.', + }); + + expect(result.exitCode).toBe(1); + expect(mockRunRemoteTemplateCommand).not.toHaveBeenCalled(); + expect(fs.readFileSync(userFile, 'utf8')).toBe('keep me'); + expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('is not empty')); + }); + + it('fails when degit exits successfully without creating a project', async () => { + const workspace = makeWorkspaceInfo(); + mockRunRemoteTemplateCommand.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from('destination directory is not empty, aborting'), + stderr: Buffer.alloc(0), + }); + + const result = await executeBuiltinTemplate(workspace, { + ...baseTemplateInfo, + command: 'vite:library', + targetDir: '.', + }); + + expect(result.exitCode).toBe(1); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('destination directory is not empty'), + ); + }); + + it('preserves a non-zero degit exit code and reports captured stderr', async () => { + const workspace = makeWorkspaceInfo(); + mockRunRemoteTemplateCommand.mockResolvedValue({ + exitCode: 7, + stdout: Buffer.alloc(0), + stderr: Buffer.from('failed to download template'), + }); + + const result = await executeBuiltinTemplate(workspace, { + ...baseTemplateInfo, + command: 'vite:library', + targetDir: '.', + }); + + expect(result.exitCode).toBe(7); + expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('failed to download')); + }); }); diff --git a/packages/cli/src/create/__tests__/prompts.spec.ts b/packages/cli/src/create/__tests__/prompts.spec.ts index 89b91546a1..ff8739346e 100644 --- a/packages/cli/src/create/__tests__/prompts.spec.ts +++ b/packages/cli/src/create/__tests__/prompts.spec.ts @@ -35,6 +35,23 @@ describe('target directory helpers', () => { expect(isTargetDirAvailable(targetDir)).toBe(false); }); + it.each(['file', 'directory'] as const)( + 'reports a directory containing only a .git %s as available', + (gitEntryType) => { + const cwd = makeTempDir(); + const targetDir = path.join(cwd, 'existing-worktree'); + fs.mkdirSync(targetDir, { recursive: true }); + const gitPath = path.join(targetDir, '.git'); + if (gitEntryType === 'file') { + fs.writeFileSync(gitPath, 'gitdir: ../.git/worktrees/existing-worktree'); + } else { + fs.mkdirSync(gitPath); + } + + expect(isTargetDirAvailable(targetDir)).toBe(true); + }, + ); + it('suggests a different target directory when the default already exists', () => { const cwd = makeTempDir(); fs.mkdirSync(path.join(cwd, 'fate-template'), { recursive: true }); diff --git a/packages/cli/src/create/command.ts b/packages/cli/src/create/command.ts index 778d84f2af..68f29091e7 100644 --- a/packages/cli/src/create/command.ts +++ b/packages/cli/src/create/command.ts @@ -9,6 +9,8 @@ import type { ExecutionResult, RunCommandOptions } from '../utils/command.ts'; * that call it; plain `runCommand` / `runCommandSilently` don't. */ export interface ExecutionWithProjectDir extends ExecutionResult { projectDir?: string; + stdout?: Buffer; + stderr?: Buffer; } export async function runCommandAndDetectProjectDir( diff --git a/packages/cli/src/create/templates/builtin.ts b/packages/cli/src/create/templates/builtin.ts index 3766f65bc3..7f233b6d00 100644 --- a/packages/cli/src/create/templates/builtin.ts +++ b/packages/cli/src/create/templates/builtin.ts @@ -1,4 +1,5 @@ import assert from 'node:assert'; +import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; @@ -7,11 +8,17 @@ import colors from 'picocolors'; import type { WorkspaceInfo } from '../../types/index.ts'; import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; +import { isTargetDirAvailable } from '../prompts.ts'; import { setPackageName } from '../utils.ts'; import { executeGeneratorScaffold } from './generator.ts'; import { runRemoteTemplateCommand } from './remote.ts'; import { BuiltinTemplate, type BuiltinTemplateInfo, LibraryTemplateRepo } from './types.ts'; +function reportLibraryScaffoldFailure(result: ExecutionWithProjectDir, fallback: string) { + const output = result.stderr?.toString().trim() || result.stdout?.toString().trim(); + prompts.log.error(output || fallback); +} + export async function executeBuiltinTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, @@ -31,10 +38,18 @@ export async function executeBuiltinTemplate( } templateInfo.args.unshift(templateInfo.targetDir); } else if (templateInfo.command === BuiltinTemplate.library) { + const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir); + // `degit --force` is needed when the destination only contains `.git`, + // which Vite+ deliberately treats as available. Re-check immediately + // before invoking degit so force is not used when user files are present. + if (!isTargetDirAvailable(fullPath)) { + prompts.log.error(`Target directory "${fullPath}" is not empty`); + return { exitCode: 1 }; + } // Use degit to download the template directly from GitHub const libraryTemplateInfo = discoverTemplate( LibraryTemplateRepo, - [templateInfo.targetDir], + [templateInfo.targetDir, '--force'], workspaceInfo, ); const result = await runRemoteTemplateCommand( @@ -45,9 +60,13 @@ export async function executeBuiltinTemplate( options?.silent ?? false, ); if (result.exitCode !== 0) { + reportLibraryScaffoldFailure(result, 'Failed to download the library template'); return { exitCode: result.exitCode }; } - const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir); + if (!fs.existsSync(path.join(fullPath, 'package.json'))) { + reportLibraryScaffoldFailure(result, 'Library template did not create package.json'); + return { exitCode: 1 }; + } setPackageName(fullPath, templateInfo.packageName); return { ...result, projectDir: templateInfo.targetDir }; }