diff --git a/README.md b/README.md index 022a2bb..2c5bb51 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,44 @@ patternfly-cli [command] ### Available Commands - **`create`**: Create a new project from the available templates. -- **`update`**: Update your project to a newer version . +- **`list`**: List all available templates (built-in and optional custom). +- **`update`**: Update your project to a newer version. + +### Custom templates + +You can add your own templates in addition to the built-in ones by passing a JSON file with the `--template-file` (or `-t`) option. Custom templates are merged with the built-in list; if a custom template has the same `name` as a built-in one, the custom definition is used. + +**Create with custom templates:** + +```sh +patternfly-cli create my-app --template-file ./my-templates.json +``` + +**List templates including custom file:** + +```sh +patternfly-cli list --template-file ./my-templates.json +``` + +**JSON format** (array of template objects, same shape as the built-in templates): + +```json +[ + { + "name": "my-template", + "description": "My custom project template", + "repo": "https://github.com/org/repo.git", + "options": ["--single-branch", "--branch", "main"], + "packageManager": "npm" + } +] +``` + +- **`name`** (required): Template identifier. +- **`description`** (required): Short description shown in prompts and `list`. +- **`repo`** (required): Git clone URL. +- **`options`** (optional): Array of extra arguments for `git clone` (e.g. `["--single-branch", "--branch", "main"]`). +- **`packageManager`** (optional): `npm`, `yarn`, or `pnpm`; defaults to `npm` if omitted. ## Development / Installation diff --git a/package.json b/package.json index 52abb66..9506999 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,21 @@ "moduleFileExtensions": [ "ts", "js" - ] + ], + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + { + "tsconfig": { + "module": "commonjs", + "moduleResolution": "node" + } + } + ] + }, + "moduleNameMapper": { + "^(\\.\\./.*)\\.js$": "$1" + } }, "dependencies": { "0g": "^0.4.2", diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..97f45e0 --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,148 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { loadCustomTemplates, mergeTemplates } from '../template-loader.js'; +import templates from '../templates.js'; + +const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures'); + +describe('loadCustomTemplates', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as () => never); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + afterEach(() => { + consoleErrorSpy.mockClear(); + }); + + afterAll(() => { + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('loads and parses a valid template file', () => { + const filePath = path.join(fixturesDir, 'valid-templates.json'); + const result = loadCustomTemplates(filePath); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'custom-one', + description: 'A custom template', + repo: 'https://github.com/example/custom-one.git', + }); + expect(result[1]).toEqual({ + name: 'custom-with-options', + description: 'Custom with clone options', + repo: 'https://github.com/example/custom.git', + options: ['--depth', '1'], + packageManager: 'pnpm', + }); + }); + + it('exits when file does not exist', () => { + const filePath = path.join(fixturesDir, 'nonexistent.json'); + + expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Template file not found'), + ); + }); + + it('exits when file contains invalid JSON', async () => { + const invalidPath = path.join(fixturesDir, 'invalid-json.txt'); + await fs.writeFile(invalidPath, 'not valid json {'); + + try { + expect(() => loadCustomTemplates(invalidPath)).toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid JSON'), + ); + } finally { + await fs.remove(invalidPath); + } + }); + + it('exits when JSON is not an array', () => { + const filePath = path.join(fixturesDir, 'not-array.json'); + + expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('must be a JSON array'), + ); + }); + + it('exits when template is missing required name', () => { + const filePath = path.join(fixturesDir, 'invalid-template-missing-name.json'); + + expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('"name" must be'), + ); + }); + + it('exits when template has invalid options (non-string array)', () => { + const filePath = path.join(fixturesDir, 'invalid-template-bad-options.json'); + + expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('"options" must be'), + ); + }); +}); + +describe('mergeTemplates', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as () => never); + + afterAll(() => { + exitSpy.mockRestore(); + }); + it('returns built-in templates when no custom file path is provided', () => { + const result = mergeTemplates(templates); + + expect(result).toEqual(templates); + expect(result).toHaveLength(templates.length); + }); + + it('returns built-in templates when custom file path is undefined', () => { + const result = mergeTemplates(templates, undefined); + + expect(result).toEqual(templates); + }); + + it('merges custom templates with built-in, custom overrides by name', () => { + const customPath = path.join(fixturesDir, 'valid-templates.json'); + const result = mergeTemplates(templates, customPath); + + const names = result.map((t) => t.name); + expect(names).toContain('custom-one'); + expect(names).toContain('custom-with-options'); + + const customOne = result.find((t) => t.name === 'custom-one'); + expect(customOne?.repo).toBe('https://github.com/example/custom-one.git'); + }); + + it('overrides built-in template when custom has same name', async () => { + const builtInStarter = templates.find((t) => t.name === 'starter'); + expect(builtInStarter).toBeDefined(); + + const customPath = path.join(fixturesDir, 'override-starter.json'); + await fs.writeJson(customPath, [ + { + name: 'starter', + description: 'Overridden starter', + repo: 'https://github.com/custom/overridden-starter.git', + }, + ]); + + try { + const result = mergeTemplates(templates, customPath); + const starter = result.find((t) => t.name === 'starter'); + expect(starter?.description).toBe('Overridden starter'); + expect(starter?.repo).toBe('https://github.com/custom/overridden-starter.git'); + } finally { + await fs.remove(customPath); + } + }); +}); diff --git a/src/__tests__/fixtures/invalid-template-bad-options.json b/src/__tests__/fixtures/invalid-template-bad-options.json new file mode 100644 index 0000000..95dd23e --- /dev/null +++ b/src/__tests__/fixtures/invalid-template-bad-options.json @@ -0,0 +1 @@ +[{"name": "bad", "description": "Bad options", "repo": "https://example.com/repo.git", "options": [123]}] diff --git a/src/__tests__/fixtures/invalid-template-missing-name.json b/src/__tests__/fixtures/invalid-template-missing-name.json new file mode 100644 index 0000000..34eaf61 --- /dev/null +++ b/src/__tests__/fixtures/invalid-template-missing-name.json @@ -0,0 +1 @@ +[{"description": "No name", "repo": "https://example.com/repo.git"}] diff --git a/src/__tests__/fixtures/not-array.json b/src/__tests__/fixtures/not-array.json new file mode 100644 index 0000000..fb60768 --- /dev/null +++ b/src/__tests__/fixtures/not-array.json @@ -0,0 +1 @@ +{"templates": []} diff --git a/src/__tests__/fixtures/valid-templates.json b/src/__tests__/fixtures/valid-templates.json new file mode 100644 index 0000000..198db5d --- /dev/null +++ b/src/__tests__/fixtures/valid-templates.json @@ -0,0 +1,14 @@ +[ + { + "name": "custom-one", + "description": "A custom template", + "repo": "https://github.com/example/custom-one.git" + }, + { + "name": "custom-with-options", + "description": "Custom with clone options", + "repo": "https://github.com/example/custom.git", + "options": ["--depth", "1"], + "packageManager": "pnpm" + } +] diff --git a/src/cli.ts b/src/cli.ts index ed56c19..b6035d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,27 +5,36 @@ import { execa } from 'execa'; import inquirer from 'inquirer'; import fs from 'fs-extra'; import path from 'path'; -import templates from './templates.js'; +import { defaultTemplates }from './templates.js'; +import { mergeTemplates } from './template-loader.js'; +/** Project data provided by the user */ type ProjectData = { - name: string, + /** Project name */ + name: string, + /** Project version */ version: string, + /** Project description */ description: string, + /** Project author */ author: string } +/** Command to create a new project */ program .version('1.0.0') .command('create') .description('Create a new project from a git template') .argument('', 'The directory to create the project in') .argument('[template-name]', 'The name of the template to use') - .action(async (projectDirectory, templateName) => { - + .option('-t, --template-file ', 'Path to a JSON file with custom templates (same format as built-in)') + .action(async (projectDirectory, templateName, options) => { + const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile); + // If template name is not provided, show available templates and let user select if (!templateName) { console.log('\nšŸ“‹ Available templates:\n'); - templates.forEach(t => { + templatesToUse.forEach(t => { console.log(` ${t.name.padEnd(12)} - ${t.description}`); }); console.log(''); @@ -35,7 +44,7 @@ program type: 'list', name: 'templateName', message: 'Select a template:', - choices: templates.map(t => ({ + choices: templatesToUse.map(t => ({ name: `${t.name} - ${t.description}`, value: t.name })) @@ -47,11 +56,11 @@ program } // Look up the template by name - const template = templates.find(t => t.name === templateName); + const template = templatesToUse.find(t => t.name === templateName); if (!template) { console.error(`āŒ Template "${templateName}" not found.\n`); console.log('šŸ“‹ Available templates:\n'); - templates.forEach(t => { + templatesToUse.forEach(t => { console.log(` ${t.name.padEnd(12)} - ${t.description}`); }); console.log(''); @@ -158,13 +167,16 @@ program } }); +/** Command to list all available templates */ program .command('list') .description('List all available templates') .option('--verbose', 'List all available templates with verbose information') + .option('-t, --template-file ', 'Include templates from a JSON file (same format as built-in)') .action((options) => { + const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile); console.log('\nšŸ“‹ Available templates:\n'); - templates.forEach(template => { + templatesToUse.forEach(template => { console.log(` ${template.name.padEnd(20)} - ${template.description}`) if (options.verbose) { console.log(` Repo URL: ${template.repo}`); @@ -176,6 +188,7 @@ program console.log(''); }); +/** Command to run PatternFly codemods on a directory */ program .command('update') .description('Run PatternFly codemods on a directory to transform code to the latest PatternFly patterns') diff --git a/src/template-loader.ts b/src/template-loader.ts new file mode 100644 index 0000000..b33d93a --- /dev/null +++ b/src/template-loader.ts @@ -0,0 +1,78 @@ +import fs from 'fs-extra'; +import path from 'path'; +import type { Template } from './templates.js'; + +/** Functoin used to load custom templates from a JSON file */ +export function loadCustomTemplates(filePath: string): Template[] { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + console.error(`āŒ Template file not found: ${resolved}\n`); + process.exit(1); + } + const raw = fs.readFileSync(resolved, 'utf-8'); + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + console.error(`āŒ Invalid JSON in template file: ${resolved}\n`); + process.exit(1); + } + if (!Array.isArray(data)) { + console.error(`āŒ Template file must be a JSON array of templates.\n`); + process.exit(1); + } + const result: Template[] = []; + for (let i = 0; i < data.length; i++) { + const item = data[i]; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + console.error(`āŒ Template at index ${i}: must be an object.\n`); + process.exit(1); + } + const obj = item as Record; + const name = obj['name']; + const description = obj['description']; + const repo = obj['repo']; + if (typeof name !== 'string' || !name.trim()) { + console.error(`āŒ Template at index ${i}: "name" must be a non-empty string.\n`); + process.exit(1); + } + if (typeof description !== 'string') { + console.error(`āŒ Template at index ${i}: "description" must be a string.\n`); + process.exit(1); + } + if (typeof repo !== 'string' || !repo.trim()) { + console.error(`āŒ Template at index ${i}: "repo" must be a non-empty string.\n`); + process.exit(1); + } + const options = obj['options']; + const packageManager = obj['packageManager']; + if (options !== undefined && (!Array.isArray(options) || options.some((o) => typeof o !== 'string'))) { + console.error(`āŒ Template at index ${i}: "options" must be an array of strings.\n`); + process.exit(1); + } + if (packageManager !== undefined && typeof packageManager !== 'string') { + console.error(`āŒ Template at index ${i}: "packageManager" must be a string.\n`); + process.exit(1); + } + result.push({ + name: name.trim(), + description: String(description), + repo: repo.trim(), + ...(Array.isArray(options) && options.length > 0 && { options: options as string[] }), + ...(typeof packageManager === 'string' && packageManager.length > 0 && { packageManager }), + }); + } + return result; +} + +/** Function used to merge built-in templates with custom templates */ +export function mergeTemplates(builtIn: Template[], customFilePath?: string): Template[] { + if (!customFilePath) { + return builtIn; + } + const custom = loadCustomTemplates(customFilePath); + const byName = new Map(); + builtIn.forEach((t) => byName.set(t.name, t)); + custom.forEach((t) => byName.set(t.name, t)); + return [...byName.values()]; +} diff --git a/src/templates.ts b/src/templates.ts index fef58e3..c8ebe65 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,4 +1,17 @@ -const templates = [ +export type Template = { + /** Template name */ + name: string; + /** Template description */ + description: string; + /** Template repository URL */ + repo: string; + /** Template checkout options */ + options?: string[]; + /** Template package manager */ + packageManager?: string; +}; + +export const defaultTemplates: Template[] = [ { name: "starter", description: "A starter template for Patternfly react typescript project", @@ -19,11 +32,11 @@ const templates = [ packageManager: "yarn" }, { - name: "ai_enabled_starter", - description: "A starter template for Patternfly ai enabled project", - repo: "https://github.com/patternfly/patternfly-react-seed.git", - options: ["--single-branch", "--branch", "ai_enabled"] + name: "rhoai_enabled_starter", + description: "A starter template for Red Hat Open AI enabled project", + repo: "https://gitlab.cee.redhat.com/uxd/prototypes/rhoai", + options: ["--single-branch", "--branch", "3.2"] } ] -export default templates; \ No newline at end of file +export default defaultTemplates; \ No newline at end of file