diff --git a/README.md b/README.md index 2c5bb51..d00f286 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/__mocks__/execa.js b/__mocks__/execa.js new file mode 100644 index 0000000..bc0a820 --- /dev/null +++ b/__mocks__/execa.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + execa: jest.fn(), +}; diff --git a/package.json b/package.json index 9506999..1335772 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,12 @@ ] }, "moduleNameMapper": { - "^(\\.\\./.*)\\.js$": "$1" - } + "^(\\.\\./.*)\\.js$": "$1", + "^(\\./.*)\\.js$": "$1" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!inquirer)" + ] }, "dependencies": { "0g": "^0.4.2", diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 97f45e0..c7a2252 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -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'); @@ -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'); + }); +}); diff --git a/src/__tests__/github.test.ts b/src/__tests__/github.test.ts new file mode 100644 index 0000000..f7ec023 --- /dev/null +++ b/src/__tests__/github.test.ts @@ -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), + ); + }); +}); diff --git a/src/__tests__/load.test.ts b/src/__tests__/load.test.ts new file mode 100644 index 0000000..d4e880c --- /dev/null +++ b/src/__tests__/load.test.ts @@ -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; +const mockExeca = execa as jest.MockedFunction; + +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>); + + 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'), + ); + }); +}); diff --git a/src/__tests__/save.test.ts b/src/__tests__/save.test.ts new file mode 100644 index 0000000..fa90aa7 --- /dev/null +++ b/src/__tests__/save.test.ts @@ -0,0 +1,272 @@ +jest.mock('fs-extra', () => ({ + __esModule: true, + default: { + pathExists: jest.fn(), + }, +})); + +jest.mock('execa', () => ({ + __esModule: true, + execa: jest.fn(), +})); + +jest.mock('inquirer', () => ({ + __esModule: true, + default: { + prompt: jest.fn(), + }, +})); + +jest.mock('../github.js', () => ({ + offerAndCreateGitHubRepo: jest.fn(), +})); + +import fs from 'fs-extra'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; +import { offerAndCreateGitHubRepo } from '../github.js'; +import { runSave } from '../save.js'; + +const mockPathExists = fs.pathExists as jest.MockedFunction; +const mockExeca = execa as jest.MockedFunction; +const mockPrompt = inquirer.prompt as jest.MockedFunction; +const mockOfferAndCreateGitHubRepo = offerAndCreateGitHubRepo as jest.MockedFunction; + +const cwd = '/tmp/my-repo'; + +describe('runSave', () => { + 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(runSave(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(); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('logs "No changes to save" and returns when working tree is clean', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + await runSave(cwd); + + expect(mockExeca).toHaveBeenCalledTimes(1); + expect(mockExeca).toHaveBeenCalledWith('git', ['status', '--porcelain'], { + cwd, + encoding: 'utf8', + }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('No changes to save'), + ); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('logs "No changes to save" when status output is only whitespace', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca.mockResolvedValue({ stdout: ' \n ', stderr: '', exitCode: 0 }); + + await runSave(cwd); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('No changes to save'), + ); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + + it('prompts to save; when user says no, logs "Nothing has been saved"', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 }); + mockPrompt.mockResolvedValueOnce({ saveChanges: false }); + + await runSave(cwd); + + expect(mockPrompt).toHaveBeenCalledTimes(1); + expect(mockPrompt).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'confirm', + name: 'saveChanges', + message: expect.stringContaining('Would you like to save them?'), + }), + ]); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Nothing has been saved'), + ); + expect(mockExeca).toHaveBeenCalledTimes(1); // only status, no add/commit/push + }); + + it('when user says yes but message is empty, logs "No message provided"', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca.mockResolvedValue({ stdout: ' M file.ts', stderr: '', exitCode: 0 }); + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: ' ' }); + + await runSave(cwd); + + expect(mockPrompt).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('No message provided'), + ); + expect(mockExeca).toHaveBeenCalledTimes(1); // only status + }); + + it('when user says yes and provides message, runs add, commit, push and logs success', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'Fix bug in save command' }); + + await runSave(cwd); + + expect(mockExeca).toHaveBeenCalledTimes(5); + expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['status', '--porcelain'], { + cwd, + encoding: 'utf8', + }); + expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['add', '.'], { + cwd, + stdio: 'inherit', + }); + expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', [ + 'commit', + '-m', + 'Fix bug in save command', + ], { cwd, stdio: 'inherit' }); + expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['remote', 'get-url', 'origin'], expect.any(Object)); + expect(mockExeca).toHaveBeenNthCalledWith(5, 'git', ['push'], { + cwd, + stdio: 'inherit', + }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Changes saved and pushed to GitHub successfully'), + ); + }); + + it('throws and logs push-failure message when push fails with exitCode 128', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(Object.assign(new Error('push failed'), { exitCode: 128 })); + + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'WIP' }); + + await expect(runSave(cwd)).rejects.toMatchObject({ message: 'push failed' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Push failed'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('remote'), + ); + }); + + it('throws and logs generic failure when add/commit/push fails with other exitCode', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(Object.assign(new Error('add failed'), { exitCode: 1 })); + + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'WIP' }); + + await expect(runSave(cwd)).rejects.toMatchObject({ message: 'add failed' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Save or push failed'), + ); + }); + + it('throws and logs error when execa throws without exitCode', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: 'https://github.com/user/repo.git', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(new Error('network error')); + + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'WIP' }); + + await expect(runSave(cwd)).rejects.toThrow('network error'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('network error'), + ); + }); + + it('when no remote origin, offers to create GitHub repo and throws if user does not create', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(new Error('no remote')); + mockOfferAndCreateGitHubRepo.mockResolvedValue(false); + + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'WIP' }); + + await expect(runSave(cwd)).rejects.toThrow('No remote origin'); + expect(mockOfferAndCreateGitHubRepo).toHaveBeenCalledWith(cwd); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Set a remote'), + ); + expect(mockExeca).not.toHaveBeenCalledWith('git', ['push'], expect.any(Object)); + }); + + it('when no remote origin and user creates GitHub repo, pushes successfully', async () => { + mockPathExists.mockResolvedValue(true); + mockExeca + .mockResolvedValueOnce({ stdout: ' M file.ts', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockRejectedValueOnce(new Error('no remote')) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + mockOfferAndCreateGitHubRepo.mockResolvedValue(true); + + mockPrompt + .mockResolvedValueOnce({ saveChanges: true }) + .mockResolvedValueOnce({ message: 'WIP' }); + + await runSave(cwd); + + expect(mockOfferAndCreateGitHubRepo).toHaveBeenCalledWith(cwd); + expect(mockExeca).toHaveBeenCalledWith('git', ['push'], { cwd, stdio: 'inherit' }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Changes saved and pushed to GitHub successfully'), + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index b6035d5..b8a7116 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,8 +5,11 @@ import { execa } from 'execa'; import inquirer from 'inquirer'; import fs from 'fs-extra'; import path from 'path'; -import { defaultTemplates }from './templates.js'; +import { defaultTemplates } from './templates.js'; import { mergeTemplates } from './template-loader.js'; +import { offerAndCreateGitHubRepo } from './github.js'; +import { runSave } from './save.js'; +import { runLoad } from './load.js'; /** Project data provided by the user */ type ProjectData = { @@ -25,12 +28,26 @@ program .version('1.0.0') .command('create') .description('Create a new project from a git template') - .argument('', 'The directory to create the project in') + .argument('[project-directory]', 'The directory to create the project in') .argument('[template-name]', 'The name of the template to use') .option('-t, --template-file ', 'Path to a JSON file with custom templates (same format as built-in)') + .option('--ssh', 'Use SSH URL for cloning the template repository') .action(async (projectDirectory, templateName, options) => { const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile); + // If project directory is not provided, prompt for it + if (!projectDirectory) { + const projectDirAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'projectDirectory', + message: 'Please provide the directory where you want to create the project?', + default: 'my-app', + }, + ]); + projectDirectory = projectDirAnswer.projectDirectory; + } + // If template name is not provided, show available templates and let user select if (!templateName) { console.log('\n📋 Available templates:\n'); @@ -66,8 +83,22 @@ program console.log(''); process.exit(1); } - - const templateRepoUrl = template.repo; + + // If --ssh was not passed, prompt whether to use SSH + let useSSH = options?.ssh; + if (useSSH === undefined && template.repoSSH) { + const sshAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'useSSH', + message: 'Use SSH URL for cloning?', + default: false, + }, + ]); + useSSH = sshAnswer.useSSH; + } + + const templateRepoUrl = useSSH && template.repoSSH ? template.repoSSH : template.repo; // Define the full path for the new project const projectPath = path.resolve(projectDirectory); @@ -93,25 +124,25 @@ program { type: 'input', name: 'name', - message: 'Project name?', + message: 'What is the project name?', default: path.basename(projectPath), }, { type: 'input', name: 'version', - message: 'Version?', + message: 'What version number would you like to use?', default: '1.0.0', }, { type: 'input', name: 'description', - message: 'Description?', + message: 'What is the project description?', default: '', }, { type: 'input', name: 'author', - message: 'Author?', + message: 'Who is the author of the project?', default: '', }, ]; @@ -143,6 +174,9 @@ program await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' }); console.log('✅ Dependencies installed.'); + // Optional: Create GitHub repository + await offerAndCreateGitHubRepo(projectPath); + // Let the user know the project was created successfully console.log('\n✨ Project created successfully! ✨\n'); console.log(`To get started:`); @@ -167,6 +201,21 @@ program } }); +/** Command to initialize a project and optionally create a GitHub repository */ +program + .command('init') + .description('Initialize the current directory (or path) as a git repo and optionally create a GitHub repository') + .argument('[path]', 'Path to the project directory (defaults to current directory)') + .action(async (dirPath) => { + const cwd = dirPath ? path.resolve(dirPath) : process.cwd(); + const gitDir = path.join(cwd, '.git'); + if (!(await fs.pathExists(gitDir))) { + await execa('git', ['init'], { stdio: 'inherit', cwd }); + console.log('✅ Git repository initialized.\n'); + } + await offerAndCreateGitHubRepo(cwd); + }); + /** Command to list all available templates */ program .command('list') @@ -227,4 +276,32 @@ program console.log('\n✨ All updates completed successfully! ✨'); }); +/** Command to save changes: check for changes, prompt to commit, and push */ +program + .command('save') + .description('Check for changes, optionally commit them with a message, and push to the current branch') + .argument('[path]', 'Path to the repository (defaults to current directory)') + .action(async (repoPath) => { + const cwd = repoPath ? path.resolve(repoPath) : process.cwd(); + try { + await runSave(cwd); + } catch { + process.exit(1); + } + }); + +/** Command to load latest updates from the remote */ +program + .command('load') + .description('Pull the latest updates from GitHub') + .argument('[path]', 'Path to the repository (defaults to current directory)') + .action(async (repoPath) => { + const cwd = repoPath ? path.resolve(repoPath) : process.cwd(); + try { + await runLoad(cwd); + } catch { + process.exit(1); + } + }); + program.parse(process.argv); \ No newline at end of file diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..4840d74 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,193 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +/** + * Sanitize a package name for use as a GitHub repository name (alphanumeric, hyphens, underscores) + */ +export function sanitizeRepoName(name: string): string { + // Strip npm scope if present (e.g. @scope/package -> package) + const withoutScope = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name; + // GitHub allows A-Za-z0-9_.- ; replace invalid chars with hyphen and collapse multiple hyphens + return withoutScope + .toLowerCase() + .replace(/[^a-z0-9_.-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'my-project'; +} + +/** + * Check if GitHub CLI is installed and the user is authenticated + */ +export async function checkGhAuth(): Promise<{ ok: true; username: string } | { ok: false; message: string }> { + try { + await execa('gh', ['auth', 'status'], { reject: true }); + } catch { + return { ok: false, message: 'GitHub CLI (gh) is not installed or you are not logged in. Install it from https://cli.github.com/ and run "gh auth login".' }; + } + try { + const { stdout } = await execa('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8' }); + const username = stdout?.trim(); + if (!username) { + return { ok: false, message: 'Could not determine your GitHub username.' }; + } + return { ok: true, username }; + } catch { + return { ok: false, message: 'Could not fetch your GitHub username. Ensure "gh auth login" has been run.' }; + } +} + +/** + * Check if a repository already exists for the given owner and repo name + */ +export async function repoExists(owner: string, repoName: string): Promise { + try { + await execa('gh', ['api', `repos/${owner}/${repoName}`], { reject: true }); + return true; + } catch { + return false; + } +} + +/** + * Ensure the project has at least one commit (for push). Idempotent. + */ +async function ensureInitialCommit(projectPath: string): Promise { + try { + await execa('git', ['rev-parse', '--verify', 'HEAD'], { + cwd: projectPath, + reject: true, + }); + } catch { + await execa('git', ['add', '.'], { stdio: 'inherit', cwd: projectPath }); + await execa('git', ['commit', '-m', 'Initial commit'], { + stdio: 'inherit', + cwd: projectPath, + }); + } +} + +/** + * Create a new GitHub repository and return its URL. Does not push. + */ +export async function createRepo(options: { + repoName: string; + projectPath: string; + username: string; + description?: string; +}): Promise { + const gitDir = path.join(options.projectPath, '.git'); + if (!(await fs.pathExists(gitDir))) { + await execa('git', ['init'], { stdio: 'inherit', cwd: options.projectPath }); + } + await ensureInitialCommit(options.projectPath); + + const args = [ + 'repo', + 'create', + options.repoName, + '--public', + `--source=${options.projectPath}`, + '--remote=origin', + '--push', + ]; + if (options.description) { + args.push(`--description=${options.description}`); + } + await execa('gh', args, { stdio: 'inherit', cwd: options.projectPath }); + return `https://github.com/${options.username}/${options.repoName}.git`; +} + +/** + * Interactive flow: prompt to create a GitHub repo under the current user, then create it and set origin. + * Returns true if a repo was created (or already had origin), false if skipped or failed. + */ +export async function offerAndCreateGitHubRepo(projectPath: string): Promise { + const pkgJsonPath = path.join(projectPath, 'package.json'); + if (!(await fs.pathExists(pkgJsonPath))) { + console.log('\nℹ️ No package.json found; skipping GitHub repository creation.\n'); + return false; + } + + const { createGitHub } = await inquirer.prompt([ + { + type: 'confirm', + name: 'createGitHub', + message: 'Would you like to create a GitHub repository for this project?', + default: false, + }, + ]); + + if (!createGitHub) return false; + + const auth = await checkGhAuth(); + if (!auth.ok) { + console.log(`\n⚠️ ${auth.message}`); + console.log(' Skipping GitHub repository creation.\n'); + return false; + } + + const pkgJson = await fs.readJson(pkgJsonPath); + const projectName = (pkgJson.name as string) ?? 'my-project'; + let repoName = sanitizeRepoName(projectName); + + while (await repoExists(auth.username, repoName)) { + console.log(`\n⚠️ A repository named "${repoName}" already exists on GitHub under your account.\n`); + const { alternativeName } = await inquirer.prompt([ + { + type: 'input', + name: 'alternativeName', + message: 'Enter an alternative repository name (or leave empty to skip creating a GitHub repository):', + default: '', + }, + ]); + if (!alternativeName?.trim()) { + repoName = ''; + break; + } + repoName = sanitizeRepoName(alternativeName.trim()); + } + + if (!repoName) return false; + + const repoUrl = `https://github.com/${auth.username}/${repoName}`; + console.log('\n📋 The following will happen:\n'); + console.log(` • A new public repository will be created at: ${repoUrl}`); + console.log(` • The repository will be created under your GitHub account (${auth.username}).`); + console.log(` • The repository URL will be added to your package.json.`); + console.log(` • The remote "origin" will be set to this repository (you can push when ready).\n`); + + const { confirmCreate } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmCreate', + message: 'Do you want to proceed with creating this repository?', + default: true, + }, + ]); + + if (!confirmCreate) { + console.log('\n❌ GitHub repository was not created.\n'); + return false; + } + + try { + const createdUrl = await createRepo({ + repoName, + projectPath, + username: auth.username, + ...(pkgJson.description && { description: String(pkgJson.description) }), + }); + pkgJson.repository = { type: 'git', url: createdUrl }; + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + console.log('\n✅ GitHub repository created successfully!'); + console.log(` ${repoUrl}`); + console.log(' Repository URL has been added to your package.json.\n'); + return true; + } catch (err) { + console.error('\n❌ Failed to create GitHub repository:'); + if (err instanceof Error) console.error(` ${err.message}\n`); + return false; + } +} diff --git a/src/load.ts b/src/load.ts new file mode 100644 index 0000000..4d39e97 --- /dev/null +++ b/src/load.ts @@ -0,0 +1,38 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; + +/** + * Runs the load flow: verify repo, then pull the latest updates from the remote. + * Throws on fatal errors (not a git repo, or git command failure). Caller should catch and process.exit(1). + */ +export async function runLoad(cwd: string): Promise { + const gitDir = path.join(cwd, '.git'); + if (!(await fs.pathExists(gitDir))) { + console.error('❌ This directory is not a git repository (.git not found).'); + console.error(' Initialize with "git init" or create a project with "patternfly-cli create".\n'); + throw new Error('Not a git repository'); + } + + try { + console.log('📥 Pulling latest updates from GitHub...\n'); + await execa('git', ['pull'], { cwd, stdio: 'inherit' }); + console.log('\n✅ Latest updates loaded successfully.\n'); + } catch (err) { + if (err && typeof err === 'object' && 'exitCode' in err) { + const code = (err as { exitCode?: number }).exitCode; + if (code === 128) { + console.error( + '\n❌ Pull failed. You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".\n', + ); + } else { + console.error('\n❌ Pull failed. See the output above for details.\n'); + } + } else { + console.error('\n❌ An error occurred:'); + if (err instanceof Error) console.error(` ${err.message}\n`); + else console.error(` ${String(err)}\n`); + } + throw err; + } +} diff --git a/src/save.ts b/src/save.ts new file mode 100644 index 0000000..282440f --- /dev/null +++ b/src/save.ts @@ -0,0 +1,103 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; +import { offerAndCreateGitHubRepo } from './github.js'; + +/** + * Runs the save flow: verify repo, check for changes, prompt to commit, then add/commit/push. + * Throws on fatal errors (not a git repo, or git command failure). Caller should catch and process.exit(1). + */ +export async function runSave(cwd: string): Promise { + const gitDir = path.join(cwd, '.git'); + if (!(await fs.pathExists(gitDir))) { + console.error('❌ This directory is not a git repository (.git not found).'); + console.error(' Initialize with "git init" or create a project with "patternfly-cli create".\n'); + throw new Error('Not a git repository'); + } + + const { stdout: statusOut } = await execa('git', ['status', '--porcelain'], { + cwd, + encoding: 'utf8', + }); + const hasChanges = statusOut.trim().length > 0; + + if (!hasChanges) { + console.log('📭 No changes to save (working tree clean).\n'); + return; + } + + const { saveChanges } = await inquirer.prompt([ + { + type: 'confirm', + name: 'saveChanges', + message: 'You have uncommitted changes. Would you like to save them?', + default: true, + }, + ]); + + if (!saveChanges) { + console.log('\n📭 Nothing has been saved.\n'); + return; + } + + const { message } = await inquirer.prompt([ + { + type: 'input', + name: 'message', + message: 'Describe your changes (commit message):', + validate: (input: string) => { + if (!input?.trim()) return 'A commit message is required to save.'; + return true; + }, + }, + ]); + + const commitMessage = (message as string).trim(); + if (!commitMessage) { + console.log('\n📭 No message provided; nothing has been saved.\n'); + return; + } + + try { + await execa('git', ['add', '.'], { cwd, stdio: 'inherit' }); + await execa('git', ['commit', '-m', commitMessage], { cwd, stdio: 'inherit' }); + + // If no remote origin, offer to create a GitHub repository before pushing + let hasOrigin = false; + try { + await execa('git', ['remote', 'get-url', 'origin'], { cwd, reject: true }); + hasOrigin = true; + } catch { + // no origin + } + if (!hasOrigin) { + const created = await offerAndCreateGitHubRepo(cwd); + if (!created) { + console.error( + '\n❌ Push skipped. Set a remote (e.g. "patternfly-cli init" or "git remote add origin ") then try save again.\n', + ); + throw new Error('No remote origin'); + } + } + + await execa('git', ['push'], { cwd, stdio: 'inherit' }); + console.log('\n✅ Changes saved and pushed to GitHub successfully.\n'); + } catch (err) { + if (err && typeof err === 'object' && 'exitCode' in err) { + const code = (err as { exitCode?: number }).exitCode; + if (code === 128) { + console.error( + '\n❌ Push failed. You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".\n', + ); + } else { + console.error('\n❌ Save or push failed. See the output above for details.\n'); + } + } else if (!(err instanceof Error && err.message === 'No remote origin')) { + console.error('\n❌ An error occurred:'); + if (err instanceof Error) console.error(` ${err.message}\n`); + else console.error(` ${String(err)}\n`); + } + throw err; + } +} diff --git a/src/template-loader.ts b/src/template-loader.ts index b33d93a..43f6a0d 100644 --- a/src/template-loader.ts +++ b/src/template-loader.ts @@ -2,7 +2,7 @@ import fs from 'fs-extra'; import path from 'path'; import type { Template } from './templates.js'; -/** Functoin used to load custom templates from a JSON file */ +/** Function used to load custom templates from a JSON file */ export function loadCustomTemplates(filePath: string): Template[] { const resolved = path.resolve(filePath); if (!fs.existsSync(resolved)) { @@ -44,8 +44,13 @@ export function loadCustomTemplates(filePath: string): Template[] { console.error(`❌ Template at index ${i}: "repo" must be a non-empty string.\n`); process.exit(1); } + const repoSSH = obj['repoSSH']; const options = obj['options']; const packageManager = obj['packageManager']; + if (typeof repoSSH !== 'undefined' && (typeof repoSSH !== 'string' || !repoSSH.trim())) { + console.error(`❌ Template at index ${i}: "repoSSH" must be a non-empty string.\n`); + process.exit(1); + } if (options !== undefined && (!Array.isArray(options) || options.some((o) => typeof o !== 'string'))) { console.error(`❌ Template at index ${i}: "options" must be an array of strings.\n`); process.exit(1); @@ -58,6 +63,7 @@ export function loadCustomTemplates(filePath: string): Template[] { name: name.trim(), description: String(description), repo: repo.trim(), + ...(typeof repoSSH === 'string' && repoSSH.trim() && { repoSSH: repoSSH.trim() }), ...(Array.isArray(options) && options.length > 0 && { options: options as string[] }), ...(typeof packageManager === 'string' && packageManager.length > 0 && { packageManager }), }); diff --git a/src/templates.ts b/src/templates.ts index c8ebe65..a77dbc6 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -5,6 +5,8 @@ export type Template = { description: string; /** Template repository URL */ repo: string; + /** Template repository SSH URL (optional, falls back to repo if not provided) */ + repoSSH?: string; /** Template checkout options */ options?: string[]; /** Template package manager */ @@ -16,25 +18,29 @@ export const defaultTemplates: Template[] = [ name: "starter", description: "A starter template for Patternfly react typescript project", repo: "https://github.com/patternfly/patternfly-react-seed.git", + repoSSH: "git@github.com:patternfly/patternfly-react-seed.git", packageManager: "yarn" }, { name: "compass-starter", description: "A starter template for Patternfly compass theme typescript project", repo: "https://github.com/patternfly/patternfly-react-seed.git", + repoSSH: "git@github.com:patternfly/patternfly-react-seed.git", options: ["--single-branch", "--branch", "compass_theme"], packageManager: "yarn" }, { name: "nextjs-starter", description: "A starter template for Patternfly nextjs project", - repo: "git@github.com:patternfly/patternfly-nextjs-seed.git", + repo: "https://github.com/patternfly/patternfly-nextjs-seed.git", + repoSSH: "git@github.com:patternfly/patternfly-nextjs-seed.git", packageManager: "yarn" }, { name: "rhoai_enabled_starter", description: "A starter template for Red Hat Open AI enabled project", repo: "https://gitlab.cee.redhat.com/uxd/prototypes/rhoai", + repoSSH: "git@gitlab.cee.redhat.com:uxd/prototypes/rhoai.git", options: ["--single-branch", "--branch", "3.2"] } ] diff --git a/tsconfig.json b/tsconfig.json index c8d26ce..f814415 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,9 @@ "esModuleInterop": true }, "include": [ - "src/**/*", + "src/**/*" + ], + "exclude": [ "src/__tests__/**/*.test.ts" ] } \ No newline at end of file