diff --git a/docs/guide/create.md b/docs/guide/create.md index d9ff2c6d91..200be46aaa 100644 --- a/docs/guide/create.md +++ b/docs/guide/create.md @@ -185,7 +185,7 @@ An invalid manifest is a hard error, not a silent fall-through — a maintainer ### Bundled subdirectory templates -Relative `./...` paths resolve against the enclosing `@org/create` package root — **not** the user's cwd. The referenced directory is copied verbatim into the target project (no template-engine processing). Paths that escape the package root are rejected. +Relative `./...` paths resolve against the enclosing `@org/create` package root — **not** the user's cwd. The referenced directory is copied into the target project as-is (no template-engine processing); the only exception is that a small set of underscore-prefixed scaffold files (`_gitignore`, `_npmrc`, `_yarnrc.yml`) are renamed to their dotfile equivalents. Paths that escape the package root are rejected. ### Make the org the default in a repo diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/mock-manifest.json b/packages/cli/snap-tests/create-org-bundled-dotfiles/mock-manifest.json new file mode 100644 index 0000000000..fa33cc7366 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-dotfiles/mock-manifest.json @@ -0,0 +1,24 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-e7obtbeDFpoRewJvBuspE70GOluDTs3tZ6N1sMTOGlSjphtT5sMH00OkerY9SFX8ESXixKPOIp5fJkHqdxLn1Q==" + }, + "createConfig": { + "templates": [ + { + "name": "demo", + "description": "Bundled demo template with dotfiles", + "template": "./templates/demo" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt b/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt new file mode 100644 index 0000000000..2586a65241 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt @@ -0,0 +1,21 @@ +> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc +◇ Scaffolded my-demo-app +• Node pnpm +→ Next: cd my-demo-app && vp run + +> ls -A my-demo-app # verify _gitignore/_npmrc were renamed and no underscore variants remain +.gitignore +.npmrc +.vite-hooks +AGENTS.md +package.json +pnpm-workspace.yaml +src +vite.config.ts + +> cat my-demo-app/.gitignore # verify _gitignore content was preserved +node_modules +dist + +> cat my-demo-app/.npmrc # verify _npmrc content was preserved +auto-install-peers=true diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json b/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json new file mode 100644 index 0000000000..7be80d20e2 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc", + "ls -A my-demo-app # verify _gitignore/_npmrc were renamed and no underscore variants remain", + "cat my-demo-app/.gitignore # verify _gitignore content was preserved", + "cat my-demo-app/.npmrc # verify _npmrc content was preserved" + ] +} diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/tarballs/create-1.0.0.tgz b/packages/cli/snap-tests/create-org-bundled-dotfiles/tarballs/create-1.0.0.tgz new file mode 100644 index 0000000000..33fb4f418d Binary files /dev/null and b/packages/cli/snap-tests/create-org-bundled-dotfiles/tarballs/create-1.0.0.tgz differ diff --git a/packages/cli/src/create/__tests__/utils.spec.ts b/packages/cli/src/create/__tests__/utils.spec.ts index c2618b3be1..23d81941f1 100644 --- a/packages/cli/src/create/__tests__/utils.spec.ts +++ b/packages/cli/src/create/__tests__/utils.spec.ts @@ -9,6 +9,7 @@ import { ensureGitignoreNodeModules, formatTargetDir, getProjectDirFromPackageName, + renameFiles, } from '../utils.js'; describe('getProjectDirFromPackageName', () => { @@ -158,3 +159,55 @@ describe('ensureGitignoreNodeModules', () => { expect(gitignore()).toBe('!node_modules\nnode_modules\n'); }); }); + +describe('renameFiles', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-rename-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function write(name: string, content: string): void { + fs.writeFileSync(path.join(projectDir, name), content); + } + + function read(name: string): string { + return fs.readFileSync(path.join(projectDir, name), 'utf-8'); + } + + function exists(name: string): boolean { + return fs.existsSync(path.join(projectDir, name)); + } + + it('renames `_gitignore` to `.gitignore`', () => { + write('_gitignore', 'node_modules\n'); + renameFiles(projectDir); + expect(exists('_gitignore')).toBe(false); + expect(read('.gitignore')).toBe('node_modules\n'); + }); + + it('renames `_npmrc` and `_yarnrc.yml`', () => { + write('_npmrc', 'auto-install-peers=true\n'); + write('_yarnrc.yml', 'nodeLinker: node-modules\n'); + renameFiles(projectDir); + expect(exists('_npmrc')).toBe(false); + expect(exists('_yarnrc.yml')).toBe(false); + expect(read('.npmrc')).toBe('auto-install-peers=true\n'); + expect(read('.yarnrc.yml')).toBe('nodeLinker: node-modules\n'); + }); + + it('is a no-op when no source files exist', () => { + expect(() => renameFiles(projectDir)).not.toThrow(); + expect(fs.readdirSync(projectDir)).toEqual([]); + }); + + it('leaves unmapped underscore files untouched', () => { + write('_foo', 'bar\n'); + renameFiles(projectDir); + expect(read('_foo')).toBe('bar\n'); + }); +}); diff --git a/packages/cli/src/create/templates/bundled.ts b/packages/cli/src/create/templates/bundled.ts index 316f32dfb5..f85018f29e 100644 --- a/packages/cli/src/create/templates/bundled.ts +++ b/packages/cli/src/create/templates/bundled.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { WorkspaceInfo } from '../../types/index.ts'; import type { ExecutionWithProjectDir } from '../command.ts'; -import { copyDir, setPackageName } from '../utils.ts'; +import { copyDir, renameFiles, setPackageName } from '../utils.ts'; import type { BuiltinTemplateInfo } from './types.ts'; /** @@ -30,6 +30,8 @@ export async function executeBundledTemplate( throw error; } + renameFiles(destDir); + try { setPackageName(destDir, templateInfo.packageName); } catch { diff --git a/packages/cli/src/create/templates/monorepo.ts b/packages/cli/src/create/templates/monorepo.ts index 3d72da0d47..03646972ef 100644 --- a/packages/cli/src/create/templates/monorepo.ts +++ b/packages/cli/src/create/templates/monorepo.ts @@ -10,7 +10,7 @@ import { editJsonFile } from '../../utils/json.ts'; import { templatesDir } from '../../utils/path.ts'; import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; -import { copyDir, formatDisplayTargetDir, setPackageName } from '../utils.ts'; +import { copyDir, formatDisplayTargetDir, renameFiles, setPackageName } from '../utils.ts'; import { runRemoteTemplateCommand } from './remote.ts'; import { type BuiltinTemplateInfo, LibraryTemplateRepo } from './types.ts'; @@ -158,21 +158,6 @@ export async function executeMonorepoTemplate( return { exitCode: 0, projectDir: templateInfo.targetDir }; } -const RENAME_FILES: Record = { - _gitignore: '.gitignore', - _npmrc: '.npmrc', - '_yarnrc.yml': '.yarnrc.yml', -}; - -function renameFiles(projectDir: string) { - for (const [from, to] of Object.entries(RENAME_FILES)) { - const fromPath = path.join(projectDir, from); - if (fs.existsSync(fromPath)) { - fs.renameSync(fromPath, path.join(projectDir, to)); - } - } -} - function getScopeFromPackageName(packageName: string) { if (packageName.startsWith('@')) { return packageName.split('/')[0]; diff --git a/packages/cli/src/create/utils.ts b/packages/cli/src/create/utils.ts index 0f6fcea105..173336d923 100644 --- a/packages/cli/src/create/utils.ts +++ b/packages/cli/src/create/utils.ts @@ -112,6 +112,22 @@ export function setPackageName(projectDir: string, packageName: string) { }); } +const RENAME_FILES = { + _gitignore: '.gitignore', + _npmrc: '.npmrc', + '_yarnrc.yml': '.yarnrc.yml', +} as const; + +/** Rename underscore-prefixed scaffold files to their dotfile names in `projectDir`. */ +export function renameFiles(projectDir: string): void { + for (const [from, to] of Object.entries(RENAME_FILES)) { + const fromPath = path.join(projectDir, from); + if (fs.existsSync(fromPath)) { + fs.renameSync(fromPath, path.join(projectDir, to)); + } + } +} + /** * Make sure the scaffolded project's `.gitignore` excludes `node_modules`. *