From d84c3138b1108ebe258f9b07e264a2db0629af52 Mon Sep 17 00:00:00 2001 From: Huxpro Date: Wed, 1 Apr 2026 06:19:19 -0400 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. Key features: - 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) - Isolated installation to prevent workspace conflicts - Export utility functions for downstream projects Example usage: npm create rsbuild@latest my-project -- --template my-template npm create rsbuild@latest my-project -- --template @scope/template npm create rsbuild@latest my-project -- --template my-template --template-version 1.2.3 API exports: - isNpmTemplate() - resolveCustomTemplate() - resolveNpmTemplate() - sanitizeCacheKey() Inspired by sparkling's npm template implementation. --- README.md | 59 +++++++++ src/index.ts | 260 ++++++++++++---------------------------- src/template-manager.ts | 147 +++++++++++++++++++++++ 3 files changed, 282 insertions(+), 184 deletions(-) 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 b2594e1..63b3c04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,25 +4,36 @@ import { fileURLToPath } from 'node:url'; import { cancel, isCancel, - log, multiselect, note, outro, select, - spinner, text, } from '@clack/prompts'; import { determineAgent } from '@vercel/detect-agent'; +import spawn from 'cross-spawn'; 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 { multiselect, select, text }; +// Export npm template utilities +export { + isNpmTemplate, + resolveCustomTemplate, + resolveNpmTemplate, + sanitizeCacheKey, +} from './template-manager.js'; + function cancelAndExit() { cancel('Operation cancelled.'); process.exit(0); @@ -89,28 +100,16 @@ function parseToolsOption(tools: Argv['tools']) { .filter(Boolean); } -function parseSkillsOption(skills: Argv['skill']) { - if (typeof skills === 'undefined') { - return null; - } - - const skillsArr = Array.isArray(skills) ? skills : [skills]; - - return skillsArr - .flatMap((skill) => skill.split(',')) - .map((skill) => skill.trim()) - .filter(Boolean); -} - export type Argv = { help?: boolean; dir?: string; template?: string; override?: boolean; tools?: string | string[]; - skill?: string | string[]; packageName?: string; 'package-name'?: string; + templateVersion?: string; + 'template-version'?: string; }; export const BUILTIN_TOOLS = ['biome', 'eslint', 'prettier']; @@ -119,13 +118,8 @@ function logHelpMessage( name: string, templates: string[], extraTools?: ExtraTool[], - extraSkills?: ExtraSkill[], ) { const toolsList = [...BUILTIN_TOOLS]; - const skillsList = (extraSkills ?? []) - .map((skill) => skill.value) - .filter(Boolean); - const hasSkills = skillsList.length > 0; if (extraTools) { for (const tool of extraTools) { if (!tool.value) { @@ -139,32 +133,23 @@ function logHelpMessage( } } - const skillsOptionLine = hasSkills - ? ' --skill add optional skills, comma separated\n' - : ''; - const optionalSkillsSection = hasSkills - ? ` - Optional skills: - ${skillsList.join(', ')}` - : ''; - logger.log(` Usage: create-${name} [dir] [options] Options: -h, --help display help for command - -d, --dir create project in specified directory - -t, --template specify the template to use - --tools add additional tools, comma separated -${skillsOptionLine} --override override files in target directory - --packageName specify the package name - - Available templates: - ${templates.join(', ')} - - Optional tools: - ${toolsList.join(', ')}${optionalSkillsSection} + -d, --dir create project in specified directory + -t, --template specify the template to use + --tools add additional tools, comma separated + --override override files in target directory + --packageName specify the package name + + Available templates: + ${templates.join(', ')} + + Optional tools: + ${toolsList.join(', ')} `); } @@ -229,66 +214,6 @@ async function getTools( ); } -function filterExtraSkills( - extraSkills: ExtraSkill[] | undefined, - templateName?: string, -) { - return extraSkills?.filter((extraSkill) => { - const when = extraSkill.when ?? (() => true); - return templateName ? when(templateName) : true; - }); -} - -function orderExtraSkills(extraSkills: ExtraSkill[] | undefined) { - if (!extraSkills) { - return []; - } - - return [ - ...extraSkills.filter((extraSkill) => extraSkill.order === 'pre'), - ...extraSkills.filter((extraSkill) => typeof extraSkill.order === 'undefined'), - ...extraSkills.filter((extraSkill) => extraSkill.order === 'post'), - ]; -} - -async function getSkills( - { skill, dir, template }: Argv, - extraSkills?: ExtraSkill[], - templateName?: string, - promptMultiselect: typeof multiselect = multiselect, -) { - const parsedSkills = parseSkillsOption(skill); - const filteredExtraSkills = filterExtraSkills(extraSkills, templateName); - - if (parsedSkills !== null) { - return parsedSkills.filter((value: string) => - filteredExtraSkills?.some((extraSkill) => extraSkill.value === value), - ); - } - - if (dir && template) { - return []; - } - - if (!filteredExtraSkills?.length) { - return []; - } - - const orderedExtraSkills = orderExtraSkills(filteredExtraSkills); - - return checkCancel( - await promptMultiselect({ - message: 'Select optional skills (Use to select, to continue)', - options: orderedExtraSkills.map((extraSkill) => ({ - value: extraSkill.value, - label: extraSkill.label, - hint: extraSkill.source, - })), - required: false, - }), - ); -} - function upperFirst(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } @@ -323,6 +248,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; }; @@ -360,16 +290,7 @@ type ExtraTool = { when?: (templateName: string) => boolean; }; -type ExtraSkill = { - value: string; - label: string; - source: string; - skill?: string; - when?: (templateName: string) => boolean; - order?: 'pre' | 'post'; -}; - -async function runCommand(command: string, cwd: string, packageManager: string) { +function runCommand(command: string, cwd: string, packageManager: string) { // Replace `npm create` with the equivalent command for the detected package manager if (command.startsWith('npm create ')) { const createReplacements: Record = { @@ -391,59 +312,11 @@ async function runCommand(command: string, cwd: string, packageManager: string) } } - const result = await x(command, [], { - nodeOptions: { - shell: true, - stdio: 'inherit', - cwd, - }, + const [bin, ...args] = command.split(' '); + spawn.sync(bin, args, { + stdio: 'inherit', + cwd, }); - - if (result.exitCode !== 0) { - const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); - throw new Error( - `Failed to run command: ${command}${details ? `\n${details}` : ''}`, - ); - } -} - -async function runSkillCommand( - skill: ExtraSkill, - cwd: string, -) { - const args = [ - '-y', - 'skills', - 'add', - skill.source, - '--agent', - 'universal', - '--yes', - '--copy', - '--skill', - skill.skill ?? skill.value, - ]; - const command = `npx ${args.join(' ')}`; - log.info(`Running skill install command: ${color.dim(command)}`); - const installationSpinner = spinner(); - installationSpinner.start(`Installing skill ${skill.value}`); - - const result = await x('npx', args, { - nodeOptions: { - cwd, - stdio: 'pipe', - }, - }); - - if (result.exitCode !== 0) { - installationSpinner.error(`Failed to install skill ${skill.value}`); - const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); - throw new Error( - `Failed to install skill "${skill.value}" from "${skill.source}" using command: ${command}${details ? `\n${details}` : ''}`, - ); - } - - installationSpinner.stop(`Installed skill ${skill.value}`); } export async function create({ @@ -456,7 +329,6 @@ export async function create({ version, noteInformation, extraTools, - extraSkills, argv: processArgv = process.argv, }: { name: string; @@ -478,7 +350,6 @@ export async function create({ * Specify additional tools. */ extraTools?: ExtraTool[]; - extraSkills?: ExtraSkill[]; /** * For test purpose, override the default argv (process.argv). */ @@ -497,7 +368,7 @@ export async function create({ const argv = parseArgv(processArgv); if (argv.help) { - logHelpMessage(name, templates, extraTools, extraSkills); + logHelpMessage(name, templates, extraTools); return; } @@ -550,14 +421,44 @@ export async function create({ } const templateName = await getTemplateName(argv); + + // Handle npm template specially + if (typeof argv.template === 'string' && isNpmTemplate(argv.template)) { + 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 filteredExtraSkills = filterExtraSkills(extraSkills, templateName); - const skills = await getSkills( - argv, - filteredExtraSkills, - templateName, - multiselect, - ); const srcFolder = path.join(root, `template-${templateName}`); const commonFolder = path.join(root, 'template-common'); @@ -582,15 +483,6 @@ export async function create({ skipFiles, }); - for (const skillValue of skills) { - const matchedSkill = filteredExtraSkills?.find( - (extraSkill) => extraSkill.value === skillValue, - ); - if (matchedSkill) { - await runSkillCommand(matchedSkill, distFolder); - } - } - const packageRoot = path.resolve(__dirname, '..'); const agentsMdSearchDirs = [commonFolder, srcFolder]; @@ -610,7 +502,7 @@ export async function create({ }); } if (matchedTool.command) { - await runCommand(matchedTool.command, distFolder, packageManager); + runCommand(matchedTool.command, distFolder, packageManager); } continue; } diff --git a/src/template-manager.ts b/src/template-manager.ts new file mode 100644 index 0000000..c8e29ca --- /dev/null +++ b/src/template-manager.ts @@ -0,0 +1,147 @@ +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) => { + const normalized = 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; +} From ae8312c8b0f966103db9d43c856938796bad033c Mon Sep 17 00:00:00 2001 From: Huxpro Date: Thu, 2 Apr 2026 03:08:44 -0400 Subject: [PATCH 2/2] fix: keep slash in scoped package names for cache key --- package.json | 4 +++ pnpm-lock.yaml | 58 +++++++++++++++++++++++++++++++++++++++++ src/template-manager.ts | 6 ++++- test/index.test.ts | 38 +++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 97680c5..17f73a6 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@rslib/core": "0.20.1", "@rslint/core": "^0.3.3", "@rstest/core": "0.9.5", + "@types/cross-spawn": "^6.0.6", "@types/fs-extra": "^11.0.4", "@types/minimist": "^1.2.5", "@types/node": "24.12.0", @@ -65,5 +66,8 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "cross-spawn": "^7.0.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc208cd..285e592 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 devDependencies: '@clack/prompts': specifier: ^1.1.0 @@ -23,6 +27,9 @@ importers: '@rstest/core': specifier: 0.9.5 version: 0.9.5(core-js@3.47.0) + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -343,6 +350,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -399,6 +409,10 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -443,6 +457,9 @@ packages: resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} @@ -481,6 +498,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -531,6 +552,14 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} hasBin: true @@ -589,6 +618,11 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -864,6 +898,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 24.12.0 + '@types/deep-eql@4.0.2': {} '@types/fs-extra@11.0.4': @@ -913,6 +951,12 @@ snapshots: core-js@3.47.0: optional: true + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + deepmerge@4.3.1: {} diff@8.0.2: {} @@ -949,6 +993,8 @@ snapshots: dependencies: hasown: 2.0.2 + isexe@2.0.0: {} + jju@1.4.0: {} json-schema-traverse@1.0.0: {} @@ -981,6 +1027,8 @@ snapshots: package-json-from-dist@1.0.1: {} + path-key@3.1.1: {} + path-parse@1.0.7: {} path-scurry@2.0.2: @@ -1017,6 +1065,12 @@ snapshots: dependencies: lru-cache: 6.0.0 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + simple-git-hooks@2.13.1: {} sisteransi@1.0.5: {} @@ -1049,4 +1103,8 @@ snapshots: universalify@2.0.1: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + yallist@4.0.0: {} diff --git a/src/template-manager.ts b/src/template-manager.ts index c8e29ca..a544b2b 100644 --- a/src/template-manager.ts +++ b/src/template-manager.ts @@ -8,7 +8,11 @@ const NPM_TEMPLATE_PREFIX = 'npm:'; * Sanitize package name and version to create a valid cache key */ export const sanitizeCacheKey = (packageName: string, version: string) => { - const normalized = packageName.replace(/[\\/]/g, '_'); + // 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}`; }; diff --git a/test/index.test.ts b/test/index.test.ts index a029de3..cb9652f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,7 +2,11 @@ import { expect, test } from '@rstest/core'; import { checkCancel, create, + isNpmTemplate, multiselect, + resolveCustomTemplate, + resolveNpmTemplate, + sanitizeCacheKey, select, text, } from '../dist/index.js'; @@ -14,3 +18,37 @@ test('should export public APIs', () => { expect(typeof select).toBe('function'); expect(typeof text).toBe('function'); }); + +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'); +});