Skip to content

Commit 4b6bd4e

Browse files
committed
feat: Added ability to pass in custom user defined templates, and added tests.
1 parent 7a3ac9f commit 4b6bd4e

File tree

10 files changed

+315
-10
lines changed

10 files changed

+315
-10
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: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import inquirer from 'inquirer';
66
import fs from 'fs-extra';
77
import path from 'path';
88
import templates from './templates.js';
9+
import { mergeTemplates } from './template-loader.js';
910

1011
type ProjectData = {
1112
name: string,
@@ -20,12 +21,14 @@ program
2021
.description('Create a new project from a git template')
2122
.argument('<project-directory>', 'The directory to create the project in')
2223
.argument('[template-name]', 'The name of the template to use')
23-
.action(async (projectDirectory, templateName) => {
24-
24+
.option('-t, --template-file <path>', 'Path to a JSON file with custom templates (same format as built-in)')
25+
.action(async (projectDirectory, templateName, options) => {
26+
const templatesToUse = mergeTemplates(templates, options?.templateFile);
27+
2528
// If template name is not provided, show available templates and let user select
2629
if (!templateName) {
2730
console.log('\n📋 Available templates:\n');
28-
templates.forEach(t => {
31+
templatesToUse.forEach(t => {
2932
console.log(` ${t.name.padEnd(12)} - ${t.description}`);
3033
});
3134
console.log('');
@@ -35,7 +38,7 @@ program
3538
type: 'list',
3639
name: 'templateName',
3740
message: 'Select a template:',
38-
choices: templates.map(t => ({
41+
choices: templatesToUse.map(t => ({
3942
name: `${t.name} - ${t.description}`,
4043
value: t.name
4144
}))
@@ -47,11 +50,11 @@ program
4750
}
4851

4952
// Look up the template by name
50-
const template = templates.find(t => t.name === templateName);
53+
const template = templatesToUse.find(t => t.name === templateName);
5154
if (!template) {
5255
console.error(`❌ Template "${templateName}" not found.\n`);
5356
console.log('📋 Available templates:\n');
54-
templates.forEach(t => {
57+
templatesToUse.forEach(t => {
5558
console.log(` ${t.name.padEnd(12)} - ${t.description}`);
5659
});
5760
console.log('');
@@ -162,9 +165,11 @@ program
162165
.command('list')
163166
.description('List all available templates')
164167
.option('--verbose', 'List all available templates with verbose information')
168+
.option('-t, --template-file <path>', 'Include templates from a JSON file (same format as built-in)')
165169
.action((options) => {
170+
const templatesToUse = mergeTemplates(templates, options?.templateFile);
166171
console.log('\n📋 Available templates:\n');
167-
templates.forEach(template => {
172+
templatesToUse.forEach(template => {
168173
console.log(` ${template.name.padEnd(20)} - ${template.description}`)
169174
if (options.verbose) {
170175
console.log(` Repo URL: ${template.repo}`);

src/template-loader.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import fs from 'fs-extra';
2+
import path from 'path';
3+
import type { Template } from './templates.js';
4+
5+
export function loadCustomTemplates(filePath: string): Template[] {
6+
const resolved = path.resolve(filePath);
7+
if (!fs.existsSync(resolved)) {
8+
console.error(`❌ Template file not found: ${resolved}\n`);
9+
process.exit(1);
10+
}
11+
const raw = fs.readFileSync(resolved, 'utf-8');
12+
let data: unknown;
13+
try {
14+
data = JSON.parse(raw);
15+
} catch {
16+
console.error(`❌ Invalid JSON in template file: ${resolved}\n`);
17+
process.exit(1);
18+
}
19+
if (!Array.isArray(data)) {
20+
console.error(`❌ Template file must be a JSON array of templates.\n`);
21+
process.exit(1);
22+
}
23+
const result: Template[] = [];
24+
for (let i = 0; i < data.length; i++) {
25+
const item = data[i];
26+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
27+
console.error(`❌ Template at index ${i}: must be an object.\n`);
28+
process.exit(1);
29+
}
30+
const obj = item as Record<string, unknown>;
31+
const name = obj['name'];
32+
const description = obj['description'];
33+
const repo = obj['repo'];
34+
if (typeof name !== 'string' || !name.trim()) {
35+
console.error(`❌ Template at index ${i}: "name" must be a non-empty string.\n`);
36+
process.exit(1);
37+
}
38+
if (typeof description !== 'string') {
39+
console.error(`❌ Template at index ${i}: "description" must be a string.\n`);
40+
process.exit(1);
41+
}
42+
if (typeof repo !== 'string' || !repo.trim()) {
43+
console.error(`❌ Template at index ${i}: "repo" must be a non-empty string.\n`);
44+
process.exit(1);
45+
}
46+
const options = obj['options'];
47+
const packageManager = obj['packageManager'];
48+
if (options !== undefined && (!Array.isArray(options) || options.some((o) => typeof o !== 'string'))) {
49+
console.error(`❌ Template at index ${i}: "options" must be an array of strings.\n`);
50+
process.exit(1);
51+
}
52+
if (packageManager !== undefined && typeof packageManager !== 'string') {
53+
console.error(`❌ Template at index ${i}: "packageManager" must be a string.\n`);
54+
process.exit(1);
55+
}
56+
result.push({
57+
name: name.trim(),
58+
description: String(description),
59+
repo: repo.trim(),
60+
...(Array.isArray(options) && options.length > 0 && { options: options as string[] }),
61+
...(typeof packageManager === 'string' && packageManager.length > 0 && { packageManager }),
62+
});
63+
}
64+
return result;
65+
}
66+
67+
export function mergeTemplates(builtIn: Template[], customFilePath?: string): Template[] {
68+
if (!customFilePath) {
69+
return builtIn;
70+
}
71+
const custom = loadCustomTemplates(customFilePath);
72+
const byName = new Map<string, Template>();
73+
builtIn.forEach((t) => byName.set(t.name, t));
74+
custom.forEach((t) => byName.set(t.name, t));
75+
return [...byName.values()];
76+
}

src/templates.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
const templates = [
1+
export type Template = {
2+
name: string;
3+
description: string;
4+
repo: string;
5+
options?: string[];
6+
packageManager?: string;
7+
};
8+
9+
const templates: Template[] = [
210
{
311
name: "starter",
412
description: "A starter template for Patternfly react typescript project",

0 commit comments

Comments
 (0)