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/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/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..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 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'); +});