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
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@
"moduleFileExtensions": [
"ts",
"js"
]
],
"transform": {
"^.+\\.tsx?$": [
"ts-jest",
{
"tsconfig": {
"module": "commonjs",
"moduleResolution": "node"
}
}
]
},
"moduleNameMapper": {
"^(\\.\\./.*)\\.js$": "$1"
}
},
"dependencies": {
"0g": "^0.4.2",
Expand Down
148 changes: 148 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
1 change: 1 addition & 0 deletions src/__tests__/fixtures/invalid-template-bad-options.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"name": "bad", "description": "Bad options", "repo": "https://example.com/repo.git", "options": [123]}]
1 change: 1 addition & 0 deletions src/__tests__/fixtures/invalid-template-missing-name.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"description": "No name", "repo": "https://example.com/repo.git"}]
1 change: 1 addition & 0 deletions src/__tests__/fixtures/not-array.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"templates": []}
14 changes: 14 additions & 0 deletions src/__tests__/fixtures/valid-templates.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
31 changes: 22 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<project-directory>', '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>', '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('');
Expand All @@ -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
}))
Expand All @@ -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('');
Expand Down Expand Up @@ -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 <path>', '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}`);
Expand All @@ -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')
Expand Down
Loading