Skip to content

Commit e627bed

Browse files
committed
feat: Added support for creating a repo and pushing your project to for github.
1 parent 232f74f commit e627bed

File tree

6 files changed

+364
-1
lines changed

6 files changed

+364
-1
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+
};

src/__tests__/cli.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'path';
22
import fs from 'fs-extra';
33
import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
44
import templates from '../templates.js';
5+
import { sanitizeRepoName } from '../github.js';
56

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

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

src/__tests__/github.test.ts

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

src/cli.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { execa } from 'execa';
55
import inquirer from 'inquirer';
66
import fs from 'fs-extra';
77
import path from 'path';
8-
import { defaultTemplates }from './templates.js';
8+
import { defaultTemplates } from './templates.js';
99
import { mergeTemplates } from './template-loader.js';
10+
import { checkGhAuth, createRepo, repoExists as ghRepoExists, sanitizeRepoName } from './github.js';
1011

1112
/** Project data provided by the user */
1213
type ProjectData = {
@@ -171,6 +172,86 @@ program
171172
await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' });
172173
console.log('✅ Dependencies installed.');
173174

175+
// Optional: Create GitHub repository
176+
const { createGitHub } = await inquirer.prompt([
177+
{
178+
type: 'confirm',
179+
name: 'createGitHub',
180+
message: 'Would you like to create a GitHub repository for this project?',
181+
default: false,
182+
},
183+
]);
184+
185+
if (createGitHub) {
186+
const auth = await checkGhAuth();
187+
if (!auth.ok) {
188+
console.log(`\n⚠️ ${auth.message}`);
189+
console.log(' Skipping GitHub repository creation.\n');
190+
} else {
191+
const pkgJsonForGh = await fs.readJson(pkgJsonPath);
192+
const projectName = pkgJsonForGh.name as string;
193+
let repoName = sanitizeRepoName(projectName);
194+
195+
// If repo already exists, ask for alternative name until we get one that doesn't exist or user skips
196+
while (await ghRepoExists(auth.username, repoName)) {
197+
console.log(`\n⚠️ A repository named "${repoName}" already exists on GitHub under your account.\n`);
198+
const { alternativeName } = await inquirer.prompt([
199+
{
200+
type: 'input',
201+
name: 'alternativeName',
202+
message: 'Enter an alternative repository name (or leave empty to skip creating a GitHub repository):',
203+
default: '',
204+
},
205+
]);
206+
if (!alternativeName?.trim()) {
207+
repoName = '';
208+
break;
209+
}
210+
repoName = sanitizeRepoName(alternativeName.trim());
211+
}
212+
213+
if (repoName) {
214+
const repoUrl = `https://github.com/${auth.username}/${repoName}`;
215+
console.log('\n📋 The following will happen:\n');
216+
console.log(` • A new public repository will be created at: ${repoUrl}`);
217+
console.log(` • The repository will be created under your GitHub account (${auth.username}).`);
218+
console.log(` • The repository URL will be added to your package.json.`);
219+
console.log(` • The remote "origin" will be set to this repository (you can push when ready).\n`);
220+
221+
const { confirmCreate } = await inquirer.prompt([
222+
{
223+
type: 'confirm',
224+
name: 'confirmCreate',
225+
message: 'Do you want to proceed with creating this repository?',
226+
default: true,
227+
},
228+
]);
229+
230+
if (!confirmCreate) {
231+
console.log('\n❌ GitHub repository was not created. Your local project is ready at:');
232+
console.log(` ${projectPath}\n`);
233+
} else {
234+
try {
235+
const createdUrl = await createRepo({
236+
repoName,
237+
projectPath,
238+
username: auth.username,
239+
...(pkgJsonForGh.description && { description: String(pkgJsonForGh.description) }),
240+
});
241+
pkgJsonForGh.repository = { type: 'git', url: createdUrl };
242+
await fs.writeJson(pkgJsonPath, pkgJsonForGh, { spaces: 2 });
243+
console.log('\n✅ GitHub repository created successfully!');
244+
console.log(` ${repoUrl}`);
245+
console.log(' Repository URL has been added to your package.json.\n');
246+
} catch (err) {
247+
console.error('\n❌ Failed to create GitHub repository:');
248+
if (err instanceof Error) console.error(` ${err.message}\n`);
249+
}
250+
}
251+
}
252+
}
253+
}
254+
174255
// Let the user know the project was created successfully
175256
console.log('\n✨ Project created successfully! ✨\n');
176257
console.log(`To get started:`);

0 commit comments

Comments
 (0)