Skip to content

Commit 4de4084

Browse files
authored
feat(create): support creating project in current directory (#1097)
## Summary - Support `vp create --directory .` to scaffold a project in the current directory without creating a new folder - Auto-derive package name from the current directory name (no interactive prompt), similar to `cargo init` / `npm init -y` - Fix `"./"` input to be treated equivalently to `"."` in `formatTargetDir` - Extract `deriveDefaultPackageName` to `utils.ts` for testability - Log the auto-derived package name when using `--directory .` Closes #1077
1 parent 15afbc0 commit 4de4084

9 files changed

Lines changed: 145 additions & 19 deletions

File tree

packages/cli/snap-tests-global/create-from-monorepo-subdir/snap.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,19 @@ Not in scripts/helper/
1919
> cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir
2020
> test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'
2121
Created at apps/custom-app with --directory
22+
23+
> mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir
24+
> test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'
25+
Created at apps/dot-test with --directory .
26+
27+
> vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail
28+
Cannot scaffold into the monorepo root directory. Use --directory to specify a target directory
29+
30+
31+
> 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
32+
Cannot scaffold inside existing package "website" (apps/website). Use --directory to specify a different location
33+
34+
35+
> cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail
36+
Cannot scaffold inside existing package "website" (apps/website). Use --directory to specify a different location
37+

packages/cli/snap-tests-global/create-from-monorepo-subdir/steps.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525
"command": "cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir",
2626
"ignoreOutput": true
2727
},
28-
"test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'"
28+
"test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'",
29+
30+
{
31+
"command": "mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir",
32+
"ignoreOutput": true
33+
},
34+
"test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'",
35+
36+
"vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail",
37+
38+
"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",
39+
40+
"cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail"
2941
]
3042
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
> mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory
2+
> test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'
3+
Created at my-app with --directory .
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"commands": [
3+
{
4+
"command": "mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory",
5+
"ignoreOutput": true
6+
},
7+
"test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'"
8+
]
9+
}

packages/cli/src/create/__tests__/__snapshots__/utils.spec.ts.snap

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,22 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

33
exports[`formatTargetDir > should format target dir with invalid input 1`] = `
4-
{
5-
"directory": "",
6-
"error": "Parsed package name "." is invalid: name cannot start with a period",
7-
"packageName": "",
8-
}
9-
`;
10-
11-
exports[`formatTargetDir > should format target dir with invalid input 2`] = `
124
{
135
"directory": "",
146
"error": "Absolute path is not allowed",
157
"packageName": "",
168
}
179
`;
1810

19-
exports[`formatTargetDir > should format target dir with invalid input 3`] = `
11+
exports[`formatTargetDir > should format target dir with invalid input 2`] = `
2012
{
2113
"directory": "",
2214
"error": "Parsed package name "@scope" is invalid: name can only contain URL-friendly characters",
2315
"packageName": "",
2416
}
2517
`;
2618

27-
exports[`formatTargetDir > should format target dir with invalid input 4`] = `
19+
exports[`formatTargetDir > should format target dir with invalid input 3`] = `
2820
{
2921
"directory": "",
3022
"error": "Relative path contains ".." which is not allowed",

packages/cli/src/create/__tests__/utils.spec.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { formatTargetDir, getProjectDirFromPackageName } from '../utils.js';
3+
import {
4+
deriveDefaultPackageName,
5+
formatTargetDir,
6+
getProjectDirFromPackageName,
7+
} from '../utils.js';
48

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

1216
describe('formatTargetDir', () => {
17+
it('should format "." as current directory with empty package name', () => {
18+
expect(formatTargetDir('.')).toEqual({
19+
directory: '.',
20+
packageName: '',
21+
});
22+
});
23+
24+
it('should format "./" as current directory with empty package name', () => {
25+
expect(formatTargetDir('./')).toEqual({
26+
directory: '.',
27+
packageName: '',
28+
});
29+
});
30+
1331
it('should format target dir with invalid input', () => {
14-
expect(formatTargetDir('.')).matchSnapshot();
1532
expect(formatTargetDir('/foo/bar')).matchSnapshot();
1633
expect(formatTargetDir('@scope/')).matchSnapshot();
1734
expect(formatTargetDir('../../foo/bar')).matchSnapshot();
@@ -45,3 +62,28 @@ describe('formatTargetDir', () => {
4562
expect(formatTargetDir('my-package@1.0.0').error).matchSnapshot();
4663
});
4764
});
65+
66+
describe('deriveDefaultPackageName', () => {
67+
it('should derive package name from directory basename', () => {
68+
expect(deriveDefaultPackageName('/home/user/my-app', undefined, 'fallback')).toBe('my-app');
69+
});
70+
71+
it('should derive scoped package name when scope is provided', () => {
72+
expect(deriveDefaultPackageName('/home/user/my-app', '@my-scope', 'fallback')).toBe(
73+
'@my-scope/my-app',
74+
);
75+
});
76+
77+
it('should fallback to random name when directory name is invalid', () => {
78+
const result = deriveDefaultPackageName('/home/user/.hidden', undefined, 'vite-plus-app');
79+
// directory name starts with '.', so a random name is generated instead
80+
expect(result).not.toBe('.hidden');
81+
expect(result.length).toBeGreaterThan(0);
82+
});
83+
84+
it('should fallback when directory is filesystem root', () => {
85+
const result = deriveDefaultPackageName('/', undefined, 'vite-plus-app');
86+
// basename of '/' is empty, so a random name is generated
87+
expect(result.length).toBeGreaterThan(0);
88+
});
89+
});

packages/cli/src/create/bin.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
} from './templates/index.js';
5454
import { InitialMonorepoAppDir } from './templates/monorepo.js';
5555
import { BuiltinTemplate, TemplateType } from './templates/types.js';
56-
import { formatTargetDir } from './utils.js';
56+
import { deriveDefaultPackageName, formatTargetDir } from './utils.js';
5757

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

573-
if (isBuiltinTemplate && !targetDir) {
574-
if (selectedTemplateName === BuiltinTemplate.monorepo) {
573+
if (isBuiltinTemplate && (!targetDir || targetDir === '.')) {
574+
if (targetDir === '.') {
575+
// Current directory: auto-derive package name from cwd, no prompt
576+
const fallbackName =
577+
selectedTemplateName === BuiltinTemplate.monorepo
578+
? 'vite-plus-monorepo'
579+
: `vite-plus-${selectedTemplateName.split(':')[1]}`;
580+
packageName = deriveDefaultPackageName(
581+
cwd,
582+
workspaceInfoOptional.monorepoScope,
583+
fallbackName,
584+
);
585+
if (isMonorepo) {
586+
if (!cwdRelativeToRoot) {
587+
// At monorepo root: scaffolding here would overwrite the entire workspace
588+
cancelAndExit(
589+
'Cannot scaffold into the monorepo root directory. Use --directory to specify a target directory',
590+
1,
591+
);
592+
}
593+
// Check if cwd is inside an existing workspace package
594+
const enclosingPackage = workspaceInfoOptional.packages.find(
595+
(pkg) => cwdRelativeToRoot === pkg.path || cwdRelativeToRoot.startsWith(`${pkg.path}/`),
596+
);
597+
if (enclosingPackage) {
598+
cancelAndExit(
599+
`Cannot scaffold inside existing package "${enclosingPackage.name}" (${enclosingPackage.path}). Use --directory to specify a different location`,
600+
1,
601+
);
602+
}
603+
// Resolve '.' to the path relative to rootDir
604+
// so that scaffolding happens in cwd, not at the workspace root
605+
targetDir = cwdRelativeToRoot;
606+
}
607+
prompts.log.info(`Using package name: ${accent(packageName)}`);
608+
} else if (selectedTemplateName === BuiltinTemplate.monorepo) {
575609
const selected = await promptPackageNameAndTargetDir(
576610
getRandomProjectName({ fallbackName: 'vite-plus-monorepo' }),
577611
options.interactive,
@@ -788,7 +822,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
788822
: selected.targetDir;
789823
}
790824
pauseCreateProgress();
791-
await checkProjectDirExists(targetDir, options.interactive);
825+
await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive);
792826
resumeCreateProgress();
793827
updateCreateProgress('Generating project');
794828
result = await executeBuiltinTemplate(

packages/cli/src/create/prompts.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export async function promptPackageNameAndTargetDir(
2424
if (value == null || value.length === 0) {
2525
return;
2626
}
27-
2827
const result = value ? validateNpmPackageName(value) : null;
2928
if (result?.validForNewPackages) {
3029
return;

packages/cli/src/create/utils.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'node:path';
44
import validateNpmPackageName from 'validate-npm-package-name';
55

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

89
// Helper functions for file operations
910
export function copy(src: string, dest: string) {
@@ -30,12 +31,12 @@ export function copyDir(srcDir: string, destDir: string) {
3031
* Examples:
3132
* ```
3233
* # invalid target directories
33-
* ./ -> { directory: '', packageName: '', error: 'Invalid target directory' }
3434
* /foo/bar -> { directory: '', packageName: '', error: 'Absolute path is not allowed' }
3535
* @scope/ -> { directory: '', packageName: '', error: 'Invalid target directory' }
3636
* ../../foo/bar -> { directory: '', packageName: '', error: 'Invalid target directory' }
3737
*
3838
* # valid target directories
39+
* . -> { directory: '.', packageName: '' }
3940
* ./my-package -> { directory: './my-package', packageName: 'my-package' }
4041
* ./foo/bar-package -> { directory: './foo/bar-package', packageName: 'bar-package' }
4142
* ./foo/bar-package/ -> { directory: './foo/bar-package', packageName: 'bar-package' }
@@ -52,6 +53,12 @@ export function formatTargetDir(input: string): {
5253
error?: string;
5354
} {
5455
let targetDir = path.normalize(input.trim());
56+
57+
// "." or "./" means current directory — valid directory, but no package name derivable
58+
if (targetDir === '.' || targetDir === `.${path.sep}`) {
59+
return { directory: '.', packageName: '' };
60+
}
61+
5562
const parsed = path.parse(targetDir);
5663
if (parsed.root || path.isAbsolute(targetDir)) {
5764
return {
@@ -120,3 +127,15 @@ export function formatDisplayTargetDir(targetDir: string) {
120127
}
121128
return `./${normalized}`;
122129
}
130+
131+
export function deriveDefaultPackageName(
132+
cwd: string,
133+
scope: string | undefined,
134+
fallbackName: string,
135+
): string {
136+
const dirName = path.basename(cwd);
137+
const candidate = scope ? `${scope}/${dirName}` : dirName;
138+
return validateNpmPackageName(candidate).validForNewPackages
139+
? candidate
140+
: getRandomProjectName({ scope, fallbackName });
141+
}

0 commit comments

Comments
 (0)