diff --git a/README.md b/README.md index eadbeb8..77b4203 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,65 @@ A shared package for create-rspack, create-rsbuild, create-rspress and create-rs npm add create-rstack -D ``` +## Features + +### NPM Template Support + +`create-rstack` supports using npm packages as templates, allowing users to create projects from custom templates published to npm. + +#### Usage + +```bash +# Using npm package name +npm create rsbuild@latest my-project -- --template my-template-package + +# Using scoped package +npm create rsbuild@latest my-project -- --template @scope/template-package + +# Using explicit npm: prefix +npm create rsbuild@latest my-project -- --template npm:my-template-package + +# With specific version +npm create rsbuild@latest my-project -- --template my-template-package --template-version 1.2.3 +``` + +#### Template Package Structure + +Your npm template package should have one of the following structures: + +``` +my-template-package/ +├── template/ # Preferred +│ ├── package.json +│ └── src/ +├── templates/ +│ └── app/ # Alternative +└── (root) # Fallback + ├── package.json + └── src/ +``` + +#### Caching Strategy + +- Templates with `latest` version are always re-installed to ensure the latest version +- Specific versions are cached in `.temp-templates/` for faster reuse + +#### API + +```typescript +import { + isNpmTemplate, + resolveCustomTemplate, + resolveNpmTemplate, +} from 'create-rstack'; + +// Check if template input is an npm package +if (isNpmTemplate(templateInput)) { + // Resolve npm template to local path + const templatePath = resolveCustomTemplate(templateInput, version); +} +``` + ## Examples | Project | Link | diff --git a/src/index.ts b/src/index.ts index 9ddacc1..4685d35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,12 +19,21 @@ import deepmerge from 'deepmerge'; import minimist from 'minimist'; import { color, logger } from 'rslog'; import { x } from 'tinyexec'; +import { isNpmTemplate, resolveCustomTemplate } from './template-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export { autocomplete, groupMultiselect, multiselect, select, text }; +// Export npm template utilities +export { + isNpmTemplate, + resolveCustomTemplate, + resolveNpmTemplate, + sanitizeCacheKey, +} from './template-manager.js'; + function cancelAndExit() { cancel('Operation cancelled.'); process.exit(0); @@ -113,6 +122,8 @@ export type Argv = { skill?: string | string[]; packageName?: string; 'package-name'?: string; + templateVersion?: string; + 'template-version'?: string; }; export const BUILTIN_TOOLS = ['eslint', 'rslint', 'biome', 'prettier']; @@ -163,6 +174,7 @@ function logHelpMessage( --tools add additional tools, comma separated ${skillsOptionLine} --override override files in target directory --packageName specify the package name + --template-version specify the npm template version Available templates: ${templates.join(', ')} @@ -341,6 +353,11 @@ const parseArgv = (processArgv: string[]) => { argv.packageName = argv['package-name']; } + // Handle template-version alias + if (argv['template-version']) { + argv.templateVersion = argv['template-version']; + } + return argv; }; @@ -488,6 +505,27 @@ async function runSkillCommand(skills: ExtraSkill[], cwd: string) { installationTaskLog.success(`Installed ${skillNoun} ${skillLabel}`); } +function logNextStepsAndOutro( + noteInformation: string[] | undefined, + targetDir: string, + packageManager: string, +) { + const nextSteps = noteInformation + ? noteInformation + : [ + `1. ${color.cyan(`cd ${targetDir}`)}`, + `2. ${color.cyan('git init')} ${color.dim('(optional)')}`, + `3. ${color.cyan(`${packageManager} install`)}`, + `4. ${color.cyan(`${packageManager} run dev`)}`, + ]; + + if (nextSteps.length) { + note(nextSteps.map((step) => color.reset(step)).join('\n'), 'Next steps'); + } + + outro('All set, happy coding!'); +} + export async function create({ name, root, @@ -592,6 +630,35 @@ export async function create({ } const templateName = await getTemplateName(argv); + + const srcFolder = path.join(root, `template-${templateName}`); + + // Handle npm template: only when the local template doesn't exist + // and the template input looks like an npm package + if ( + typeof argv.template === 'string' && + isNpmTemplate(argv.template) && + !fs.existsSync(srcFolder) + ) { + const templateVersion = argv.templateVersion ?? argv['template-version']; + const templatePath = resolveCustomTemplate(argv.template, templateVersion, { + cacheDir: root, + }); + + // Copy npm template directly to distFolder + copyFolder({ + from: templatePath, + to: distFolder, + version, + packageName, + templateParameters, + skipFiles, + }); + + logNextStepsAndOutro(noteInformation, targetDir, packageManager); + return; + } + const tools = await getTools(argv, extraTools, templateName); const skills = await getSkills( argv, @@ -601,7 +668,6 @@ export async function create({ multiselect, ); - const srcFolder = path.join(root, `template-${templateName}`); const commonFolder = path.join(root, 'template-common'); if (!fs.existsSync(srcFolder)) { @@ -734,20 +800,7 @@ export async function create({ ); } - const nextSteps = noteInformation - ? noteInformation - : [ - `1. ${color.cyan(`cd ${targetDir}`)}`, - `2. ${color.cyan('git init')} ${color.dim('(optional)')}`, - `3. ${color.cyan(`${packageManager} install`)}`, - `4. ${color.cyan(`${packageManager} run dev`)}`, - ]; - - if (nextSteps.length) { - note(nextSteps.map((step) => color.reset(step)).join('\n'), 'Next steps'); - } - - outro('All set, happy coding!'); + logNextStepsAndOutro(noteInformation, targetDir, packageManager); } function sortObjectKeys(obj: Record) { diff --git a/src/template-manager.ts b/src/template-manager.ts new file mode 100644 index 0000000..785d8cc --- /dev/null +++ b/src/template-manager.ts @@ -0,0 +1,197 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const NPM_TEMPLATE_PREFIX = 'npm:'; + +// Validates that a string looks like a valid npm package name/version +// to prevent unexpected values from being passed to npm install +const VALID_NPM_NAME = /^(@[\w.-]+\/)?[\w.-]+$/; +const VALID_NPM_VERSION = /^[\w.\-+^~>=<* ]+$/; + +/** + * Sanitize package name and version to create a valid cache key + */ +export const sanitizeCacheKey = (packageName: string, version: string) => { + // Keep the slash for scoped packages (e.g., @scope/package) + // but replace other slashes that would be invalid in file paths + const normalized = packageName.startsWith('@') + ? packageName + : packageName.replace(/[\\/]/g, '_'); + const versionLabel = version || 'latest'; + return `${normalized}@${versionLabel}`; +}; + +/** + * Check if the input is an npm package template + */ +export function isNpmTemplate(templateInput: string): boolean { + const trimmedInput = templateInput.trim(); + + // Explicit npm: prefix + if (trimmedInput.startsWith(NPM_TEMPLATE_PREFIX)) { + return true; + } + + // Scoped package (@scope/package) or pure package name (no path separators) + if ( + trimmedInput.startsWith('@') || + (!trimmedInput.includes('/') && + !trimmedInput.startsWith('http') && + !trimmedInput.startsWith('.') && + !trimmedInput.startsWith('github:')) + ) { + return true; + } + + return false; +} + +/** + * Resolve npm template package and return the local path + */ +export function resolveNpmTemplate( + packageName: string, + version?: string, + options?: { forceLatest?: boolean; cacheDir?: string }, +): string { + const normalizedName = packageName.trim(); + + // Handle version + const versionSpecifier = + version?.trim() && version.trim().toLowerCase() !== 'latest' + ? version.trim() + : 'latest'; + + // Validate inputs to prevent unexpected values from reaching npm + if (!VALID_NPM_NAME.test(normalizedName)) { + throw new Error( + `Invalid npm package name: "${normalizedName}". Package names may only contain word characters, hyphens, and dots.`, + ); + } + if ( + versionSpecifier !== 'latest' && + !VALID_NPM_VERSION.test(versionSpecifier) + ) { + throw new Error( + `Invalid version specifier: "${versionSpecifier}". Version may only contain word characters, dots, hyphens, and range operators.`, + ); + } + + // Generate cache key + const cacheKey = sanitizeCacheKey(normalizedName, versionSpecifier); + const cacheRoot = options?.cacheDir || process.cwd(); + const templateDir = path.join(cacheRoot, '.temp-templates', cacheKey); + // Isolate each install per cache key to avoid concurrent install races + const installRoot = path.join(templateDir, '.install'); + const packagePath = path.join(installRoot, 'node_modules', normalizedName); + + // Check if we should reuse cache + const forceLatest = options?.forceLatest ?? versionSpecifier === 'latest'; + const shouldReuseCache = !forceLatest && fs.existsSync(templateDir); + + if (shouldReuseCache) { + return templateDir; + } + + // Create isolated package.json to prevent workspace conflicts + fs.mkdirSync(installRoot, { recursive: true }); + const anchorPkgJson = path.join(installRoot, 'package.json'); + if (!fs.existsSync(anchorPkgJson)) { + const minimal = { name: 'create-rstack-template-cache', private: true }; + fs.writeFileSync( + anchorPkgJson, + `${JSON.stringify(minimal, null, 2)}\n`, + 'utf8', + ); + } + + // Install the package using execFileSync to avoid shell injection + try { + execFileSync( + 'npm', + [ + 'install', + `${normalizedName}@${versionSpecifier}`, + '--no-save', + '--package-lock=false', + '--no-audit', + '--no-fund', + '--silent', + ], + { + cwd: installRoot, + stdio: 'pipe', + }, + ); + } catch (err: unknown) { + const stderr = + err instanceof Error && 'stderr' in err + ? String((err as { stderr: unknown }).stderr).trim() + : ''; + const detail = stderr + ? `\n${stderr.split('\n').slice(0, 5).join('\n')}` + : ''; + throw new Error( + `Failed to install npm template "${normalizedName}@${versionSpecifier}". Please check if the package exists.${detail}`, + ); + } + + // Find template directory (by priority) + const possibleTemplatePaths = [ + path.join(packagePath, 'template'), // Priority: package/template + path.join(packagePath, 'templates', 'app'), + path.join(packagePath, 'templates', 'default'), + packagePath, // Fallback: package root + ]; + + for (const pathCandidate of possibleTemplatePaths) { + if ( + fs.existsSync(pathCandidate) && + fs.statSync(pathCandidate).isDirectory() + ) { + // Clear stale cache before copying to avoid leftover files from older versions + if (fs.existsSync(templateDir)) { + for (const entry of fs.readdirSync(templateDir)) { + if (entry === '.install') continue; + fs.rmSync(path.join(templateDir, entry), { + recursive: true, + force: true, + }); + } + } + // eslint-disable-next-line n/no-unsupported-features/node-builtins + fs.cpSync(pathCandidate, templateDir, { recursive: true }); + return templateDir; + } + } + + throw new Error( + `No valid template directory found in package "${normalizedName}". Expected one of: template/, templates/app/, templates/default/, or package root.`, + ); +} + +/** + * Resolve custom template (npm package, GitHub, or local path) + */ +export function resolveCustomTemplate( + templateInput: string, + version?: string, + options?: { forceLatest?: boolean; cacheDir?: string }, +): string { + const trimmedInput = templateInput.trim(); + + // Handle npm: prefix explicitly + if (trimmedInput.startsWith(NPM_TEMPLATE_PREFIX)) { + const packageName = trimmedInput.slice(NPM_TEMPLATE_PREFIX.length).trim(); + return resolveNpmTemplate(packageName, version, options); + } + + // Handle scoped package or pure package name + if (isNpmTemplate(trimmedInput)) { + return resolveNpmTemplate(trimmedInput, version, options); + } + + // For GitHub URLs or local paths, return as-is (handled by create-rstack) + return trimmedInput; +} diff --git a/test/index.test.ts b/test/index.test.ts index db97455..6cb549a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,7 +3,11 @@ import { expect, test } from '@rstest/core'; import { checkCancel, create, + isNpmTemplate, multiselect, + resolveCustomTemplate, + resolveNpmTemplate, + sanitizeCacheKey, select, text, } from '../dist/index.js'; @@ -22,3 +26,39 @@ test('should expose selected clack prompt helpers from src entrypoint', () => { expect(publicApi.multiselect).toBe(promptsActual.multiselect); expect(publicApi.groupMultiselect).toBe(promptsActual.groupMultiselect); }); + +test('should export npm template utilities', () => { + expect(typeof isNpmTemplate).toBe('function'); + expect(typeof resolveCustomTemplate).toBe('function'); + expect(typeof resolveNpmTemplate).toBe('function'); + expect(typeof sanitizeCacheKey).toBe('function'); +}); + +test('should detect npm templates correctly', () => { + // npm: prefix + expect(isNpmTemplate('npm:my-package')).toBe(true); + expect(isNpmTemplate('npm:@scope/package')).toBe(true); + + // Scoped packages + expect(isNpmTemplate('@scope/package')).toBe(true); + + // Pure package names + expect(isNpmTemplate('my-package')).toBe(true); + expect(isNpmTemplate('my-package-name')).toBe(true); + + // Not npm templates + expect(isNpmTemplate('./local-path')).toBe(false); + expect(isNpmTemplate('../relative-path')).toBe(false); + expect(isNpmTemplate('github:user/repo')).toBe(false); + expect(isNpmTemplate('https://example.com')).toBe(false); + expect(isNpmTemplate('/absolute/path')).toBe(false); +}); + +test('should sanitize cache keys correctly', () => { + expect(sanitizeCacheKey('my-package', '1.0.0')).toBe('my-package@1.0.0'); + expect(sanitizeCacheKey('@scope/package', 'latest')).toBe( + '@scope/package@latest', + ); + expect(sanitizeCacheKey('my-package', '')).toBe('my-package@latest'); + expect(sanitizeCacheKey('my/package', '1.0.0')).toBe('my_package@1.0.0'); +}); diff --git a/test/template-manager.test.ts b/test/template-manager.test.ts new file mode 100644 index 0000000..4062caa --- /dev/null +++ b/test/template-manager.test.ts @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeEach, expect, test } from '@rstest/core'; +import { + isNpmTemplate, + resolveCustomTemplate, + resolveNpmTemplate, + sanitizeCacheKey, +} from '../src/template-manager.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testDir = path.join(__dirname, 'fixtures', 'template-manager-test'); + +beforeEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + return () => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }; +}); + +test('should reject invalid package names', () => { + expect(() => + resolveNpmTemplate('foo;echo hi', undefined, { cacheDir: testDir }), + ).toThrow('Invalid npm package name'); + expect(() => + resolveNpmTemplate('$(whoami)', undefined, { cacheDir: testDir }), + ).toThrow('Invalid npm package name'); + expect(() => + resolveNpmTemplate('foo bar', undefined, { cacheDir: testDir }), + ).toThrow('Invalid npm package name'); +}); + +test('should reject invalid version specifiers', () => { + expect(() => + resolveNpmTemplate('my-pkg', '1.0.0;echo hi', { cacheDir: testDir }), + ).toThrow('Invalid version specifier'); + expect(() => + resolveNpmTemplate('my-pkg', '$(whoami)', { cacheDir: testDir }), + ).toThrow('Invalid version specifier'); +}); + +test('resolveCustomTemplate should strip npm: prefix and delegate', () => { + // npm: prefix with invalid name still triggers validation + expect(() => + resolveCustomTemplate('npm:$(bad)', undefined, { cacheDir: testDir }), + ).toThrow('Invalid npm package name'); +}); + +test('resolveCustomTemplate should return local paths as-is', () => { + expect(resolveCustomTemplate('./local-path')).toBe('./local-path'); + expect(resolveCustomTemplate('../relative')).toBe('../relative'); + expect(resolveCustomTemplate('/absolute/path')).toBe('/absolute/path'); + expect(resolveCustomTemplate('github:user/repo')).toBe('github:user/repo'); +}); + +test('should reuse cache for pinned versions', () => { + const cacheKey = sanitizeCacheKey('my-pkg', '1.0.0'); + const templateDir = path.join(testDir, '.temp-templates', cacheKey); + + // Pre-populate cache + fs.mkdirSync(templateDir, { recursive: true }); + fs.writeFileSync(path.join(templateDir, 'package.json'), '{}'); + + // Should return cached path without calling npm install + const result = resolveNpmTemplate('my-pkg', '1.0.0', { cacheDir: testDir }); + expect(result).toBe(templateDir); +}); + +test('should not reuse cache for latest version', () => { + const cacheKey = sanitizeCacheKey('nonexistent-pkg-12345', 'latest'); + const templateDir = path.join(testDir, '.temp-templates', cacheKey); + + // Pre-populate cache + fs.mkdirSync(templateDir, { recursive: true }); + fs.writeFileSync(path.join(templateDir, 'package.json'), '{}'); + + // latest should try to reinstall (and fail for nonexistent package) + expect(() => + resolveNpmTemplate('nonexistent-pkg-12345', undefined, { + cacheDir: testDir, + }), + ).toThrow('Failed to install'); +});