diff --git a/README.md b/README.md index d7dcfb8..1ee2483 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,29 @@ The callback runs for both enabled and disabled Git initialization. `isGitRoot` indicates whether the generated project directory is the root of its Git worktree. +### Custom template creation + +Register a template with a `name` and `create` function to use an external CLI or generate project files yourself: + +```ts +create({ + templates: [ + 'react-ts', + { + name: 'custom', + create: async (context) => { + // Invoke your scaffolder using the resolved project options. + }, + }, + ], + // ...other options +}); +``` + +`getTemplateName` returns the template name as usual. The callback receives the absolute `distFolder`, `packageName`, `packageManager`, `gitEnabled`, and `override`. The `override` value includes any overwrite confirmation from the user. + +The callback replaces the built-in project setup, including file generation, tools, skills, Git initialization, and next-step messages. It may return a promise; errors propagate to the caller. String templates keep their existing behavior. + ### NPM Template Support `@rstackjs/create-toolkit` supports using npm packages as templates, allowing users to create projects from custom templates published to npm. diff --git a/src/index.ts b/src/index.ts index e226d7c..e4c7da1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -153,6 +153,27 @@ export type GitResolvedContext = GitContext & { distFolder: string; }; +export type TemplateCreateContext = { + /** Absolute path to the project directory. */ + distFolder: string; + packageName: string; + packageManager: string; + gitEnabled: boolean; + /** Whether overwriting was requested or confirmed by the user. */ + override: boolean; +}; + +export type Template = + | string + | { + name: string; + /** + * Create a project using an external CLI or custom generation logic. + * Replaces the toolkit's built-in project setup. + */ + create: (context: TemplateCreateContext) => void | Promise; + }; + export type BuiltinToolName = 'eslint' | 'rslint' | 'biome' | 'prettier'; export const BUILTIN_TOOLS: BuiltinToolName[] = [ @@ -188,7 +209,7 @@ function resolveBuiltinTools( function logHelpMessage( name: string, - templates: string[], + templates: Template[], git: boolean, builtinTools: BuiltinToolName[] | undefined, extraTools?: ExtraTool[], @@ -250,7 +271,7 @@ function logHelpMessage( 'Options:', optionLines, '', - `Available templates: ${templates.join(', ')}`, + `Available templates: ${templates.map((template) => (typeof template === 'string' ? template : template.name)).join(', ')}`, ]; if (hasTools) { @@ -694,7 +715,7 @@ export async function create({ name: string; root: string; skipFiles?: string[]; - templates: string[]; + templates: Template[]; getTemplateName: (argv: Argv) => Promise; /** * Specify the package manager for the selected template. @@ -776,11 +797,6 @@ export async function create({ const cwd = process.cwd(); - // No version provided, read from package.json - if (!version) { - version = (await readPackageJson(root)).version; - } - const projectName = argv.dir ?? checkCancel( @@ -803,7 +819,8 @@ export async function create({ ? targetDir : path.join(cwd, targetDir); - if (!argv.override && fs.existsSync(distFolder) && !isEmptyDir(distFolder)) { + let override = argv.override ?? false; + if (!override && fs.existsSync(distFolder) && !isEmptyDir(distFolder)) { const option = checkCancel( await select({ message: `"${targetDir}" is not empty, please choose:`, @@ -817,11 +834,34 @@ export async function create({ if (option === 'no') { cancelAndExit(); } + override = true; } const templateName = await getTemplateName(argv); const packageManager = getPackageManager?.({ templateName }) ?? detectedPackageManager; + + const customTemplate = templates.find( + (template) => + typeof template !== 'string' && template.name === templateName, + ); + if (typeof customTemplate === 'object') { + await customTemplate.create({ + distFolder, + packageName: + packageName === '.' ? path.basename(distFolder) : packageName, + packageManager, + gitEnabled, + override, + }); + return; + } + + // No version provided, read from package.json + if (!version) { + version = (await readPackageJson(root)).version; + } + const templateParameters = { packageManager }; const srcFolder = path.join(root, `template-${templateName}`); diff --git a/test/custom-templates.test.ts b/test/custom-templates.test.ts new file mode 100644 index 0000000..614a11d --- /dev/null +++ b/test/custom-templates.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as promptsActual from '@clack/prompts' with { rstest: 'importActual' }; +import { beforeEach, expect, rs, test } from 'rstack/test'; +import { create } from '../src'; + +const fixturesDir = path.join(import.meta.dirname, 'fixtures/basic'); +const projectDir = path.join(fixturesDir, 'test-temp-custom-template'); +const mocks = rs.hoisted(() => ({ text: rs.fn(), select: rs.fn() })); +rs.mock('@clack/prompts', () => ({ ...promptsActual, ...mocks })); + +const createTemplate = rs.fn(); +const options = { + name: 'test', + root: fixturesDir, + templates: ['vanilla', { name: 'external', create: createTemplate }], + getTemplateName: async () => 'external', + argv: ['node', 'test', projectDir, '--template', 'external'], +}; + +beforeEach(() => { + rs.resetAllMocks(); + fs.rmSync(projectDir, { recursive: true, force: true }); + return () => fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +test('should pass resolved project options to a custom template', async () => { + mocks.text.mockResolvedValue(path.relative(process.cwd(), projectDir)); + + await create({ + ...options, + root: projectDir, + getPackageManager: () => 'pnpm', + argv: ['node', 'test', '--package-name', '@scope/app', '--no-git'], + }); + + expect(createTemplate).toHaveBeenCalledWith({ + distFolder: projectDir, + packageName: '@scope/app', + packageManager: 'pnpm', + gitEnabled: false, + override: false, + }); + expect(fs.existsSync(projectDir)).toBe(false); +}); + +test('should pass confirmed overwrite permission to a custom template', async () => { + fs.mkdirSync(projectDir); + fs.writeFileSync(path.join(projectDir, 'existing.txt'), 'keep'); + mocks.select.mockResolvedValue('yes'); + + await create(options); + + expect(createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ override: true }), + ); +}); + +test('should support string templates alongside custom templates', async () => { + await create({ + ...options, + getTemplateName: async () => 'vanilla', + git: false, + }); + + expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); + expect(createTemplate).not.toHaveBeenCalled(); +}); + +test('should resolve the package name for the current directory', async () => { + await create({ ...options, argv: ['node', 'test', '.', '--override'] }); + + expect(createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ packageName: path.basename(process.cwd()) }), + ); +}); + +test('should propagate errors from a custom template', async () => { + const error = new Error('External scaffolder failed'); + createTemplate.mockRejectedValue(error); + + await expect(create(options)).rejects.toBe(error); +}); diff --git a/test/help.test.ts b/test/help.test.ts index 85a74d6..3c6b29a 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -6,6 +6,24 @@ afterEach(() => { rs.restoreAllMocks(); }); +test('help message includes custom template names without creating a project', async () => { + const log = rs.spyOn(logger, 'log').mockImplementation(() => {}); + const createTemplate = rs.fn(); + + await create({ + name: 'test', + root: '.', + templates: ['vanilla', { name: 'external', create: createTemplate }], + getTemplateName: async () => 'external', + argv: ['node', 'test', '--help'], + }); + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Available templates: vanilla, external', + ); + expect(createTemplate).not.toHaveBeenCalled(); +}); + test('help message uses compact, aligned output', async () => { const log = rs.spyOn(logger, 'log').mockImplementation(() => {});