Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 49 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
};

export type BuiltinToolName = 'eslint' | 'rslint' | 'biome' | 'prettier';

export const BUILTIN_TOOLS: BuiltinToolName[] = [
Expand Down Expand Up @@ -188,7 +209,7 @@ function resolveBuiltinTools(

function logHelpMessage(
name: string,
templates: string[],
templates: Template[],
git: boolean,
builtinTools: BuiltinToolName[] | undefined,
extraTools?: ExtraTool[],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -694,7 +715,7 @@ export async function create({
name: string;
root: string;
skipFiles?: string[];
templates: string[];
templates: Template[];
getTemplateName: (argv: Argv) => Promise<string>;
/**
* Specify the package manager for the selected template.
Expand Down Expand Up @@ -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<string>(
Expand All @@ -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<string>(
await select({
message: `"${targetDir}" is not empty, please choose:`,
Expand All @@ -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}`);
Expand Down
83 changes: 83 additions & 0 deletions test/custom-templates.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
18 changes: 18 additions & 0 deletions test/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});

Expand Down
Loading