Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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" },
]
Original file line number Diff line number Diff line change
@@ -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 <version> pnpm <version>
→ 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
```
125 changes: 119 additions & 6 deletions packages/cli/src/create/__tests__/builtin.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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'));
});
});
17 changes: 17 additions & 0 deletions packages/cli/src/create/__tests__/prompts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/create/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/create/templates/builtin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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 };
}
Expand Down
Loading