Skip to content

Commit caacef1

Browse files
authored
Merge pull request #11 from patternfly/issue-6
feat: Custom templates, and unit tests.
2 parents 7a3ac9f + ba87d74 commit caacef1

File tree

10 files changed

+337
-17
lines changed

10 files changed

+337
-17
lines changed

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,44 @@ patternfly-cli [command]
2727
### Available Commands
2828

2929
- **`create`**: Create a new project from the available templates.
30-
- **`update`**: Update your project to a newer version .
30+
- **`list`**: List all available templates (built-in and optional custom).
31+
- **`update`**: Update your project to a newer version.
32+
33+
### Custom templates
34+
35+
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.
36+
37+
**Create with custom templates:**
38+
39+
```sh
40+
patternfly-cli create my-app --template-file ./my-templates.json
41+
```
42+
43+
**List templates including custom file:**
44+
45+
```sh
46+
patternfly-cli list --template-file ./my-templates.json
47+
```
48+
49+
**JSON format** (array of template objects, same shape as the built-in templates):
50+
51+
```json
52+
[
53+
{
54+
"name": "my-template",
55+
"description": "My custom project template",
56+
"repo": "https://github.com/org/repo.git",
57+
"options": ["--single-branch", "--branch", "main"],
58+
"packageManager": "npm"
59+
}
60+
]
61+
```
62+
63+
- **`name`** (required): Template identifier.
64+
- **`description`** (required): Short description shown in prompts and `list`.
65+
- **`repo`** (required): Git clone URL.
66+
- **`options`** (optional): Array of extra arguments for `git clone` (e.g. `["--single-branch", "--branch", "main"]`).
67+
- **`packageManager`** (optional): `npm`, `yarn`, or `pnpm`; defaults to `npm` if omitted.
3168

3269

3370
## Development / Installation

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,21 @@
2323
"moduleFileExtensions": [
2424
"ts",
2525
"js"
26-
]
26+
],
27+
"transform": {
28+
"^.+\\.tsx?$": [
29+
"ts-jest",
30+
{
31+
"tsconfig": {
32+
"module": "commonjs",
33+
"moduleResolution": "node"
34+
}
35+
}
36+
]
37+
},
38+
"moduleNameMapper": {
39+
"^(\\.\\./.*)\\.js$": "$1"
40+
}
2741
},
2842
"dependencies": {
2943
"0g": "^0.4.2",

src/__tests__/cli.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import path from 'path';
2+
import fs from 'fs-extra';
3+
import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
4+
import templates from '../templates.js';
5+
6+
const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures');
7+
8+
describe('loadCustomTemplates', () => {
9+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
10+
throw new Error(`process.exit(${code})`);
11+
}) as () => never);
12+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
13+
14+
afterEach(() => {
15+
consoleErrorSpy.mockClear();
16+
});
17+
18+
afterAll(() => {
19+
exitSpy.mockRestore();
20+
consoleErrorSpy.mockRestore();
21+
});
22+
23+
it('loads and parses a valid template file', () => {
24+
const filePath = path.join(fixturesDir, 'valid-templates.json');
25+
const result = loadCustomTemplates(filePath);
26+
27+
expect(result).toHaveLength(2);
28+
expect(result[0]).toEqual({
29+
name: 'custom-one',
30+
description: 'A custom template',
31+
repo: 'https://github.com/example/custom-one.git',
32+
});
33+
expect(result[1]).toEqual({
34+
name: 'custom-with-options',
35+
description: 'Custom with clone options',
36+
repo: 'https://github.com/example/custom.git',
37+
options: ['--depth', '1'],
38+
packageManager: 'pnpm',
39+
});
40+
});
41+
42+
it('exits when file does not exist', () => {
43+
const filePath = path.join(fixturesDir, 'nonexistent.json');
44+
45+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
46+
expect(consoleErrorSpy).toHaveBeenCalledWith(
47+
expect.stringContaining('Template file not found'),
48+
);
49+
});
50+
51+
it('exits when file contains invalid JSON', async () => {
52+
const invalidPath = path.join(fixturesDir, 'invalid-json.txt');
53+
await fs.writeFile(invalidPath, 'not valid json {');
54+
55+
try {
56+
expect(() => loadCustomTemplates(invalidPath)).toThrow('process.exit(1)');
57+
expect(consoleErrorSpy).toHaveBeenCalledWith(
58+
expect.stringContaining('Invalid JSON'),
59+
);
60+
} finally {
61+
await fs.remove(invalidPath);
62+
}
63+
});
64+
65+
it('exits when JSON is not an array', () => {
66+
const filePath = path.join(fixturesDir, 'not-array.json');
67+
68+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
69+
expect(consoleErrorSpy).toHaveBeenCalledWith(
70+
expect.stringContaining('must be a JSON array'),
71+
);
72+
});
73+
74+
it('exits when template is missing required name', () => {
75+
const filePath = path.join(fixturesDir, 'invalid-template-missing-name.json');
76+
77+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
78+
expect(consoleErrorSpy).toHaveBeenCalledWith(
79+
expect.stringContaining('"name" must be'),
80+
);
81+
});
82+
83+
it('exits when template has invalid options (non-string array)', () => {
84+
const filePath = path.join(fixturesDir, 'invalid-template-bad-options.json');
85+
86+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
87+
expect(consoleErrorSpy).toHaveBeenCalledWith(
88+
expect.stringContaining('"options" must be'),
89+
);
90+
});
91+
});
92+
93+
describe('mergeTemplates', () => {
94+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
95+
throw new Error(`process.exit(${code})`);
96+
}) as () => never);
97+
98+
afterAll(() => {
99+
exitSpy.mockRestore();
100+
});
101+
it('returns built-in templates when no custom file path is provided', () => {
102+
const result = mergeTemplates(templates);
103+
104+
expect(result).toEqual(templates);
105+
expect(result).toHaveLength(templates.length);
106+
});
107+
108+
it('returns built-in templates when custom file path is undefined', () => {
109+
const result = mergeTemplates(templates, undefined);
110+
111+
expect(result).toEqual(templates);
112+
});
113+
114+
it('merges custom templates with built-in, custom overrides by name', () => {
115+
const customPath = path.join(fixturesDir, 'valid-templates.json');
116+
const result = mergeTemplates(templates, customPath);
117+
118+
const names = result.map((t) => t.name);
119+
expect(names).toContain('custom-one');
120+
expect(names).toContain('custom-with-options');
121+
122+
const customOne = result.find((t) => t.name === 'custom-one');
123+
expect(customOne?.repo).toBe('https://github.com/example/custom-one.git');
124+
});
125+
126+
it('overrides built-in template when custom has same name', async () => {
127+
const builtInStarter = templates.find((t) => t.name === 'starter');
128+
expect(builtInStarter).toBeDefined();
129+
130+
const customPath = path.join(fixturesDir, 'override-starter.json');
131+
await fs.writeJson(customPath, [
132+
{
133+
name: 'starter',
134+
description: 'Overridden starter',
135+
repo: 'https://github.com/custom/overridden-starter.git',
136+
},
137+
]);
138+
139+
try {
140+
const result = mergeTemplates(templates, customPath);
141+
const starter = result.find((t) => t.name === 'starter');
142+
expect(starter?.description).toBe('Overridden starter');
143+
expect(starter?.repo).toBe('https://github.com/custom/overridden-starter.git');
144+
} finally {
145+
await fs.remove(customPath);
146+
}
147+
});
148+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"name": "bad", "description": "Bad options", "repo": "https://example.com/repo.git", "options": [123]}]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"description": "No name", "repo": "https://example.com/repo.git"}]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"templates": []}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"name": "custom-one",
4+
"description": "A custom template",
5+
"repo": "https://github.com/example/custom-one.git"
6+
},
7+
{
8+
"name": "custom-with-options",
9+
"description": "Custom with clone options",
10+
"repo": "https://github.com/example/custom.git",
11+
"options": ["--depth", "1"],
12+
"packageManager": "pnpm"
13+
}
14+
]

src/cli.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,36 @@ import { execa } from 'execa';
55
import inquirer from 'inquirer';
66
import fs from 'fs-extra';
77
import path from 'path';
8-
import templates from './templates.js';
8+
import { defaultTemplates }from './templates.js';
9+
import { mergeTemplates } from './template-loader.js';
910

11+
/** Project data provided by the user */
1012
type ProjectData = {
11-
name: string,
13+
/** Project name */
14+
name: string,
15+
/** Project version */
1216
version: string,
17+
/** Project description */
1318
description: string,
19+
/** Project author */
1420
author: string
1521
}
1622

23+
/** Command to create a new project */
1724
program
1825
.version('1.0.0')
1926
.command('create')
2027
.description('Create a new project from a git template')
2128
.argument('<project-directory>', 'The directory to create the project in')
2229
.argument('[template-name]', 'The name of the template to use')
23-
.action(async (projectDirectory, templateName) => {
24-
30+
.option('-t, --template-file <path>', 'Path to a JSON file with custom templates (same format as built-in)')
31+
.action(async (projectDirectory, templateName, options) => {
32+
const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile);
33+
2534
// If template name is not provided, show available templates and let user select
2635
if (!templateName) {
2736
console.log('\n📋 Available templates:\n');
28-
templates.forEach(t => {
37+
templatesToUse.forEach(t => {
2938
console.log(` ${t.name.padEnd(12)} - ${t.description}`);
3039
});
3140
console.log('');
@@ -35,7 +44,7 @@ program
3544
type: 'list',
3645
name: 'templateName',
3746
message: 'Select a template:',
38-
choices: templates.map(t => ({
47+
choices: templatesToUse.map(t => ({
3948
name: `${t.name} - ${t.description}`,
4049
value: t.name
4150
}))
@@ -47,11 +56,11 @@ program
4756
}
4857

4958
// Look up the template by name
50-
const template = templates.find(t => t.name === templateName);
59+
const template = templatesToUse.find(t => t.name === templateName);
5160
if (!template) {
5261
console.error(`❌ Template "${templateName}" not found.\n`);
5362
console.log('📋 Available templates:\n');
54-
templates.forEach(t => {
63+
templatesToUse.forEach(t => {
5564
console.log(` ${t.name.padEnd(12)} - ${t.description}`);
5665
});
5766
console.log('');
@@ -158,13 +167,16 @@ program
158167
}
159168
});
160169

170+
/** Command to list all available templates */
161171
program
162172
.command('list')
163173
.description('List all available templates')
164174
.option('--verbose', 'List all available templates with verbose information')
175+
.option('-t, --template-file <path>', 'Include templates from a JSON file (same format as built-in)')
165176
.action((options) => {
177+
const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile);
166178
console.log('\n📋 Available templates:\n');
167-
templates.forEach(template => {
179+
templatesToUse.forEach(template => {
168180
console.log(` ${template.name.padEnd(20)} - ${template.description}`)
169181
if (options.verbose) {
170182
console.log(` Repo URL: ${template.repo}`);
@@ -176,6 +188,7 @@ program
176188
console.log('');
177189
});
178190

191+
/** Command to run PatternFly codemods on a directory */
179192
program
180193
.command('update')
181194
.description('Run PatternFly codemods on a directory to transform code to the latest PatternFly patterns')

0 commit comments

Comments
 (0)