From dc97f4701ec4b10a4cc5164f15943eca23cd415b Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:41:41 +0800 Subject: [PATCH 1/2] feat: add npm template support Add support for using npm packages as templates when creating new projects. This feature allows users to specify custom templates from npm registry with optional version control. - Support multiple npm template formats (npm:, @scope/package, package-name) - Add --template-version flag for version specification - Implement smart caching mechanism (.temp-templates/) - Support flexible template structures (template/, templates/app/, root) - Export utility functions for downstream projects --- README.md | 59 ++++++++++++++++ src/index.ts | 61 +++++++++++++++- src/template-manager.ts | 151 ++++++++++++++++++++++++++++++++++++++++ test/index.test.ts | 40 +++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/template-manager.ts 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..f438bff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,8 +23,18 @@ import { x } from 'tinyexec'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +import { isNpmTemplate, resolveCustomTemplate } from './template-manager.js'; + 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 +123,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 +175,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 +354,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; }; @@ -592,6 +610,48 @@ 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, + }); + + 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!'); + return; + } + const tools = await getTools(argv, extraTools, templateName); const skills = await getSkills( argv, @@ -601,7 +661,6 @@ export async function create({ multiselect, ); - const srcFolder = path.join(root, `template-${templateName}`); const commonFolder = path.join(root, 'template-common'); if (!fs.existsSync(srcFolder)) { diff --git a/src/template-manager.ts b/src/template-manager.ts new file mode 100644 index 0000000..a544b2b --- /dev/null +++ b/src/template-manager.ts @@ -0,0 +1,151 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const NPM_TEMPLATE_PREFIX = 'npm:'; + +/** + * 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'; + + // Generate cache key + const cacheKey = sanitizeCacheKey(normalizedName, versionSpecifier); + const cacheRoot = options?.cacheDir || process.cwd(); + const templateDir = path.join(cacheRoot, '.temp-templates', cacheKey); + const installRoot = path.dirname(templateDir); + 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 + 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 + try { + execSync( + `npm install ${normalizedName}@${versionSpecifier} --no-save --package-lock=false --no-audit --no-fund --silent`, + { + cwd: installRoot, + stdio: 'pipe', + }, + ); + } catch { + throw new Error( + `Failed to install npm template "${normalizedName}@${versionSpecifier}". Please check if the package exists.`, + ); + } + + // 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() + ) { + // Copy to cache directory + fs.mkdirSync(templateDir, { recursive: 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'); +}); From 82267f1a5d027745d6bdaaf3e00687f728c7b4c8 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:04:49 +0800 Subject: [PATCH 2/2] fix: address review feedback - Fix P1 command injection: replace execSync with execFileSync + args array, add input validation for package names and version specifiers - Fix P2 stale cache: clear templateDir contents before re-copying to prevent leftover files from older template versions - Fix concurrent install races: isolate npm install per cache key (.install subdirectory) instead of shared installRoot - Include npm stderr (first 5 lines) in error messages for easier debugging of install failures - Move template-manager import above executable statements - Extract logNextStepsAndOutro helper to remove duplicated code - Add template-manager unit tests covering validation, cache reuse, cache bypass for latest, and resolveCustomTemplate routing --- src/index.ts | 54 ++++++++++----------- src/template-manager.ts | 64 +++++++++++++++++++++---- test/template-manager.test.ts | 90 +++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 39 deletions(-) create mode 100644 test/template-manager.test.ts diff --git a/src/index.ts b/src/index.ts index f438bff..4685d35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,12 +19,11 @@ 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); -import { isNpmTemplate, resolveCustomTemplate } from './template-manager.js'; - export { autocomplete, groupMultiselect, multiselect, select, text }; // Export npm template utilities @@ -506,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, @@ -635,20 +655,7 @@ export async function create({ skipFiles, }); - 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); return; } @@ -793,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 index a544b2b..785d8cc 100644 --- a/src/template-manager.ts +++ b/src/template-manager.ts @@ -1,9 +1,14 @@ -import { execSync } from 'node:child_process'; +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 */ @@ -58,11 +63,27 @@ export function resolveNpmTemplate( ? 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); - const installRoot = path.dirname(templateDir); + // 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 @@ -74,6 +95,7 @@ export function resolveNpmTemplate( } // 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 }; @@ -84,18 +106,34 @@ export function resolveNpmTemplate( ); } - // Install the package + // Install the package using execFileSync to avoid shell injection try { - execSync( - `npm install ${normalizedName}@${versionSpecifier} --no-save --package-lock=false --no-audit --no-fund --silent`, + execFileSync( + 'npm', + [ + 'install', + `${normalizedName}@${versionSpecifier}`, + '--no-save', + '--package-lock=false', + '--no-audit', + '--no-fund', + '--silent', + ], { cwd: installRoot, stdio: 'pipe', }, ); - } catch { + } 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.`, + `Failed to install npm template "${normalizedName}@${versionSpecifier}". Please check if the package exists.${detail}`, ); } @@ -112,8 +150,16 @@ export function resolveNpmTemplate( fs.existsSync(pathCandidate) && fs.statSync(pathCandidate).isDirectory() ) { - // Copy to cache directory - fs.mkdirSync(templateDir, { recursive: true }); + // 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; 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'); +});