diff --git a/packages/cli/src/create/__tests__/utils.spec.ts b/packages/cli/src/create/__tests__/utils.spec.ts index 4217b4b216..611d99a986 100644 --- a/packages/cli/src/create/__tests__/utils.spec.ts +++ b/packages/cli/src/create/__tests__/utils.spec.ts @@ -111,12 +111,40 @@ describe('deriveDefaultPackageName', () => { }); 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'); + const result = deriveDefaultPackageName('/home/user/!!!', undefined, 'vite-plus-app'); + // nothing in the directory name survives sanitization, so a random name is generated instead + expect(result).not.toContain('!'); expect(result.length).toBeGreaterThan(0); }); + it('should sanitize a directory name rather than inventing an unrelated one', () => { + expect( + deriveDefaultPackageName( + '/home/user/ComfyUI-DenoiseHQNodes.feat-1-save-image-node', + undefined, + 'vite-plus-app', + ), + ).toBe('comfyui-denoisehqnodes.feat-1-save-image-node'); + }); + + it('should replace characters that are not valid in a package name', () => { + expect(deriveDefaultPackageName('/home/user/My App (v2)!', undefined, 'vite-plus-app')).toBe( + 'my-app-v2', + ); + }); + + it('should strip leading characters npm forbids instead of generating a name', () => { + expect(deriveDefaultPackageName('/home/user/.hidden', undefined, 'vite-plus-app')).toBe( + 'hidden', + ); + }); + + it('should sanitize the directory name while keeping the scope', () => { + expect(deriveDefaultPackageName('/home/user/My-App', '@my-scope', 'vite-plus-app')).toBe( + '@my-scope/my-app', + ); + }); + 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 diff --git a/packages/cli/src/create/utils.ts b/packages/cli/src/create/utils.ts index 3dc77c3081..efe109a43a 100644 --- a/packages/cli/src/create/utils.ts +++ b/packages/cli/src/create/utils.ts @@ -274,6 +274,21 @@ export function formatDisplayTargetDir(targetDir: string) { return `./${normalized}`; } +const MAX_PACKAGE_NAME_LENGTH = 214; + +// Turn a directory name into the closest npm-compatible name: lowercase it and +// replace characters npm rejects, so a directory like `My-App.v2` keeps its +// identity as `my-app.v2` instead of being swapped for an unrelated name. +function sanitizePackageNameSegment(dirName: string): string { + return dirName + .toLowerCase() + .replace(/[^a-z0-9\-._]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[-._]+/, '') + .slice(0, MAX_PACKAGE_NAME_LENGTH) + .replace(/[-.]+$/, ''); +} + export function deriveDefaultPackageName( cwd: string, scope: string | undefined, @@ -281,7 +296,15 @@ export function deriveDefaultPackageName( ): string { const dirName = path.basename(cwd); const candidate = scope ? `${scope}/${dirName}` : dirName; - return validateNpmPackageName(candidate).validForNewPackages - ? candidate - : getRandomProjectName({ scope, fallbackName }); + if (validateNpmPackageName(candidate).validForNewPackages) { + return candidate; + } + const sanitized = sanitizePackageNameSegment(dirName); + if (sanitized) { + const sanitizedCandidate = scope ? `${scope}/${sanitized}` : sanitized; + if (validateNpmPackageName(sanitizedCandidate).validForNewPackages) { + return sanitizedCandidate; + } + } + return getRandomProjectName({ scope, fallbackName }); }