From 8bbce637e04c41234ce3b7533ecac5f613df5ec8 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Mon, 19 Oct 2026 23:35:34 +0800 Subject: [PATCH 1/3] feat: support optional CLI version in startup greeting --- README.md | 20 ++++++ src/index.ts | 10 ++- test/cli-version.test.ts | 127 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 test/cli-version.test.ts diff --git a/README.md b/README.md index 1ee2483..0b7fae6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,26 @@ npm add @rstackjs/create-toolkit -D ## Features +### CLI Version Display + +Pass the optional `cliVersion` string to display your scaffolding CLI's version +in the startup greeting: + +```ts +create({ + name: 'rsbuild', + cliVersion: '1.2.3', + // ...other options +}); +``` + +The greeting becomes `Create Rsbuild Project v1.2.3` in both interactive and +non-interactive runs. Omitting `cliVersion` or passing an empty string keeps +the existing greeting. Help output is unchanged. + +This option only controls the displayed version. The existing `version` option +continues to control dependency versions in generated templates. + ### 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 5b7353c..af908b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -725,6 +725,7 @@ export async function create({ mapESLintTemplate, mapRslintTemplate, version, + cliVersion, noteInformation, git = true, onGitResolved, @@ -761,6 +762,11 @@ export async function create({ context: { distFolder: string }, ) => RslintTemplateName | null; version?: Record | string; + /** + * The scaffolding CLI version to display in the startup greeting. + * Omit this option to keep the greeting without a version. + */ + cliVersion?: string; noteInformation?: string[]; /** * Whether to initialize a Git repository by default when the target directory @@ -801,7 +807,9 @@ export async function create({ return; } - logger.greet(`\n◆ Create ${upperFirst(name)} Project`); + logger.greet( + `\n◆ Create ${upperFirst(name)} Project${cliVersion ? ` v${cliVersion}` : ''}`, + ); const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const detectedPackageManager = pkgInfo ? pkgInfo.name : 'npm'; diff --git a/test/cli-version.test.ts b/test/cli-version.test.ts new file mode 100644 index 0000000..2b0826f --- /dev/null +++ b/test/cli-version.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as promptsActual from '@clack/prompts' with { rstest: 'importActual' }; +import { beforeEach, expect, rs, test } from 'rstack/test'; +import { logger } from 'rslog'; +import { create } from '../src'; + +const mocks = rs.hoisted(() => ({ text: rs.fn(), multiselect: rs.fn() })); +rs.mock('@clack/prompts', () => ({ ...promptsActual, ...mocks })); + +let root: string; +let projectDir: string; + +beforeEach(() => { + rs.resetAllMocks(); + rs.spyOn(logger, 'greet').mockImplementation(() => {}); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'create-toolkit-cli-version-')); + projectDir = path.join(root, 'project'); + fs.mkdirSync(path.join(root, 'template-common')); + fs.mkdirSync(path.join(root, 'template-vanilla')); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(root, 'template-vanilla/package.json'), + JSON.stringify({ + name: 'template', + dependencies: { example: 'workspace:*' }, + }), + ); + + return () => { + rs.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }; +}); + +const getOptions = () => ({ + name: 'test', + root, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + git: false, + argv: ['node', 'test', projectDir, '--template', 'vanilla'], +}); + +test.each([ + { options: {}, suffix: '' }, + { options: { cliVersion: '' }, suffix: '' }, + { options: { cliVersion: '2.3.4' }, suffix: ' v2.3.4' }, + { options: { cliVersion: '2.3.4-beta.1' }, suffix: ' v2.3.4-beta.1' }, +])( + 'should display the non-interactive greeting with $options', + async ({ options, suffix }) => { + await create({ ...getOptions(), ...options }); + + expect(logger.greet).toHaveBeenCalledExactlyOnceWith( + `\n◆ Create Test Project${suffix}`, + ); + expect(mocks.text).not.toHaveBeenCalled(); + expect(mocks.multiselect).not.toHaveBeenCalled(); + const pkg = JSON.parse( + fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'), + ); + expect(pkg.dependencies.example).toBe('^1.0.0'); + }, +); + +test.each([ + { version: '3.0.0', expected: '^3.0.0' }, + { version: { example: '~4.0.0' }, expected: '~4.0.0' }, +])( + 'should keep dependency version replacement separate: $version', + async ({ version, expected }) => { + await create({ ...getOptions(), version, cliVersion: '2.3.4' }); + + expect(logger.greet).toHaveBeenCalledExactlyOnceWith( + '\n◆ Create Test Project v2.3.4', + ); + const pkg = JSON.parse( + fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'), + ); + expect(pkg.dependencies.example).toBe(expected); + }, +); + +test('should display the CLI version before interactive prompts', async () => { + mocks.text.mockImplementation(async () => { + expect(logger.greet).toHaveBeenCalledExactlyOnceWith( + '\n◆ Create Test Project v2.3.4', + ); + return projectDir; + }); + + await create({ + ...getOptions(), + cliVersion: '2.3.4', + builtinTools: [], + argv: ['node', 'test'], + }); + + expect(mocks.text).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); +}); + +test('should leave help output unchanged when a CLI version is provided', async () => { + const log = rs.spyOn(logger, 'log').mockImplementation(() => {}); + const getTemplateName = rs.fn(); + const options = { + ...getOptions(), + getTemplateName, + argv: ['node', 'test', '--help'], + }; + + await create(options); + const helpCalls = [...log.mock.calls]; + log.mockClear(); + await create({ ...options, cliVersion: '2.3.4' }); + + expect(log.mock.calls).toEqual(helpCalls); + expect(logger.greet).not.toHaveBeenCalled(); + expect(getTemplateName).not.toHaveBeenCalled(); + expect(mocks.text).not.toHaveBeenCalled(); + expect(fs.existsSync(projectDir)).toBe(false); +}); From 38836fb7afed1f49a9a19ebe4ba523567add6720 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Tue, 20 Oct 2026 08:47:38 +0800 Subject: [PATCH 2/3] feat: accept a complete custom startup banner --- README.md | 20 ++++---- src/index.ts | 12 ++--- test/{cli-version.test.ts => banner.test.ts} | 53 +++++++++++++------- 3 files changed, 52 insertions(+), 33 deletions(-) rename test/{cli-version.test.ts => banner.test.ts} (69%) diff --git a/README.md b/README.md index 0b7fae6..5debe63 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,27 @@ npm add @rstackjs/create-toolkit -D ## Features -### CLI Version Display +### Custom Startup Banner -Pass the optional `cliVersion` string to display your scaffolding CLI's version -in the startup greeting: +Pass the optional `banner` string to customize the complete startup greeting: ```ts create({ name: 'rsbuild', - cliVersion: '1.2.3', + banner: 'create-rsbuild v2.2.14', // ...other options }); ``` -The greeting becomes `Create Rsbuild Project v1.2.3` in both interactive and -non-interactive runs. Omitting `cliVersion` or passing an empty string keeps -the existing greeting. Help output is unchanged. +The toolkit displays `create-rsbuild v2.2.14` with the existing greeting style +in both interactive and non-interactive runs. Compose the full text in the +caller, including any package name or version you want to display. -This option only controls the displayed version. The existing `version` option -continues to control dependency versions in generated templates. +Omitting `banner` keeps the existing `Create Project` greeting. An empty +string is displayed as provided. Help output is unchanged. + +The existing `version` option continues to control dependency versions in +generated templates independently of the banner. ### Configure Built-in Tools diff --git a/src/index.ts b/src/index.ts index af908b0..b0c4822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -725,7 +725,7 @@ export async function create({ mapESLintTemplate, mapRslintTemplate, version, - cliVersion, + banner, noteInformation, git = true, onGitResolved, @@ -763,10 +763,10 @@ export async function create({ ) => RslintTemplateName | null; version?: Record | string; /** - * The scaffolding CLI version to display in the startup greeting. - * Omit this option to keep the greeting without a version. + * The complete text to display in the startup greeting. + * Omit this option to use `Create Project`. */ - cliVersion?: string; + banner?: string; noteInformation?: string[]; /** * Whether to initialize a Git repository by default when the target directory @@ -807,9 +807,7 @@ export async function create({ return; } - logger.greet( - `\n◆ Create ${upperFirst(name)} Project${cliVersion ? ` v${cliVersion}` : ''}`, - ); + logger.greet(`\n◆ ${banner ?? `Create ${upperFirst(name)} Project`}`); const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const detectedPackageManager = pkgInfo ? pkgInfo.name : 'npm'; diff --git a/test/cli-version.test.ts b/test/banner.test.ts similarity index 69% rename from test/cli-version.test.ts rename to test/banner.test.ts index 2b0826f..d9313bd 100644 --- a/test/cli-version.test.ts +++ b/test/banner.test.ts @@ -15,7 +15,7 @@ let projectDir: string; beforeEach(() => { rs.resetAllMocks(); rs.spyOn(logger, 'greet').mockImplementation(() => {}); - root = fs.mkdtempSync(path.join(os.tmpdir(), 'create-toolkit-cli-version-')); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'create-toolkit-banner-')); projectDir = path.join(root, 'project'); fs.mkdirSync(path.join(root, 'template-common')); fs.mkdirSync(path.join(root, 'template-vanilla')); @@ -47,18 +47,27 @@ const getOptions = () => ({ }); test.each([ - { options: {}, suffix: '' }, - { options: { cliVersion: '' }, suffix: '' }, - { options: { cliVersion: '2.3.4' }, suffix: ' v2.3.4' }, - { options: { cliVersion: '2.3.4-beta.1' }, suffix: ' v2.3.4-beta.1' }, + { options: {}, expected: 'Create Test Project' }, + { options: { banner: undefined }, expected: 'Create Test Project' }, + { options: { banner: '' }, expected: '' }, + { + options: { banner: 'create-rsbuild v2.2.14' }, + expected: 'create-rsbuild v2.2.14', + }, + { + options: { banner: 'toolkit@2.0.0-beta.1' }, + expected: 'toolkit@2.0.0-beta.1', + }, + { + options: { banner: 'My custom scaffolder' }, + expected: 'My custom scaffolder', + }, ])( - 'should display the non-interactive greeting with $options', - async ({ options, suffix }) => { + 'should display the non-interactive banner: $expected', + async ({ options, expected }) => { await create({ ...getOptions(), ...options }); - expect(logger.greet).toHaveBeenCalledExactlyOnceWith( - `\n◆ Create Test Project${suffix}`, - ); + expect(logger.greet).toHaveBeenCalledExactlyOnceWith(`\n◆ ${expected}`); expect(mocks.text).not.toHaveBeenCalled(); expect(mocks.multiselect).not.toHaveBeenCalled(); const pkg = JSON.parse( @@ -74,10 +83,14 @@ test.each([ ])( 'should keep dependency version replacement separate: $version', async ({ version, expected }) => { - await create({ ...getOptions(), version, cliVersion: '2.3.4' }); + await create({ + ...getOptions(), + version, + banner: 'create-rsbuild v2.2.14', + }); expect(logger.greet).toHaveBeenCalledExactlyOnceWith( - '\n◆ Create Test Project v2.3.4', + '\n◆ create-rsbuild v2.2.14', ); const pkg = JSON.parse( fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'), @@ -86,26 +99,32 @@ test.each([ }, ); -test('should display the CLI version before interactive prompts', async () => { +test('should display the custom banner before interactive prompts', async () => { mocks.text.mockImplementation(async () => { expect(logger.greet).toHaveBeenCalledExactlyOnceWith( - '\n◆ Create Test Project v2.3.4', + '\n◆ create-rsbuild v2.2.14', ); return projectDir; }); await create({ ...getOptions(), - cliVersion: '2.3.4', + banner: 'create-rsbuild v2.2.14', builtinTools: [], argv: ['node', 'test'], }); expect(mocks.text).toHaveBeenCalledTimes(1); + expect(mocks.text).toHaveBeenCalledWith( + expect.objectContaining({ + placeholder: 'test-project', + defaultValue: 'test-project', + }), + ); expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); }); -test('should leave help output unchanged when a CLI version is provided', async () => { +test('should leave help output unchanged when a banner is provided', async () => { const log = rs.spyOn(logger, 'log').mockImplementation(() => {}); const getTemplateName = rs.fn(); const options = { @@ -117,7 +136,7 @@ test('should leave help output unchanged when a CLI version is provided', async await create(options); const helpCalls = [...log.mock.calls]; log.mockClear(); - await create({ ...options, cliVersion: '2.3.4' }); + await create({ ...options, banner: 'create-rsbuild v2.2.14' }); expect(log.mock.calls).toEqual(helpCalls); expect(logger.greet).not.toHaveBeenCalled(); From 6c9c260b4e6466296605d71a056930d950655d42 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Tue, 20 Oct 2026 08:52:42 +0800 Subject: [PATCH 3/3] test: simplify banner coverage and documentation --- README.md | 12 +--- test/banner.test.ts | 150 +++++++------------------------------------- 2 files changed, 25 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 5debe63..3c997e7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm add @rstackjs/create-toolkit -D ### Custom Startup Banner -Pass the optional `banner` string to customize the complete startup greeting: +Pass the optional `banner` string to customize the startup greeting: ```ts create({ @@ -35,15 +35,7 @@ create({ }); ``` -The toolkit displays `create-rsbuild v2.2.14` with the existing greeting style -in both interactive and non-interactive runs. Compose the full text in the -caller, including any package name or version you want to display. - -Omitting `banner` keeps the existing `Create Project` greeting. An empty -string is displayed as provided. Help output is unchanged. - -The existing `version` option continues to control dependency versions in -generated templates independently of the banner. +Supply the complete text; the toolkit applies the existing greeting style. Omit `banner` to keep `Create Project`. The `version` option for template dependencies is unchanged. ### Configure Built-in Tools diff --git a/test/banner.test.ts b/test/banner.test.ts index d9313bd..f4276dd 100644 --- a/test/banner.test.ts +++ b/test/banner.test.ts @@ -1,146 +1,42 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import * as promptsActual from '@clack/prompts' with { rstest: 'importActual' }; -import { beforeEach, expect, rs, test } from 'rstack/test'; +import { afterEach, expect, rs, test } from 'rstack/test'; import { logger } from 'rslog'; import { create } from '../src'; -const mocks = rs.hoisted(() => ({ text: rs.fn(), multiselect: rs.fn() })); -rs.mock('@clack/prompts', () => ({ ...promptsActual, ...mocks })); - -let root: string; -let projectDir: string; - -beforeEach(() => { - rs.resetAllMocks(); - rs.spyOn(logger, 'greet').mockImplementation(() => {}); - root = fs.mkdtempSync(path.join(os.tmpdir(), 'create-toolkit-banner-')); - projectDir = path.join(root, 'project'); - fs.mkdirSync(path.join(root, 'template-common')); - fs.mkdirSync(path.join(root, 'template-vanilla')); - fs.writeFileSync( - path.join(root, 'package.json'), - JSON.stringify({ version: '1.0.0' }), - ); - fs.writeFileSync( - path.join(root, 'template-vanilla/package.json'), - JSON.stringify({ - name: 'template', - dependencies: { example: 'workspace:*' }, - }), - ); - - return () => { - rs.restoreAllMocks(); - fs.rmSync(root, { recursive: true, force: true }); - }; -}); - -const getOptions = () => ({ +const options = { name: 'test', - root, - templates: ['vanilla'], - getTemplateName: async () => 'vanilla', - git: false, - argv: ['node', 'test', projectDir, '--template', 'vanilla'], + root: '.', + templates: [{ name: 'custom', create: rs.fn() }], + getTemplateName: async () => 'custom', + argv: ['node', 'test', '.', '--template', 'custom', '--override'], +}; + +afterEach(() => { + rs.restoreAllMocks(); }); test.each([ - { options: {}, expected: 'Create Test Project' }, - { options: { banner: undefined }, expected: 'Create Test Project' }, - { options: { banner: '' }, expected: '' }, - { - options: { banner: 'create-rsbuild v2.2.14' }, - expected: 'create-rsbuild v2.2.14', - }, - { - options: { banner: 'toolkit@2.0.0-beta.1' }, - expected: 'toolkit@2.0.0-beta.1', - }, - { - options: { banner: 'My custom scaffolder' }, - expected: 'My custom scaffolder', - }, + { banner: undefined, expected: 'Create Test Project' }, + { banner: 'create-rsbuild v2.2.14', expected: 'create-rsbuild v2.2.14' }, ])( - 'should display the non-interactive banner: $expected', - async ({ options, expected }) => { - await create({ ...getOptions(), ...options }); - - expect(logger.greet).toHaveBeenCalledExactlyOnceWith(`\n◆ ${expected}`); - expect(mocks.text).not.toHaveBeenCalled(); - expect(mocks.multiselect).not.toHaveBeenCalled(); - const pkg = JSON.parse( - fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'), - ); - expect(pkg.dependencies.example).toBe('^1.0.0'); - }, -); + 'should display the startup banner: $expected', + async ({ banner, expected }) => { + const greet = rs.spyOn(logger, 'greet').mockImplementation(() => {}); -test.each([ - { version: '3.0.0', expected: '^3.0.0' }, - { version: { example: '~4.0.0' }, expected: '~4.0.0' }, -])( - 'should keep dependency version replacement separate: $version', - async ({ version, expected }) => { - await create({ - ...getOptions(), - version, - banner: 'create-rsbuild v2.2.14', - }); + await create({ ...options, banner }); - expect(logger.greet).toHaveBeenCalledExactlyOnceWith( - '\n◆ create-rsbuild v2.2.14', - ); - const pkg = JSON.parse( - fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'), - ); - expect(pkg.dependencies.example).toBe(expected); + expect(greet).toHaveBeenCalledExactlyOnceWith(`\n◆ ${expected}`); }, ); -test('should display the custom banner before interactive prompts', async () => { - mocks.text.mockImplementation(async () => { - expect(logger.greet).toHaveBeenCalledExactlyOnceWith( - '\n◆ create-rsbuild v2.2.14', - ); - return projectDir; - }); +test('should skip the banner when displaying help', async () => { + const greet = rs.spyOn(logger, 'greet').mockImplementation(() => {}); + rs.spyOn(logger, 'log').mockImplementation(() => {}); await create({ - ...getOptions(), + ...options, banner: 'create-rsbuild v2.2.14', - builtinTools: [], - argv: ['node', 'test'], - }); - - expect(mocks.text).toHaveBeenCalledTimes(1); - expect(mocks.text).toHaveBeenCalledWith( - expect.objectContaining({ - placeholder: 'test-project', - defaultValue: 'test-project', - }), - ); - expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); -}); - -test('should leave help output unchanged when a banner is provided', async () => { - const log = rs.spyOn(logger, 'log').mockImplementation(() => {}); - const getTemplateName = rs.fn(); - const options = { - ...getOptions(), - getTemplateName, argv: ['node', 'test', '--help'], - }; - - await create(options); - const helpCalls = [...log.mock.calls]; - log.mockClear(); - await create({ ...options, banner: 'create-rsbuild v2.2.14' }); + }); - expect(log.mock.calls).toEqual(helpCalls); - expect(logger.greet).not.toHaveBeenCalled(); - expect(getTemplateName).not.toHaveBeenCalled(); - expect(mocks.text).not.toHaveBeenCalled(); - expect(fs.existsSync(projectDir)).toBe(false); + expect(greet).not.toHaveBeenCalled(); });