Skip to content

Commit 4cd82e1

Browse files
authored
Merge pull request #12 from patternfly/git-updates
Feat: Add support for creating git prototypes to cli.
2 parents caacef1 + 441ab36 commit 4cd82e1

File tree

14 files changed

+1071
-13
lines changed

14 files changed

+1071
-13
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ To install the CLI globally, use npm:
1616
npm install -g patternfly-cli
1717
```
1818

19+
## Prerequisites
20+
21+
Before using the Patternfly CLI, install the following:
22+
23+
- **Node.js and npm** (v20–24) — [npm](https://www.npmjs.com/) · [Node.js downloads](https://nodejs.org/)
24+
- **Corepack** — enable with `corepack enable` (included with Node.js). Run the command after installing npm.
25+
- **GitHub CLI**[Install GitHub CLI](https://cli.github.com/)
26+
1927
## Usage
2028

2129
After installation, you can use the CLI by running:

__mocks__/execa.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
module.exports = {
4+
execa: jest.fn(),
5+
};

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@
3636
]
3737
},
3838
"moduleNameMapper": {
39-
"^(\\.\\./.*)\\.js$": "$1"
40-
}
39+
"^(\\.\\./.*)\\.js$": "$1",
40+
"^(\\./.*)\\.js$": "$1"
41+
},
42+
"transformIgnorePatterns": [
43+
"/node_modules/(?!inquirer)"
44+
]
4145
},
4246
"dependencies": {
4347
"0g": "^0.4.2",

src/__tests__/cli.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
jest.mock('inquirer', () => ({
2+
__esModule: true,
3+
default: { prompt: jest.fn() },
4+
}));
5+
16
import path from 'path';
27
import fs from 'fs-extra';
38
import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
49
import templates from '../templates.js';
10+
import { sanitizeRepoName } from '../github.js';
511

612
const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures');
713

@@ -146,3 +152,20 @@ describe('mergeTemplates', () => {
146152
}
147153
});
148154
});
155+
156+
describe('GitHub support (create command)', () => {
157+
it('derives repo name from package name the same way the create command does', () => {
158+
// Create command uses sanitizeRepoName(pkgJson.name) for initial repo name
159+
expect(sanitizeRepoName('my-app')).toBe('my-app');
160+
expect(sanitizeRepoName('@patternfly/my-project')).toBe('my-project');
161+
expect(sanitizeRepoName('My Project Name')).toBe('my-project-name');
162+
});
163+
164+
it('builds repo URL in the format used by the create command', () => {
165+
// Create command builds https://github.com/${auth.username}/${repoName}
166+
const username = 'testuser';
167+
const repoName = sanitizeRepoName('@org/my-package');
168+
const repoUrl = `https://github.com/${username}/${repoName}`;
169+
expect(repoUrl).toBe('https://github.com/testuser/my-package');
170+
});
171+
});

src/__tests__/github.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
jest.mock('execa', () => ({
2+
__esModule: true,
3+
execa: jest.fn(),
4+
}));
5+
6+
jest.mock('inquirer', () => ({
7+
__esModule: true,
8+
default: { prompt: jest.fn() },
9+
}));
10+
11+
import {
12+
sanitizeRepoName,
13+
checkGhAuth,
14+
repoExists,
15+
createRepo,
16+
} from '../github.js';
17+
18+
const { execa: mockExeca } = require('execa');
19+
20+
describe('sanitizeRepoName', () => {
21+
it('returns lowercase name with invalid chars replaced by hyphen', () => {
22+
expect(sanitizeRepoName('My Project')).toBe('my-project');
23+
expect(sanitizeRepoName('my_project')).toBe('my_project');
24+
});
25+
26+
it('strips npm scope and uses package name only', () => {
27+
expect(sanitizeRepoName('@my-org/my-package')).toBe('my-package');
28+
expect(sanitizeRepoName('@scope/package-name')).toBe('package-name');
29+
});
30+
31+
it('collapses multiple hyphens', () => {
32+
expect(sanitizeRepoName('my---project')).toBe('my-project');
33+
expect(sanitizeRepoName(' spaces ')).toBe('spaces');
34+
});
35+
36+
it('strips leading and trailing hyphens', () => {
37+
expect(sanitizeRepoName('--my-project--')).toBe('my-project');
38+
expect(sanitizeRepoName('-single-')).toBe('single');
39+
});
40+
41+
it('allows alphanumeric, hyphens, underscores, and dots', () => {
42+
expect(sanitizeRepoName('my.project_1')).toBe('my.project_1');
43+
expect(sanitizeRepoName('v1.0.0')).toBe('v1.0.0');
44+
});
45+
46+
it('returns "my-project" when result would be empty', () => {
47+
expect(sanitizeRepoName('@scope/---')).toBe('my-project');
48+
expect(sanitizeRepoName('!!!')).toBe('my-project');
49+
});
50+
51+
it('handles scoped package with only special chars after scope', () => {
52+
expect(sanitizeRepoName('@org/---')).toBe('my-project');
53+
});
54+
});
55+
56+
describe('checkGhAuth', () => {
57+
beforeEach(() => {
58+
mockExeca.mockReset();
59+
});
60+
61+
it('returns ok: false when gh auth status fails', async () => {
62+
mockExeca.mockRejectedValueOnce(new Error('not logged in'));
63+
64+
const result = await checkGhAuth();
65+
66+
expect(result).toEqual({
67+
ok: false,
68+
message: expect.stringContaining('GitHub CLI (gh) is not installed'),
69+
});
70+
expect(mockExeca).toHaveBeenCalledWith('gh', ['auth', 'status'], { reject: true });
71+
});
72+
73+
it('returns ok: false when gh api user returns empty login', async () => {
74+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
75+
mockExeca.mockResolvedValueOnce({ stdout: '\n \n', stderr: '', exitCode: 0 });
76+
77+
const result = await checkGhAuth();
78+
79+
expect(result).toEqual({
80+
ok: false,
81+
message: expect.stringContaining('Could not determine your GitHub username'),
82+
});
83+
});
84+
85+
it('returns ok: false when gh api user throws', async () => {
86+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
87+
mockExeca.mockRejectedValueOnce(new Error('API error'));
88+
89+
const result = await checkGhAuth();
90+
91+
expect(result).toEqual({
92+
ok: false,
93+
message: expect.stringContaining('Could not fetch your GitHub username'),
94+
});
95+
});
96+
97+
it('returns ok: true with username when auth and api succeed', async () => {
98+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
99+
mockExeca.mockResolvedValueOnce({
100+
stdout: ' octocat ',
101+
stderr: '',
102+
exitCode: 0,
103+
});
104+
105+
const result = await checkGhAuth();
106+
107+
expect(result).toEqual({ ok: true, username: 'octocat' });
108+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'gh', ['api', 'user', '--jq', '.login'], {
109+
encoding: 'utf8',
110+
});
111+
});
112+
});
113+
114+
describe('repoExists', () => {
115+
beforeEach(() => {
116+
mockExeca.mockReset();
117+
});
118+
119+
it('returns true when gh api repos/owner/repo succeeds', async () => {
120+
mockExeca.mockResolvedValueOnce({ stdout: '{}', stderr: '', exitCode: 0 });
121+
122+
const result = await repoExists('octocat', 'my-repo');
123+
124+
expect(result).toBe(true);
125+
expect(mockExeca).toHaveBeenCalledWith('gh', ['api', 'repos/octocat/my-repo'], {
126+
reject: true,
127+
});
128+
});
129+
130+
it('returns false when gh api throws (e.g. 404)', async () => {
131+
mockExeca.mockRejectedValueOnce(new Error('Not Found'));
132+
133+
const result = await repoExists('octocat', 'nonexistent');
134+
135+
expect(result).toBe(false);
136+
});
137+
});
138+
139+
describe('createRepo', () => {
140+
const projectPath = '/tmp/my-app';
141+
const username = 'octocat';
142+
143+
beforeEach(() => {
144+
mockExeca.mockReset();
145+
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
146+
});
147+
148+
it('initializes git and calls gh repo create with expected args and returns repo URL', async () => {
149+
mockExeca
150+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
151+
.mockRejectedValueOnce(new Error('no HEAD'))
152+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
153+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
154+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
155+
156+
const url = await createRepo({
157+
repoName: 'my-app',
158+
projectPath,
159+
username,
160+
});
161+
162+
expect(url).toBe('https://github.com/octocat/my-app.git');
163+
expect(mockExeca).toHaveBeenCalledTimes(5);
164+
expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['init'], {
165+
stdio: 'inherit',
166+
cwd: projectPath,
167+
});
168+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['rev-parse', '--verify', 'HEAD'], expect.any(Object));
169+
expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', ['add', '.'], {
170+
stdio: 'inherit',
171+
cwd: projectPath,
172+
});
173+
expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['commit', '-m', 'Initial commit'], {
174+
stdio: 'inherit',
175+
cwd: projectPath,
176+
});
177+
expect(mockExeca).toHaveBeenNthCalledWith(
178+
5,
179+
'gh',
180+
[
181+
'repo',
182+
'create',
183+
'my-app',
184+
'--public',
185+
`--source=${projectPath}`,
186+
'--remote=origin',
187+
'--push',
188+
],
189+
{ stdio: 'inherit', cwd: projectPath },
190+
);
191+
});
192+
193+
it('passes description when provided', async () => {
194+
mockExeca
195+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
196+
.mockRejectedValueOnce(new Error('no HEAD'))
197+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
198+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
199+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
200+
201+
await createRepo({
202+
repoName: 'my-app',
203+
projectPath,
204+
username,
205+
description: 'My cool project',
206+
});
207+
208+
expect(mockExeca).toHaveBeenNthCalledWith(
209+
5,
210+
'gh',
211+
expect.arrayContaining(['--description=My cool project']),
212+
expect.any(Object),
213+
);
214+
});
215+
});

src/__tests__/load.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
import fs from 'fs-extra';
14+
import { execa } from 'execa';
15+
import { runLoad } from '../load.js';
16+
17+
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
18+
const mockExeca = execa as jest.MockedFunction<typeof execa>;
19+
20+
const cwd = '/tmp/my-repo';
21+
22+
describe('runLoad', () => {
23+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
24+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
afterAll(() => {
31+
consoleErrorSpy.mockRestore();
32+
consoleLogSpy.mockRestore();
33+
});
34+
35+
it('throws and logs error when .git directory does not exist', async () => {
36+
mockPathExists.mockResolvedValue(false);
37+
38+
await expect(runLoad(cwd)).rejects.toThrow('Not a git repository');
39+
expect(mockPathExists).toHaveBeenCalledWith(`${cwd}/.git`);
40+
expect(consoleErrorSpy).toHaveBeenCalledWith(
41+
expect.stringContaining('This directory is not a git repository'),
42+
);
43+
expect(mockExeca).not.toHaveBeenCalled();
44+
});
45+
46+
it('runs git pull and logs success when repo exists', async () => {
47+
mockPathExists.mockResolvedValue(true);
48+
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
49+
50+
await runLoad(cwd);
51+
52+
expect(mockExeca).toHaveBeenCalledTimes(1);
53+
expect(mockExeca).toHaveBeenCalledWith('git', ['pull'], {
54+
cwd,
55+
stdio: 'inherit',
56+
});
57+
expect(consoleLogSpy).toHaveBeenCalledWith(
58+
expect.stringContaining('Pulling latest updates from GitHub'),
59+
);
60+
expect(consoleLogSpy).toHaveBeenCalledWith(
61+
expect.stringContaining('Latest updates loaded successfully'),
62+
);
63+
});
64+
65+
it('throws and logs pull-failure message when pull fails with exitCode 128', async () => {
66+
mockPathExists.mockResolvedValue(true);
67+
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 128 }));
68+
69+
await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });
70+
71+
expect(consoleErrorSpy).toHaveBeenCalledWith(
72+
expect.stringContaining('Pull failed'),
73+
);
74+
expect(consoleErrorSpy).toHaveBeenCalledWith(
75+
expect.stringContaining('remote'),
76+
);
77+
});
78+
79+
it('throws and logs generic failure when pull fails with other exitCode', async () => {
80+
mockPathExists.mockResolvedValue(true);
81+
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 1 }));
82+
83+
await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });
84+
85+
expect(consoleErrorSpy).toHaveBeenCalledWith(
86+
expect.stringContaining('Pull failed'),
87+
);
88+
expect(consoleErrorSpy).toHaveBeenCalledWith(
89+
expect.stringContaining('See the output above'),
90+
);
91+
});
92+
93+
it('throws and logs error when execa throws without exitCode', async () => {
94+
mockPathExists.mockResolvedValue(true);
95+
mockExeca.mockRejectedValueOnce(new Error('network error'));
96+
97+
await expect(runLoad(cwd)).rejects.toThrow('network error');
98+
99+
expect(consoleErrorSpy).toHaveBeenCalledWith(
100+
expect.stringContaining('An error occurred'),
101+
);
102+
expect(consoleErrorSpy).toHaveBeenCalledWith(
103+
expect.stringContaining('network error'),
104+
);
105+
});
106+
});

0 commit comments

Comments
 (0)