Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,19 @@ Not in scripts/helper/
> cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir
> test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'
Created at apps/custom-app with --directory

> mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir
> test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'
Created at apps/dot-test with --directory .

> vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail
Cannot scaffold into the monorepo root directory. Use --directory to specify a target directory


> mkdir -p apps/website/src && cd apps/website/src && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . inside existing package should fail
Cannot scaffold inside existing package "website" (apps/website). Use --directory to specify a different location


> cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail
Cannot scaffold inside existing package "website" (apps/website). Use --directory to specify a different location

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@
"command": "cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir",
"ignoreOutput": true
},
"test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'"
"test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'",

{
"command": "mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir",
"ignoreOutput": true
},
"test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'",

"vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail",

"mkdir -p apps/website/src && cd apps/website/src && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . inside existing package should fail",

"cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
> mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory
> test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'
Created at my-app with --directory .
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"commands": [
{
"command": "mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory",
"ignoreOutput": true
},
"test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'"
]
}
12 changes: 2 additions & 10 deletions packages/cli/src/create/__tests__/__snapshots__/utils.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`formatTargetDir > should format target dir with invalid input 1`] = `
{
"directory": "",
"error": "Parsed package name "." is invalid: name cannot start with a period",
"packageName": "",
}
`;

exports[`formatTargetDir > should format target dir with invalid input 2`] = `
{
"directory": "",
"error": "Absolute path is not allowed",
"packageName": "",
}
`;

exports[`formatTargetDir > should format target dir with invalid input 3`] = `
exports[`formatTargetDir > should format target dir with invalid input 2`] = `
{
"directory": "",
"error": "Parsed package name "@scope" is invalid: name can only contain URL-friendly characters",
"packageName": "",
}
`;

exports[`formatTargetDir > should format target dir with invalid input 4`] = `
exports[`formatTargetDir > should format target dir with invalid input 3`] = `
{
"directory": "",
"error": "Relative path contains ".." which is not allowed",
Expand Down
46 changes: 44 additions & 2 deletions packages/cli/src/create/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';

import { formatTargetDir, getProjectDirFromPackageName } from '../utils.js';
import {
deriveDefaultPackageName,
formatTargetDir,
getProjectDirFromPackageName,
} from '../utils.js';

describe('getProjectDirFromPackageName', () => {
it('should get project dir from package name', () => {
Expand All @@ -10,8 +14,21 @@ describe('getProjectDirFromPackageName', () => {
});

describe('formatTargetDir', () => {
it('should format "." as current directory with empty package name', () => {
expect(formatTargetDir('.')).toEqual({
directory: '.',
packageName: '',
});
});

it('should format "./" as current directory with empty package name', () => {
expect(formatTargetDir('./')).toEqual({
directory: '.',
packageName: '',
});
});

it('should format target dir with invalid input', () => {
expect(formatTargetDir('.')).matchSnapshot();
expect(formatTargetDir('/foo/bar')).matchSnapshot();
expect(formatTargetDir('@scope/')).matchSnapshot();
expect(formatTargetDir('../../foo/bar')).matchSnapshot();
Expand Down Expand Up @@ -45,3 +62,28 @@ describe('formatTargetDir', () => {
expect(formatTargetDir('my-package@1.0.0').error).matchSnapshot();
});
});

describe('deriveDefaultPackageName', () => {
it('should derive package name from directory basename', () => {
expect(deriveDefaultPackageName('/home/user/my-app', undefined, 'fallback')).toBe('my-app');
});

it('should derive scoped package name when scope is provided', () => {
expect(deriveDefaultPackageName('/home/user/my-app', '@my-scope', 'fallback')).toBe(
'@my-scope/my-app',
);
});

it('should fallback to random name when directory name is invalid', () => {
const result = deriveDefaultPackageName('/home/user/.hidden', undefined, 'vite-plus-app');
// directory name starts with '.', so a random name is generated instead
expect(result).not.toBe('.hidden');
expect(result.length).toBeGreaterThan(0);
});

it('should fallback when directory is filesystem root', () => {
const result = deriveDefaultPackageName('/', undefined, 'vite-plus-app');
// basename of '/' is empty, so a random name is generated
expect(result.length).toBeGreaterThan(0);
});
});
42 changes: 38 additions & 4 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
} from './templates/index.js';
import { InitialMonorepoAppDir } from './templates/monorepo.js';
import { BuiltinTemplate, TemplateType } from './templates/types.js';
import { formatTargetDir } from './utils.js';
import { deriveDefaultPackageName, formatTargetDir } from './utils.js';

const helpMessage = renderCliDoc({
usage: 'vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]',
Expand Down Expand Up @@ -570,8 +570,42 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
}
}

if (isBuiltinTemplate && !targetDir) {
if (selectedTemplateName === BuiltinTemplate.monorepo) {
if (isBuiltinTemplate && (!targetDir || targetDir === '.')) {
if (targetDir === '.') {
// Current directory: auto-derive package name from cwd, no prompt
const fallbackName =
selectedTemplateName === BuiltinTemplate.monorepo
? 'vite-plus-monorepo'
: `vite-plus-${selectedTemplateName.split(':')[1]}`;
packageName = deriveDefaultPackageName(
cwd,
workspaceInfoOptional.monorepoScope,
fallbackName,
);
if (isMonorepo) {
if (!cwdRelativeToRoot) {
// At monorepo root: scaffolding here would overwrite the entire workspace
cancelAndExit(
'Cannot scaffold into the monorepo root directory. Use --directory to specify a target directory',
1,
);
}
// Check if cwd is inside an existing workspace package
const enclosingPackage = workspaceInfoOptional.packages.find(
(pkg) => cwdRelativeToRoot === pkg.path || cwdRelativeToRoot.startsWith(`${pkg.path}/`),
);
if (enclosingPackage) {
cancelAndExit(
`Cannot scaffold inside existing package "${enclosingPackage.name}" (${enclosingPackage.path}). Use --directory to specify a different location`,
1,
);
}
// Resolve '.' to the path relative to rootDir
// so that scaffolding happens in cwd, not at the workspace root
targetDir = cwdRelativeToRoot;
}
prompts.log.info(`Using package name: ${accent(packageName)}`);
} else if (selectedTemplateName === BuiltinTemplate.monorepo) {
const selected = await promptPackageNameAndTargetDir(
getRandomProjectName({ fallbackName: 'vite-plus-monorepo' }),
options.interactive,
Expand Down Expand Up @@ -788,7 +822,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
: selected.targetDir;
}
pauseCreateProgress();
await checkProjectDirExists(targetDir, options.interactive);
await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive);
resumeCreateProgress();
updateCreateProgress('Generating project');
result = await executeBuiltinTemplate(
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/create/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export async function promptPackageNameAndTargetDir(
if (value == null || value.length === 0) {
return;
}

const result = value ? validateNpmPackageName(value) : null;
if (result?.validForNewPackages) {
return;
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/create/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import validateNpmPackageName from 'validate-npm-package-name';

import { editJsonFile } from '../utils/json.js';
import { getRandomProjectName } from './random-name.js';

// Helper functions for file operations
export function copy(src: string, dest: string) {
Expand All @@ -30,12 +31,12 @@ export function copyDir(srcDir: string, destDir: string) {
* Examples:
* ```
* # invalid target directories
* ./ -> { directory: '', packageName: '', error: 'Invalid target directory' }
* /foo/bar -> { directory: '', packageName: '', error: 'Absolute path is not allowed' }
* @scope/ -> { directory: '', packageName: '', error: 'Invalid target directory' }
* ../../foo/bar -> { directory: '', packageName: '', error: 'Invalid target directory' }
*
* # valid target directories
* . -> { directory: '.', packageName: '' }
* ./my-package -> { directory: './my-package', packageName: 'my-package' }
* ./foo/bar-package -> { directory: './foo/bar-package', packageName: 'bar-package' }
* ./foo/bar-package/ -> { directory: './foo/bar-package', packageName: 'bar-package' }
Expand All @@ -52,6 +53,12 @@ export function formatTargetDir(input: string): {
error?: string;
} {
let targetDir = path.normalize(input.trim());

// "." or "./" means current directory — valid directory, but no package name derivable
if (targetDir === '.' || targetDir === `.${path.sep}`) {
return { directory: '.', packageName: '' };
}

const parsed = path.parse(targetDir);
if (parsed.root || path.isAbsolute(targetDir)) {
return {
Expand Down Expand Up @@ -120,3 +127,15 @@ export function formatDisplayTargetDir(targetDir: string) {
}
return `./${normalized}`;
}

export function deriveDefaultPackageName(
cwd: string,
scope: string | undefined,
fallbackName: string,
): string {
const dirName = path.basename(cwd);
const candidate = scope ? `${scope}/${dirName}` : dirName;
return validateNpmPackageName(candidate).validForNewPackages
? candidate
: getRandomProjectName({ scope, fallbackName });
}
Loading