@patternfly/patternfly-cli 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.github/workflows/build.yml +21 -0
  2. package/.github/workflows/lint.yml +24 -0
  3. package/.github/workflows/release.yml +42 -0
  4. package/.github/workflows/test.yml +24 -0
  5. package/.releaserc.json +14 -0
  6. package/LICENSE +21 -0
  7. package/README.md +110 -0
  8. package/__mocks__/execa.js +5 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +272 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/github.d.ts +33 -0
  14. package/dist/github.d.ts.map +1 -0
  15. package/dist/github.js +180 -0
  16. package/dist/github.js.map +1 -0
  17. package/dist/load.d.ts +6 -0
  18. package/dist/load.d.ts.map +1 -0
  19. package/dist/load.js +40 -0
  20. package/dist/load.js.map +1 -0
  21. package/dist/save.d.ts +6 -0
  22. package/dist/save.d.ts.map +1 -0
  23. package/dist/save.js +97 -0
  24. package/dist/save.js.map +1 -0
  25. package/dist/template-loader.d.ts +6 -0
  26. package/dist/template-loader.d.ts.map +1 -0
  27. package/dist/template-loader.js +83 -0
  28. package/dist/template-loader.js.map +1 -0
  29. package/dist/templates.d.ts +17 -0
  30. package/dist/templates.d.ts.map +1 -0
  31. package/dist/templates.js +33 -0
  32. package/dist/templates.js.map +1 -0
  33. package/eslint.config.js +19 -0
  34. package/package.json +80 -0
  35. package/src/__tests__/cli.test.ts +171 -0
  36. package/src/__tests__/fixtures/invalid-template-bad-options.json +1 -0
  37. package/src/__tests__/fixtures/invalid-template-missing-name.json +1 -0
  38. package/src/__tests__/fixtures/not-array.json +1 -0
  39. package/src/__tests__/fixtures/valid-templates.json +14 -0
  40. package/src/__tests__/github.test.ts +214 -0
  41. package/src/__tests__/load.test.ts +106 -0
  42. package/src/__tests__/save.test.ts +272 -0
  43. package/src/cli.ts +307 -0
  44. package/src/github.ts +193 -0
  45. package/src/load.ts +38 -0
  46. package/src/save.ts +103 -0
  47. package/src/template-loader.ts +84 -0
  48. package/src/templates.ts +48 -0
  49. package/tsconfig.json +50 -0
@@ -0,0 +1,272 @@
1
+ jest.mock('fs-extra', () => ({
2
+ __esModule: true,
3
+ default: {
4
+ pathExists: jest.fn(),
5
+ },
6
+ }));
7
+
8
+ jest.mock('execa', () => ({
9
+ __esModule: true,
10
+ execa: jest.fn(),
11
+ }));
12
+
13
+ jest.mock('inquirer', () => ({
14
+ __esModule: true,
15
+ default: {
16
+ prompt: jest.fn(),
17
+ },
18
+ }));
19
+
20
+ jest.mock('../github.js', () => ({
21
+ offerAndCreateGitHubRepo: jest.fn(),
22
+ }));
23
+
24
+ import fs from 'fs-extra';
25
+ import { execa } from 'execa';
26
+ import inquirer from 'inquirer';
27
+ import { offerAndCreateGitHubRepo } from '../github.js';
28
+ import { runSave } from '../save.js';
29
+
30
+ const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
31
+ const mockExeca = execa as jest.MockedFunction<typeof execa>;
32
+ const mockPrompt = inquirer.prompt as jest.MockedFunction<typeof inquirer.prompt>;
33
+ const mockOfferAndCreateGitHubRepo = offerAndCreateGitHubRepo as jest.MockedFunction<typeof offerAndCreateGitHubRepo>;
34
+
35
+ const cwd = '/tmp/my-repo';
36
+
37
+ describe('runSave', () => {
38
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
39
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
40
+
41
+ beforeEach(() => {
42
+ jest.clearAllMocks();
43
+ });
44
+
45
+ afterAll(() => {
46
+ consoleErrorSpy.mockRestore();
47
+ consoleLogSpy.mockRestore();
48
+ });
49
+
50
+ it('throws and logs error when .git directory does not exist', async () => {
51
+ mockPathExists.mockResolvedValue(false);
52
+
53
+ await expect(runSave(cwd)).rejects.toThrow('Not a git repository');
54
+ expect(mockPathExists).toHaveBeenCalledWith(`${cwd}/.git`);
55
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
56
+ expect.stringContaining('This directory is not a git repository'),
57
+ );
58
+ expect(mockExeca).not.toHaveBeenCalled();
59
+ expect(mockPrompt).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('logs "No changes to save" and returns when working tree is clean', async () => {
63
+ mockPathExists.mockResolvedValue(true);
64
+ mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
65
+
66
+ await runSave(cwd);
67
+
68
+ expect(mockExeca).toHaveBeenCalledTimes(1);
69
+ expect(mockExeca).toHaveBeenCalledWith('git', ['status', '--porcelain'], {
70
+ cwd,
71
+ encoding: 'utf8',
72
+ });
73
+ expect(consoleLogSpy).toHaveBeenCalledWith(
74
+ expect.stringContaining('No changes to save'),
75
+ );
76
+ expect(mockPrompt).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('logs "No changes to save" when status output is only whitespace', async () => {
80
+ mockPathExists.mockResolvedValue(true);
81
+ mockExeca.mockResolvedValue({ stdout: ' \n ', stderr: '', exitCode: 0 });
82
+
83
+ await runSave(cwd);
84
+
85
+ expect(consoleLogSpy).toHaveBeenCalledWith(
86
+ expect.stringContaining('No changes to save'),
87
+ );
88
+ expect(mockPrompt).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('prompts to save; when user says no, logs "Nothing has been saved"', async () => {
92
+ mockPathExists.mockResolvedValue(true);
93
+ mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 });
94
+ mockPrompt.mockResolvedValueOnce({ saveChanges: false });
95
+
96
+ await runSave(cwd);
97
+
98
+ expect(mockPrompt).toHaveBeenCalledTimes(1);
99
+ expect(mockPrompt).toHaveBeenCalledWith([
100
+ expect.objectContaining({
101
+ type: 'confirm',
102
+ name: 'saveChanges',
103
+ message: expect.stringContaining('Would you like to save them?'),
104
+ }),
105
+ ]);
106
+ expect(consoleLogSpy).toHaveBeenCalledWith(
107
+ expect.stringContaining('Nothing has been saved'),
108
+ );
109
+ expect(mockExeca).toHaveBeenCalledTimes(1); // only status, no add/commit/push
110
+ });
111
+
112
+ it('when user says yes but message is empty, logs "No message provided"', async () => {
113
+ mockPathExists.mockResolvedValue(true);
114
+ mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 });
115
+ mockPrompt
116
+ .mockResolvedValueOnce({ saveChanges: true })
117
+ .mockResolvedValueOnce({ message: ' ' });
118
+
119
+ await runSave(cwd);
120
+
121
+ expect(mockPrompt).toHaveBeenCalledTimes(2);
122
+ expect(consoleLogSpy).toHaveBeenCalledWith(
123
+ expect.stringContaining('No message provided'),
124
+ );
125
+ expect(mockExeca).toHaveBeenCalledTimes(1); // only status
126
+ });
127
+
128
+ it('when user says yes and provides message, runs add, commit, push and logs success', async () => {
129
+ mockPathExists.mockResolvedValue(true);
130
+ mockExeca
131
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
132
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
133
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
134
+ .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 })
135
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
136
+ mockPrompt
137
+ .mockResolvedValueOnce({ saveChanges: true })
138
+ .mockResolvedValueOnce({ message: 'Fix bug in save command' });
139
+
140
+ await runSave(cwd);
141
+
142
+ expect(mockExeca).toHaveBeenCalledTimes(5);
143
+ expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['status', '--porcelain'], {
144
+ cwd,
145
+ encoding: 'utf8',
146
+ });
147
+ expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['add', '.'], {
148
+ cwd,
149
+ stdio: 'inherit',
150
+ });
151
+ expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', [
152
+ 'commit',
153
+ '-m',
154
+ 'Fix bug in save command',
155
+ ], { cwd, stdio: 'inherit' });
156
+ expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['remote', 'get-url', 'origin'], expect.any(Object));
157
+ expect(mockExeca).toHaveBeenNthCalledWith(5, 'git', ['push'], {
158
+ cwd,
159
+ stdio: 'inherit',
160
+ });
161
+ expect(consoleLogSpy).toHaveBeenCalledWith(
162
+ expect.stringContaining('Changes saved and pushed to GitHub successfully'),
163
+ );
164
+ });
165
+
166
+ it('throws and logs push-failure message when push fails with exitCode 128', async () => {
167
+ mockPathExists.mockResolvedValue(true);
168
+ mockExeca
169
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
170
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
171
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
172
+ .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 })
173
+ .mockRejectedValueOnce(Object.assign(new Error('push failed'), { exitCode: 128 }));
174
+
175
+ mockPrompt
176
+ .mockResolvedValueOnce({ saveChanges: true })
177
+ .mockResolvedValueOnce({ message: 'WIP' });
178
+
179
+ await expect(runSave(cwd)).rejects.toMatchObject({ message: 'push failed' });
180
+
181
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
182
+ expect.stringContaining('Push failed'),
183
+ );
184
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
185
+ expect.stringContaining('remote'),
186
+ );
187
+ });
188
+
189
+ it('throws and logs generic failure when add/commit/push fails with other exitCode', async () => {
190
+ mockPathExists.mockResolvedValue(true);
191
+ mockExeca
192
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
193
+ .mockRejectedValueOnce(Object.assign(new Error('add failed'), { exitCode: 1 }));
194
+
195
+ mockPrompt
196
+ .mockResolvedValueOnce({ saveChanges: true })
197
+ .mockResolvedValueOnce({ message: 'WIP' });
198
+
199
+ await expect(runSave(cwd)).rejects.toMatchObject({ message: 'add failed' });
200
+
201
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
202
+ expect.stringContaining('Save or push failed'),
203
+ );
204
+ });
205
+
206
+ it('throws and logs error when execa throws without exitCode', async () => {
207
+ mockPathExists.mockResolvedValue(true);
208
+ mockExeca
209
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
210
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
211
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
212
+ .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 })
213
+ .mockRejectedValueOnce(new Error('network error'));
214
+
215
+ mockPrompt
216
+ .mockResolvedValueOnce({ saveChanges: true })
217
+ .mockResolvedValueOnce({ message: 'WIP' });
218
+
219
+ await expect(runSave(cwd)).rejects.toThrow('network error');
220
+
221
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
222
+ expect.stringContaining('An error occurred'),
223
+ );
224
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
225
+ expect.stringContaining('network error'),
226
+ );
227
+ });
228
+
229
+ it('when no remote origin, offers to create GitHub repo and throws if user does not create', async () => {
230
+ mockPathExists.mockResolvedValue(true);
231
+ mockExeca
232
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
233
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
234
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
235
+ .mockRejectedValueOnce(new Error('no remote'));
236
+ mockOfferAndCreateGitHubRepo.mockResolvedValue(false);
237
+
238
+ mockPrompt
239
+ .mockResolvedValueOnce({ saveChanges: true })
240
+ .mockResolvedValueOnce({ message: 'WIP' });
241
+
242
+ await expect(runSave(cwd)).rejects.toThrow('No remote origin');
243
+ expect(mockOfferAndCreateGitHubRepo).toHaveBeenCalledWith(cwd);
244
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
245
+ expect.stringContaining('Set a remote'),
246
+ );
247
+ expect(mockExeca).not.toHaveBeenCalledWith('git', ['push'], expect.any(Object));
248
+ });
249
+
250
+ it('when no remote origin and user creates GitHub repo, pushes successfully', async () => {
251
+ mockPathExists.mockResolvedValue(true);
252
+ mockExeca
253
+ .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 })
254
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
255
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
256
+ .mockRejectedValueOnce(new Error('no remote'))
257
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
258
+ mockOfferAndCreateGitHubRepo.mockResolvedValue(true);
259
+
260
+ mockPrompt
261
+ .mockResolvedValueOnce({ saveChanges: true })
262
+ .mockResolvedValueOnce({ message: 'WIP' });
263
+
264
+ await runSave(cwd);
265
+
266
+ expect(mockOfferAndCreateGitHubRepo).toHaveBeenCalledWith(cwd);
267
+ expect(mockExeca).toHaveBeenCalledWith('git', ['push'], { cwd, stdio: 'inherit' });
268
+ expect(consoleLogSpy).toHaveBeenCalledWith(
269
+ expect.stringContaining('Changes saved and pushed to GitHub successfully'),
270
+ );
271
+ });
272
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { execa } from 'execa';
5
+ import inquirer from 'inquirer';
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { defaultTemplates } from './templates.js';
9
+ import { mergeTemplates } from './template-loader.js';
10
+ import { offerAndCreateGitHubRepo } from './github.js';
11
+ import { runSave } from './save.js';
12
+ import { runLoad } from './load.js';
13
+
14
+ /** Project data provided by the user */
15
+ type ProjectData = {
16
+ /** Project name */
17
+ name: string,
18
+ /** Project version */
19
+ version: string,
20
+ /** Project description */
21
+ description: string,
22
+ /** Project author */
23
+ author: string
24
+ }
25
+
26
+ /** Command to create a new project */
27
+ program
28
+ .version('1.0.0')
29
+ .command('create')
30
+ .description('Create a new project from a git template')
31
+ .argument('[project-directory]', 'The directory to create the project in')
32
+ .argument('[template-name]', 'The name of the template to use')
33
+ .option('-t, --template-file <path>', 'Path to a JSON file with custom templates (same format as built-in)')
34
+ .option('--ssh', 'Use SSH URL for cloning the template repository')
35
+ .action(async (projectDirectory, templateName, options) => {
36
+ const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile);
37
+
38
+ // If project directory is not provided, prompt for it
39
+ if (!projectDirectory) {
40
+ const projectDirAnswer = await inquirer.prompt([
41
+ {
42
+ type: 'input',
43
+ name: 'projectDirectory',
44
+ message: 'Please provide the directory where you want to create the project?',
45
+ default: 'my-app',
46
+ },
47
+ ]);
48
+ projectDirectory = projectDirAnswer.projectDirectory;
49
+ }
50
+
51
+ // If template name is not provided, show available templates and let user select
52
+ if (!templateName) {
53
+ console.log('\n๐Ÿ“‹ Available templates:\n');
54
+ templatesToUse.forEach(t => {
55
+ console.log(` ${t.name.padEnd(12)} - ${t.description}`);
56
+ });
57
+ console.log('');
58
+
59
+ const templateQuestion = [
60
+ {
61
+ type: 'list',
62
+ name: 'templateName',
63
+ message: 'Select a template:',
64
+ choices: templatesToUse.map(t => ({
65
+ name: `${t.name} - ${t.description}`,
66
+ value: t.name
67
+ }))
68
+ }
69
+ ];
70
+
71
+ const templateAnswer = await inquirer.prompt(templateQuestion);
72
+ templateName = templateAnswer.templateName;
73
+ }
74
+
75
+ // Look up the template by name
76
+ const template = templatesToUse.find(t => t.name === templateName);
77
+ if (!template) {
78
+ console.error(`โŒ Template "${templateName}" not found.\n`);
79
+ console.log('๐Ÿ“‹ Available templates:\n');
80
+ templatesToUse.forEach(t => {
81
+ console.log(` ${t.name.padEnd(12)} - ${t.description}`);
82
+ });
83
+ console.log('');
84
+ process.exit(1);
85
+ }
86
+
87
+ // If --ssh was not passed, prompt whether to use SSH
88
+ let useSSH = options?.ssh;
89
+ if (useSSH === undefined && template.repoSSH) {
90
+ const sshAnswer = await inquirer.prompt([
91
+ {
92
+ type: 'confirm',
93
+ name: 'useSSH',
94
+ message: 'Use SSH URL for cloning?',
95
+ default: false,
96
+ },
97
+ ]);
98
+ useSSH = sshAnswer.useSSH;
99
+ }
100
+
101
+ const templateRepoUrl = useSSH && template.repoSSH ? template.repoSSH : template.repo;
102
+
103
+ // Define the full path for the new project
104
+ const projectPath = path.resolve(projectDirectory);
105
+ console.log(`Cloning template "${templateName}" from ${templateRepoUrl} into ${projectPath}...`);
106
+
107
+ try {
108
+
109
+ // Clone the repository
110
+ const cloneArgs = ['clone'];
111
+ if (template.options && Array.isArray(template.options)) {
112
+ cloneArgs.push(...template.options);
113
+ }
114
+ cloneArgs.push(templateRepoUrl, projectPath);
115
+ await execa('git', cloneArgs, { stdio: 'inherit' });
116
+ console.log('โœ… Template cloned successfully.');
117
+
118
+ // Remove the .git folder from the *new* project
119
+ await fs.remove(path.join(projectPath, '.git'));
120
+ console.log('๐Ÿงน Cleaned up template .git directory.');
121
+
122
+ // Ask user for customization details
123
+ const questions = [
124
+ {
125
+ type: 'input',
126
+ name: 'name',
127
+ message: 'What is the project name?',
128
+ default: path.basename(projectPath),
129
+ },
130
+ {
131
+ type: 'input',
132
+ name: 'version',
133
+ message: 'What version number would you like to use?',
134
+ default: '1.0.0',
135
+ },
136
+ {
137
+ type: 'input',
138
+ name: 'description',
139
+ message: 'What is the project description?',
140
+ default: '',
141
+ },
142
+ {
143
+ type: 'input',
144
+ name: 'author',
145
+ message: 'Who is the author of the project?',
146
+ default: '',
147
+ },
148
+ ];
149
+
150
+ const answers: ProjectData = await inquirer.prompt(questions);
151
+
152
+ // Update the package.json in the new project
153
+ const pkgJsonPath = path.join(projectPath, 'package.json');
154
+
155
+ if (await fs.pathExists(pkgJsonPath)) {
156
+ const pkgJson = await fs.readJson(pkgJsonPath);
157
+
158
+ // Overwrite fields with user's answers
159
+ pkgJson.name = answers.name;
160
+ pkgJson.version = answers.version;
161
+ pkgJson.description = answers.description;
162
+ pkgJson.author = answers.author;
163
+
164
+ // Write the updated package.json back
165
+ await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
166
+ console.log('๐Ÿ“ Customized package.json.');
167
+ } else {
168
+ console.log('โ„น๏ธ No package.json found in template, skipping customization.');
169
+ }
170
+
171
+ const packageManager = template.packageManager || "npm";
172
+ // Install dependencies
173
+ console.log('๐Ÿ“ฆ Installing dependencies... (This may take a moment)');
174
+ await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' });
175
+ console.log('โœ… Dependencies installed.');
176
+
177
+ // Optional: Create GitHub repository
178
+ await offerAndCreateGitHubRepo(projectPath);
179
+
180
+ // Let the user know the project was created successfully
181
+ console.log('\nโœจ Project created successfully! โœจ\n');
182
+ console.log(`To get started:`);
183
+ console.log(` cd ${projectDirectory}`);
184
+ console.log(' Happy coding! ๐Ÿš€');
185
+
186
+ } catch (error) {
187
+ console.error('โŒ An error occurred:');
188
+ if (error instanceof Error) {
189
+ console.error(error.message);
190
+ } else if (error && typeof error === 'object' && 'stderr' in error) {
191
+ console.error((error as { stderr?: string }).stderr || String(error));
192
+ } else {
193
+ console.error(String(error));
194
+ }
195
+
196
+ // Clean up the created directory if an error occurred
197
+ if (await fs.pathExists(projectPath)) {
198
+ await fs.remove(projectPath);
199
+ console.log('๐Ÿงน Cleaned up failed project directory.');
200
+ }
201
+ }
202
+ });
203
+
204
+ /** Command to initialize a project and optionally create a GitHub repository */
205
+ program
206
+ .command('init')
207
+ .description('Initialize the current directory (or path) as a git repo and optionally create a GitHub repository')
208
+ .argument('[path]', 'Path to the project directory (defaults to current directory)')
209
+ .action(async (dirPath) => {
210
+ const cwd = dirPath ? path.resolve(dirPath) : process.cwd();
211
+ const gitDir = path.join(cwd, '.git');
212
+ if (!(await fs.pathExists(gitDir))) {
213
+ await execa('git', ['init'], { stdio: 'inherit', cwd });
214
+ console.log('โœ… Git repository initialized.\n');
215
+ }
216
+ await offerAndCreateGitHubRepo(cwd);
217
+ });
218
+
219
+ /** Command to list all available templates */
220
+ program
221
+ .command('list')
222
+ .description('List all available templates')
223
+ .option('--verbose', 'List all available templates with verbose information')
224
+ .option('-t, --template-file <path>', 'Include templates from a JSON file (same format as built-in)')
225
+ .action((options) => {
226
+ const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile);
227
+ console.log('\n๐Ÿ“‹ Available templates:\n');
228
+ templatesToUse.forEach(template => {
229
+ console.log(` ${template.name.padEnd(20)} - ${template.description}`)
230
+ if (options.verbose) {
231
+ console.log(` Repo URL: ${template.repo}`);
232
+ if (template.options && Array.isArray(template.options)) {
233
+ console.log(` Checkout Options: ${template.options.join(', ')}`);
234
+ }
235
+ }
236
+ });
237
+ console.log('');
238
+ });
239
+
240
+ /** Command to run PatternFly codemods on a directory */
241
+ program
242
+ .command('update')
243
+ .description('Run PatternFly codemods on a directory to transform code to the latest PatternFly patterns')
244
+ .argument('[path]', 'The path to the source directory to run codemods on (defaults to "src")')
245
+ .option('--fix', 'Automatically apply fixes to files instead of just showing what would be changed')
246
+ .action(async (srcPath, options) => {
247
+ const targetPath = srcPath || 'src';
248
+ const resolvedPath = path.resolve(targetPath);
249
+ const commands = ['@patternfly/pf-codemods', '@patternfly/class-name-updater'];
250
+
251
+ console.log(`Running PatternFly updates on ${resolvedPath}...`);
252
+
253
+ for (const command of commands) {
254
+ try {
255
+ console.log(`\n๐Ÿ“ฆ Running ${command}...`);
256
+ const args = [command];
257
+ if (options.fix) {
258
+ args.push('--fix');
259
+ }
260
+ args.push(resolvedPath);
261
+ await execa('npx', args, { stdio: 'inherit' });
262
+ console.log(`โœ… ${command} completed successfully.`);
263
+ } catch (error) {
264
+ console.error(`โŒ An error occurred while running ${command}:`);
265
+ if (error instanceof Error) {
266
+ console.error(error.message);
267
+ } else if (error && typeof error === 'object' && 'stderr' in error) {
268
+ console.error((error as { stderr?: string }).stderr || String(error));
269
+ } else {
270
+ console.error(String(error));
271
+ }
272
+ process.exit(1);
273
+ }
274
+ }
275
+
276
+ console.log('\nโœจ All updates completed successfully! โœจ');
277
+ });
278
+
279
+ /** Command to save changes: check for changes, prompt to commit, and push */
280
+ program
281
+ .command('save')
282
+ .description('Check for changes, optionally commit them with a message, and push to the current branch')
283
+ .argument('[path]', 'Path to the repository (defaults to current directory)')
284
+ .action(async (repoPath) => {
285
+ const cwd = repoPath ? path.resolve(repoPath) : process.cwd();
286
+ try {
287
+ await runSave(cwd);
288
+ } catch {
289
+ process.exit(1);
290
+ }
291
+ });
292
+
293
+ /** Command to load latest updates from the remote */
294
+ program
295
+ .command('load')
296
+ .description('Pull the latest updates from GitHub')
297
+ .argument('[path]', 'Path to the repository (defaults to current directory)')
298
+ .action(async (repoPath) => {
299
+ const cwd = repoPath ? path.resolve(repoPath) : process.cwd();
300
+ try {
301
+ await runLoad(cwd);
302
+ } catch {
303
+ process.exit(1);
304
+ }
305
+ });
306
+
307
+ program.parse(process.argv);