Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ To install the CLI globally, use npm:
npm install -g patternfly-cli
```

## Prerequisites

Before using the Patternfly CLI, install the following:

- **Node.js and npm** (v20–24) — [npm](https://www.npmjs.com/) · [Node.js downloads](https://nodejs.org/)
- **Corepack** — enable with `corepack enable` (included with Node.js). Run the command after installing npm.
- **GitHub CLI** — [Install GitHub CLI](https://cli.github.com/)

## Usage

After installation, you can use the CLI by running:
Expand Down
5 changes: 5 additions & 0 deletions __mocks__/execa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = {
execa: jest.fn(),
};
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@
]
},
"moduleNameMapper": {
"^(\\.\\./.*)\\.js$": "$1"
}
"^(\\.\\./.*)\\.js$": "$1",
"^(\\./.*)\\.js$": "$1"
},
"transformIgnorePatterns": [
"/node_modules/(?!inquirer)"
]
},
"dependencies": {
"0g": "^0.4.2",
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
jest.mock('inquirer', () => ({
__esModule: true,
default: { prompt: jest.fn() },
}));

import path from 'path';
import fs from 'fs-extra';
import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
import templates from '../templates.js';
import { sanitizeRepoName } from '../github.js';

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

Expand Down Expand Up @@ -146,3 +152,20 @@ describe('mergeTemplates', () => {
}
});
});

describe('GitHub support (create command)', () => {
it('derives repo name from package name the same way the create command does', () => {
// Create command uses sanitizeRepoName(pkgJson.name) for initial repo name
expect(sanitizeRepoName('my-app')).toBe('my-app');
expect(sanitizeRepoName('@patternfly/my-project')).toBe('my-project');
expect(sanitizeRepoName('My Project Name')).toBe('my-project-name');
});

it('builds repo URL in the format used by the create command', () => {
// Create command builds https://github.com/${auth.username}/${repoName}
const username = 'testuser';
const repoName = sanitizeRepoName('@org/my-package');
const repoUrl = `https://github.com/${username}/${repoName}`;
expect(repoUrl).toBe('https://github.com/testuser/my-package');
});
});
215 changes: 215 additions & 0 deletions src/__tests__/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
jest.mock('execa', () => ({
__esModule: true,
execa: jest.fn(),
}));

jest.mock('inquirer', () => ({
__esModule: true,
default: { prompt: jest.fn() },
}));

import {
sanitizeRepoName,
checkGhAuth,
repoExists,
createRepo,
} from '../github.js';

const { execa: mockExeca } = require('execa');

describe('sanitizeRepoName', () => {
it('returns lowercase name with invalid chars replaced by hyphen', () => {
expect(sanitizeRepoName('My Project')).toBe('my-project');
expect(sanitizeRepoName('my_project')).toBe('my_project');
});

it('strips npm scope and uses package name only', () => {
expect(sanitizeRepoName('@my-org/my-package')).toBe('my-package');
expect(sanitizeRepoName('@scope/package-name')).toBe('package-name');
});

it('collapses multiple hyphens', () => {
expect(sanitizeRepoName('my---project')).toBe('my-project');
expect(sanitizeRepoName(' spaces ')).toBe('spaces');
});

it('strips leading and trailing hyphens', () => {
expect(sanitizeRepoName('--my-project--')).toBe('my-project');
expect(sanitizeRepoName('-single-')).toBe('single');
});

it('allows alphanumeric, hyphens, underscores, and dots', () => {
expect(sanitizeRepoName('my.project_1')).toBe('my.project_1');
expect(sanitizeRepoName('v1.0.0')).toBe('v1.0.0');
});

it('returns "my-project" when result would be empty', () => {
expect(sanitizeRepoName('@scope/---')).toBe('my-project');
expect(sanitizeRepoName('!!!')).toBe('my-project');
});

it('handles scoped package with only special chars after scope', () => {
expect(sanitizeRepoName('@org/---')).toBe('my-project');
});
});

describe('checkGhAuth', () => {
beforeEach(() => {
mockExeca.mockReset();
});

it('returns ok: false when gh auth status fails', async () => {
mockExeca.mockRejectedValueOnce(new Error('not logged in'));

const result = await checkGhAuth();

expect(result).toEqual({
ok: false,
message: expect.stringContaining('GitHub CLI (gh) is not installed'),
});
expect(mockExeca).toHaveBeenCalledWith('gh', ['auth', 'status'], { reject: true });
});

it('returns ok: false when gh api user returns empty login', async () => {
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
mockExeca.mockResolvedValueOnce({ stdout: '\n \n', stderr: '', exitCode: 0 });

const result = await checkGhAuth();

expect(result).toEqual({
ok: false,
message: expect.stringContaining('Could not determine your GitHub username'),
});
});

it('returns ok: false when gh api user throws', async () => {
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
mockExeca.mockRejectedValueOnce(new Error('API error'));

const result = await checkGhAuth();

expect(result).toEqual({
ok: false,
message: expect.stringContaining('Could not fetch your GitHub username'),
});
});

it('returns ok: true with username when auth and api succeed', async () => {
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
mockExeca.mockResolvedValueOnce({
stdout: ' octocat ',
stderr: '',
exitCode: 0,
});

const result = await checkGhAuth();

expect(result).toEqual({ ok: true, username: 'octocat' });
expect(mockExeca).toHaveBeenNthCalledWith(2, 'gh', ['api', 'user', '--jq', '.login'], {
encoding: 'utf8',
});
});
});

describe('repoExists', () => {
beforeEach(() => {
mockExeca.mockReset();
});

it('returns true when gh api repos/owner/repo succeeds', async () => {
mockExeca.mockResolvedValueOnce({ stdout: '{}', stderr: '', exitCode: 0 });

const result = await repoExists('octocat', 'my-repo');

expect(result).toBe(true);
expect(mockExeca).toHaveBeenCalledWith('gh', ['api', 'repos/octocat/my-repo'], {
reject: true,
});
});

it('returns false when gh api throws (e.g. 404)', async () => {
mockExeca.mockRejectedValueOnce(new Error('Not Found'));

const result = await repoExists('octocat', 'nonexistent');

expect(result).toBe(false);
});
});

describe('createRepo', () => {
const projectPath = '/tmp/my-app';
const username = 'octocat';

beforeEach(() => {
mockExeca.mockReset();
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
});

it('initializes git and calls gh repo create with expected args and returns repo URL', async () => {
mockExeca
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockRejectedValueOnce(new Error('no HEAD'))
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });

const url = await createRepo({
repoName: 'my-app',
projectPath,
username,
});

expect(url).toBe('https://github.com/octocat/my-app.git');
expect(mockExeca).toHaveBeenCalledTimes(5);
expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['init'], {
stdio: 'inherit',
cwd: projectPath,
});
expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['rev-parse', '--verify', 'HEAD'], expect.any(Object));
expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', ['add', '.'], {
stdio: 'inherit',
cwd: projectPath,
});
expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['commit', '-m', 'Initial commit'], {
stdio: 'inherit',
cwd: projectPath,
});
expect(mockExeca).toHaveBeenNthCalledWith(
5,
'gh',
[
'repo',
'create',
'my-app',
'--public',
`--source=${projectPath}`,
'--remote=origin',
'--push',
],
{ stdio: 'inherit', cwd: projectPath },
);
});

it('passes description when provided', async () => {
mockExeca
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockRejectedValueOnce(new Error('no HEAD'))
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });

await createRepo({
repoName: 'my-app',
projectPath,
username,
description: 'My cool project',
});

expect(mockExeca).toHaveBeenNthCalledWith(
5,
'gh',
expect.arrayContaining(['--description=My cool project']),
expect.any(Object),
);
});
});
106 changes: 106 additions & 0 deletions src/__tests__/load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
jest.mock('fs-extra', () => ({
__esModule: true,
default: {
pathExists: jest.fn(),
},
}));

jest.mock('execa', () => ({
__esModule: true,
execa: jest.fn(),
}));

import fs from 'fs-extra';
import { execa } from 'execa';
import { runLoad } from '../load.js';

const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
const mockExeca = execa as jest.MockedFunction<typeof execa>;

const cwd = '/tmp/my-repo';

describe('runLoad', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

beforeEach(() => {
jest.clearAllMocks();
});

afterAll(() => {
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
});

it('throws and logs error when .git directory does not exist', async () => {
mockPathExists.mockResolvedValue(false);

await expect(runLoad(cwd)).rejects.toThrow('Not a git repository');
expect(mockPathExists).toHaveBeenCalledWith(`${cwd}/.git`);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('This directory is not a git repository'),
);
expect(mockExeca).not.toHaveBeenCalled();
});

it('runs git pull and logs success when repo exists', async () => {
mockPathExists.mockResolvedValue(true);
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);

await runLoad(cwd);

expect(mockExeca).toHaveBeenCalledTimes(1);
expect(mockExeca).toHaveBeenCalledWith('git', ['pull'], {
cwd,
stdio: 'inherit',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Pulling latest updates from GitHub'),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Latest updates loaded successfully'),
);
});

it('throws and logs pull-failure message when pull fails with exitCode 128', async () => {
mockPathExists.mockResolvedValue(true);
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 128 }));

await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });

expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Pull failed'),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('remote'),
);
});

it('throws and logs generic failure when pull fails with other exitCode', async () => {
mockPathExists.mockResolvedValue(true);
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 1 }));

await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });

expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Pull failed'),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('See the output above'),
);
});

it('throws and logs error when execa throws without exitCode', async () => {
mockPathExists.mockResolvedValue(true);
mockExeca.mockRejectedValueOnce(new Error('network error'));

await expect(runLoad(cwd)).rejects.toThrow('network error');

expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('An error occurred'),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('network error'),
);
});
});
Loading