From 9258a4f292577f6d6c31df59571799a3045ea37d Mon Sep 17 00:00:00 2001 From: neverland Date: Sun, 18 Oct 2026 10:35:19 +0800 Subject: [PATCH 1/3] feat: support template-level create functions --- README.md | 29 ++++++++ src/index.ts | 48 +++++++++++-- test/custom-templates.test.ts | 128 ++++++++++++++++++++++++++++++++++ test/help.test.ts | 18 +++++ 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 test/custom-templates.test.ts diff --git a/README.md b/README.md index d7dcfb8..ed65f34 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,35 @@ npm add @rstackjs/create-toolkit -D ## Features +### Custom templates + +Use a template object with a `name` and `create` function to run an external scaffolder or generate files programmatically. String templates keep the existing file-copying behavior: + +```ts +create({ + templates: [ + 'react-ts', + { + name: 'custom', + create: async ({ + distFolder, + packageName, + packageManager, + gitEnabled, + override, + }) => { + // Call an external CLI or generate project files here. + }, + }, + ], + // ...other options +}); +``` + +`getTemplateName` still returns the selected template's name. The toolkit resolves the project path and confirms overwriting before calling `create`. `distFolder` is an absolute path, and `override` includes any overwrite confirmation from the user. + +The toolkit awaits `create` and then stops. The callback owns the remaining setup, including tools, skills, Git initialization, and next-step messages. Errors propagate to the caller. Registered custom templates take precedence over local directories and npm template resolution, and their names appear in `--help`. + ### Configure Built-in Tools The toolkit provides ESLint, Rslint, Biome, and Prettier as built-in tools. Use diff --git a/src/index.ts b/src/index.ts index e226d7c..44ab482 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 the project after its path and overwrite permission are resolved. + * Owns all remaining setup, including tools, skills, Git, and next steps. + */ + 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. @@ -803,7 +824,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 +839,29 @@ 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; + } + 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..9105b89 --- /dev/null +++ b/test/custom-templates.test.ts @@ -0,0 +1,128 @@ +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(), + multiselect: rs.fn(), +})); + +rs.mock('@clack/prompts', () => ({ ...promptsActual, ...mocks })); + +beforeEach(() => { + rs.clearAllMocks(); + fs.rmSync(projectDir, { recursive: true, force: true }); + return () => fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +test('should run a custom template with the prompted project path and skip local scaffolding', async () => { + const targetDir = path.relative(process.cwd(), projectDir); + mocks.text.mockResolvedValue(` ${targetDir}/ `); + const onGitResolved = rs.fn(); + const createTemplate = rs.fn(async () => {}); + + await create({ + name: 'test', + root: fixturesDir, + templates: [{ name: 'external', create: createTemplate }], + getTemplateName: async () => 'external', + getPackageManager: () => 'pnpm', + onGitResolved, + argv: ['node', 'test', '--package-name', '@scope/app', '--no-git'], + }); + + expect(createTemplate).toHaveBeenCalledWith({ + distFolder: projectDir, + packageName: '@scope/app', + packageManager: 'pnpm', + gitEnabled: false, + override: false, + }); + expect(mocks.multiselect).not.toHaveBeenCalled(); + expect(onGitResolved).not.toHaveBeenCalled(); + expect(fs.existsSync(projectDir)).toBe(false); +}); + +test('should pass confirmed overwrite permission to the custom template', async () => { + fs.mkdirSync(projectDir); + const existingFile = path.join(projectDir, 'existing.txt'); + fs.writeFileSync(existingFile, 'keep'); + mocks.select.mockResolvedValue('yes'); + const createTemplate = rs.fn(); + + await create({ + name: 'test', + root: fixturesDir, + templates: [{ name: 'external', create: createTemplate }], + getTemplateName: async () => 'external', + argv: ['node', 'test', projectDir, '--template', 'external'], + }); + + expect(createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ override: true, distFolder: projectDir }), + ); + expect(fs.readFileSync(existingFile, 'utf8')).toBe('keep'); +}); + +test('should scaffold string templates alongside custom templates', async () => { + const createTemplate = rs.fn(); + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla', { name: 'external', create: createTemplate }], + getTemplateName: async () => 'vanilla', + git: false, + argv: ['node', 'test', projectDir, '--template', 'vanilla'], + }); + + expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); + expect(createTemplate).not.toHaveBeenCalled(); +}); + +test('should resolve the package name when creating in the current directory', async () => { + const createTemplate = rs.fn(); + + await create({ + name: 'test', + root: fixturesDir, + templates: [{ name: 'external', create: createTemplate }], + getTemplateName: async () => 'external', + argv: ['node', 'test', '.', '--template', 'external', '--override'], + }); + + expect(createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + distFolder: process.cwd(), + packageName: path.basename(process.cwd()), + override: true, + }), + ); +}); + +test('should propagate custom template errors without falling back to local scaffolding', async () => { + const error = new Error('External scaffolder failed'); + + await expect( + create({ + name: 'test', + root: fixturesDir, + templates: [ + { + name: 'vanilla', + create: async () => { + throw error; + }, + }, + ], + getTemplateName: async () => 'vanilla', + argv: ['node', 'test', projectDir, '--template', 'vanilla'], + }), + ).rejects.toBe(error); + + expect(fs.existsSync(projectDir)).toBe(false); +}); 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(() => {}); From 7f475fc58ed5bcfa3b1b7f25763c98856af41e70 Mon Sep 17 00:00:00 2001 From: neverland Date: Sun, 18 Oct 2026 16:50:35 +0800 Subject: [PATCH 2/3] docs: clarify custom template create callback --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 44ab482..6f7cb04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,8 +168,8 @@ export type Template = | { name: string; /** - * Create the project after its path and overwrite permission are resolved. - * Owns all remaining setup, including tools, skills, Git, and next steps. + * Create a project using an external CLI or custom generation logic. + * Replaces the toolkit's built-in project setup. */ create: (context: TemplateCreateContext) => void | Promise; }; From bc5b7250d8f27da4d9216c17564ec5fbe7fabde0 Mon Sep 17 00:00:00 2001 From: neverland Date: Sun, 18 Oct 2026 16:53:53 +0800 Subject: [PATCH 3/3] refactor: simplify custom template setup and coverage --- README.md | 52 ++++++++---------- src/index.ts | 10 ++-- test/custom-templates.test.ts | 99 ++++++++++------------------------- 3 files changed, 55 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index ed65f34..1ee2483 100644 --- a/README.md +++ b/README.md @@ -23,35 +23,6 @@ npm add @rstackjs/create-toolkit -D ## Features -### Custom templates - -Use a template object with a `name` and `create` function to run an external scaffolder or generate files programmatically. String templates keep the existing file-copying behavior: - -```ts -create({ - templates: [ - 'react-ts', - { - name: 'custom', - create: async ({ - distFolder, - packageName, - packageManager, - gitEnabled, - override, - }) => { - // Call an external CLI or generate project files here. - }, - }, - ], - // ...other options -}); -``` - -`getTemplateName` still returns the selected template's name. The toolkit resolves the project path and confirms overwriting before calling `create`. `distFolder` is an absolute path, and `override` includes any overwrite confirmation from the user. - -The toolkit awaits `create` and then stops. The callback owns the remaining setup, including tools, skills, Git initialization, and next-step messages. Errors propagate to the caller. Registered custom templates take precedence over local directories and npm template resolution, and their names appear in `--help`. - ### Configure Built-in Tools The toolkit provides ESLint, Rslint, Biome, and Prettier as built-in tools. Use @@ -144,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 6f7cb04..e4c7da1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -797,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( @@ -862,6 +857,11 @@ export async function create({ 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 index 9105b89..614a11d 100644 --- a/test/custom-templates.test.ts +++ b/test/custom-templates.test.ts @@ -6,33 +6,31 @@ 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(), - multiselect: rs.fn(), -})); - +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.clearAllMocks(); + rs.resetAllMocks(); fs.rmSync(projectDir, { recursive: true, force: true }); return () => fs.rmSync(projectDir, { recursive: true, force: true }); }); -test('should run a custom template with the prompted project path and skip local scaffolding', async () => { - const targetDir = path.relative(process.cwd(), projectDir); - mocks.text.mockResolvedValue(` ${targetDir}/ `); - const onGitResolved = rs.fn(); - const createTemplate = rs.fn(async () => {}); +test('should pass resolved project options to a custom template', async () => { + mocks.text.mockResolvedValue(path.relative(process.cwd(), projectDir)); await create({ - name: 'test', - root: fixturesDir, - templates: [{ name: 'external', create: createTemplate }], - getTemplateName: async () => 'external', + ...options, + root: projectDir, getPackageManager: () => 'pnpm', - onGitResolved, argv: ['node', 'test', '--package-name', '@scope/app', '--no-git'], }); @@ -43,86 +41,43 @@ test('should run a custom template with the prompted project path and skip local gitEnabled: false, override: false, }); - expect(mocks.multiselect).not.toHaveBeenCalled(); - expect(onGitResolved).not.toHaveBeenCalled(); expect(fs.existsSync(projectDir)).toBe(false); }); -test('should pass confirmed overwrite permission to the custom template', async () => { +test('should pass confirmed overwrite permission to a custom template', async () => { fs.mkdirSync(projectDir); - const existingFile = path.join(projectDir, 'existing.txt'); - fs.writeFileSync(existingFile, 'keep'); + fs.writeFileSync(path.join(projectDir, 'existing.txt'), 'keep'); mocks.select.mockResolvedValue('yes'); - const createTemplate = rs.fn(); - await create({ - name: 'test', - root: fixturesDir, - templates: [{ name: 'external', create: createTemplate }], - getTemplateName: async () => 'external', - argv: ['node', 'test', projectDir, '--template', 'external'], - }); + await create(options); expect(createTemplate).toHaveBeenCalledWith( - expect.objectContaining({ override: true, distFolder: projectDir }), + expect.objectContaining({ override: true }), ); - expect(fs.readFileSync(existingFile, 'utf8')).toBe('keep'); }); -test('should scaffold string templates alongside custom templates', async () => { - const createTemplate = rs.fn(); +test('should support string templates alongside custom templates', async () => { await create({ - name: 'test', - root: fixturesDir, - templates: ['vanilla', { name: 'external', create: createTemplate }], + ...options, getTemplateName: async () => 'vanilla', git: false, - argv: ['node', 'test', projectDir, '--template', 'vanilla'], }); expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); expect(createTemplate).not.toHaveBeenCalled(); }); -test('should resolve the package name when creating in the current directory', async () => { - const createTemplate = rs.fn(); - - await create({ - name: 'test', - root: fixturesDir, - templates: [{ name: 'external', create: createTemplate }], - getTemplateName: async () => 'external', - argv: ['node', 'test', '.', '--template', 'external', '--override'], - }); +test('should resolve the package name for the current directory', async () => { + await create({ ...options, argv: ['node', 'test', '.', '--override'] }); expect(createTemplate).toHaveBeenCalledWith( - expect.objectContaining({ - distFolder: process.cwd(), - packageName: path.basename(process.cwd()), - override: true, - }), + expect.objectContaining({ packageName: path.basename(process.cwd()) }), ); }); -test('should propagate custom template errors without falling back to local scaffolding', async () => { +test('should propagate errors from a custom template', async () => { const error = new Error('External scaffolder failed'); + createTemplate.mockRejectedValue(error); - await expect( - create({ - name: 'test', - root: fixturesDir, - templates: [ - { - name: 'vanilla', - create: async () => { - throw error; - }, - }, - ], - getTemplateName: async () => 'vanilla', - argv: ['node', 'test', projectDir, '--template', 'vanilla'], - }), - ).rejects.toBe(error); - - expect(fs.existsSync(projectDir)).toBe(false); + await expect(create(options)).rejects.toBe(error); });