Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,43 @@ patternfly-cli [command]

### Available Commands

- **`doctor`**: Check if all requirements are installed and optionally fix them.
- **`create`**: Create a new project from the available templates.
- **`list`**: List all available templates (built-in and optional custom).
- **`update`**: Update your project to a newer version.
- **`init`**: Initialize a git repository and optionally create a GitHub repository.
- **`save`**: Commit and push changes to the current branch.
- **`load`**: Pull the latest updates from GitHub.
- **`deploy`**: Build and deploy your app to GitHub Pages.

### Doctor Command

The `doctor` command checks if all requirements are met to use Patternfly CLI:

```sh
patternfly-cli doctor
```

This will check for:
- Node.js version >= 20
- Corepack enabled
- GitHub CLI installed

To automatically fix any missing requirements, use the `--fix` flag:

```sh
patternfly-cli doctor --fix
```

The `--fix` flag will:
- Enable corepack if it's not already enabled
- Install GitHub CLI using the appropriate package manager for your OS:
- macOS: Homebrew (`brew install gh`)
- Linux (Debian/Ubuntu): apt (`sudo apt install gh`)
- Linux (Fedora/RHEL): dnf (`sudo dnf install gh`)
- Windows: winget (`winget install --id GitHub.cli`)

**Important Note about Node.js:** The `doctor` command **cannot** automatically install or update Node.js. If your Node.js version is below 20 or Node.js is not installed, you must manually download and install it from [https://nodejs.org/](https://nodejs.org/). We recommend installing the **LTS (Long Term Support)** version.

### Custom templates

Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { jest } from '@jest/globals';
import { execa } from 'execa';

// Mock execa
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unnecessary comment. There are a few of these - where the comment doesn't add value - just repeats what the line below easily reads on its own.

jest.mock('execa');

const mockedExeca = execa as jest.MockedFunction<typeof execa>;

describe('doctor command', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock process.version
Object.defineProperty(process, 'version', {
value: 'v20.0.0',
writable: true,
});
});

it('should pass all checks when requirements are met', async () => {
// Mock successful corepack and gh commands
mockedExeca.mockResolvedValue({
stdout: 'gh version 2.0.0',
stderr: '',
exitCode: 0,
} as any);

const { runDoctor } = await import('../doctor.js');
await expect(runDoctor(false)).resolves.not.toThrow();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every test just asserts resolves.not.toThrow() - none verify the actual behavior (logged messages, execa call args, return values). These will pass even if the implementation is completely wrong. The test names say "should detect when X" but nothing is actually detected or asserted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like:

it('should detect when Node.js version is too old', async () => {
  const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

  Object.defineProperty(process, 'version', {
    value: 'v18.0.0',
    writable: true,
  });

  const { runDoctor } = await import('../doctor.js');
  await runDoctor(false);

  expect(logSpy).toHaveBeenCalledWith(
    expect.stringContaining('Node.js version v18.0.0 is not supported')
  );
});

});

it('should detect when Node.js version is too old', async () => {
Object.defineProperty(process, 'version', {
value: 'v18.0.0',
writable: true,
});

const { runDoctor } = await import('../doctor.js');
await expect(runDoctor(false)).resolves.not.toThrow();
});

it('should detect when corepack is not enabled', async () => {
mockedExeca.mockImplementation((command: string) => {
if (command === 'corepack') {
throw new Error('Command not found');
}
return Promise.resolve({
stdout: 'gh version 2.0.0',
stderr: '',
exitCode: 0,
} as any);
});

const { runDoctor } = await import('../doctor.js');
await expect(runDoctor(false)).resolves.not.toThrow();
});

it('should detect when GitHub CLI is not installed', async () => {
mockedExeca.mockImplementation((command: string) => {
if (command === 'gh') {
throw new Error('Command not found');
}
return Promise.resolve({
stdout: '0.28.0',
stderr: '',
exitCode: 0,
} as any);
});

const { runDoctor } = await import('../doctor.js');
await expect(runDoctor(false)).resolves.not.toThrow();
});
});
19 changes: 19 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ import { runCreate } from './create.js';
import { runSave } from './save.js';
import { runLoad } from './load.js';
import { runDeployToGitHubPages } from './gh-pages.js';
import { runDoctor } from './doctor.js';

/** Command to check and install requirements */
program
.command('doctor')
.description('Check if all requirements are installed (Node.js >= 20, corepack, GitHub CLI)')
.option('--fix', 'Automatically install missing requirements')
.action(async (options) => {
try {
await runDoctor(options.fix);
} catch (error) {
if (error instanceof Error) {
console.error(`\n❌ ${error.message}\n`);
} else {
console.error(error);
}
process.exit(1);
}
});

/** Command to create a new project */
program
Expand Down
204 changes: 204 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { execa } from 'execa';
import * as os from 'os';

interface CheckResult {
passed: boolean;
message: string;
}

/** Check if Node.js version is >= 20 */
async function checkNodeVersion(): Promise<CheckResult> {
const version = process.version;
const majorVersion = parseInt(version.slice(1).split('.')[0] || '0', 10);

if (majorVersion >= 20) {
return {
passed: true,
message: `✅ Node.js version ${version} (>= 20)`,
};
}

return {
passed: false,
message: `❌ Node.js version ${version} is not supported. Please install Node.js >= 20 from https://nodejs.org/`,
};
}

/** Check if corepack is enabled */
async function checkCorepack(): Promise<CheckResult> {
try {
await execa('corepack', ['--version'], { stdio: 'pipe' });
return {
passed: true,
message: '✅ Corepack is enabled',
};
} catch {
return {
passed: false,
message: '❌ Corepack is not enabled',
};
}
}

/** Enable corepack */
async function enableCorepack(): Promise<void> {
console.log('\n🔧 Enabling corepack...');
try {
await execa('corepack', ['enable'], { stdio: 'inherit' });
console.log('✅ Corepack enabled successfully\n');
} catch (error) {
throw new Error('Failed to enable corepack. You may need to run this command with elevated privileges (sudo).');
}
}

/** Check if GitHub CLI is installed */
async function checkGitHubCLI(): Promise<CheckResult> {
try {
const { stdout } = await execa('gh', ['--version'], { stdio: 'pipe' });
const version = stdout.split('\n')[0];
return {
passed: true,
message: `✅ GitHub CLI is installed (${version})`,
};
} catch {
return {
passed: false,
message: '❌ GitHub CLI is not installed',
};
}
}

/** Get OS-specific installation instructions for GitHub CLI */
function getGitHubCLIInstallCommand(): { command: string; args: string[]; description: string } | null {
const platform = os.platform();

switch (platform) {
case 'darwin': // macOS
return {
command: 'brew',
args: ['install', 'gh'],
description: 'Installing GitHub CLI via Homebrew',
};
case 'linux': {
// Try to detect the Linux distribution
try {
const fs = require('fs');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think require('fs') in an ESM output file will throw ReferenceError: require is not defined at runtime on Node.js 20-21 (only 22.12+ made require available in ESM). Since the tool supports Node >= 20, should this be import { existsSync } from 'fs' at the top of the file instead? I could be wrong on this.

if (fs.existsSync('/etc/debian_version')) {
// Debian/Ubuntu
return {
command: 'sudo',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--fix runs sudo apt/dnf install with -y and no user confirmation beyond the flag itself. Should this prompt before escalating to root?

args: ['apt', 'install', 'gh', '-y'],
description: 'Installing GitHub CLI via apt',
};
} else if (fs.existsSync('/etc/redhat-release')) {
// Red Hat/Fedora/CentOS
return {
command: 'sudo',
args: ['dnf', 'install', 'gh', '-y'],
description: 'Installing GitHub CLI via dnf',
};
}
} catch {
// If we can't detect, return null
}
return null;
}
case 'win32': // Windows
return {
command: 'winget',
args: ['install', '--id', 'GitHub.cli'],
description: 'Installing GitHub CLI via winget',
};
default:
return null;
}
}

/** Install GitHub CLI */
async function installGitHubCLI(): Promise<void> {
const installCommand = getGitHubCLIInstallCommand();

if (!installCommand) {
console.log('\n⚠️ Unable to automatically install GitHub CLI for your operating system.');
console.log('Please visit https://cli.github.com/ for installation instructions.\n');
return;
}

console.log(`\n🔧 ${installCommand.description}...`);
try {
await execa(installCommand.command, installCommand.args, { stdio: 'inherit' });
console.log('✅ GitHub CLI installed successfully\n');
} catch (error) {
console.error(`\n❌ Failed to install GitHub CLI automatically.`);
console.error('Please visit https://cli.github.com/ for manual installation instructions.\n');
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
}
}
}

/** Run the doctor command to check and fix requirements */
export async function runDoctor(autoFix: boolean = false): Promise<void> {
console.log('\n🏥 Running Patternfly CLI Doctor...\n');
console.log('Checking requirements...\n');

const results: CheckResult[] = [];
let allPassed = true;

// Check Node.js version
const nodeResult = await checkNodeVersion();
results.push(nodeResult);
console.log(nodeResult.message);
if (!nodeResult.passed) {
allPassed = false;
}

// Check corepack
const corepackResult = await checkCorepack();
results.push(corepackResult);
console.log(corepackResult.message);
if (!corepackResult.passed) {
allPassed = false;
if (autoFix) {
try {
await enableCorepack();
console.log('✅ Corepack is now enabled');
} catch (error) {
if (error instanceof Error) {
console.error(`❌ ${error.message}`);
}
}
}
}

// Check GitHub CLI
const ghResult = await checkGitHubCLI();
results.push(ghResult);
console.log(ghResult.message);
if (!ghResult.passed) {
allPassed = false;
if (autoFix) {
await installGitHubCLI();
}
}

console.log('\n' + '─'.repeat(60) + '\n');

if (allPassed) {
console.log('✨ All requirements are satisfied! You are ready to use Patternfly CLI.\n');
} else {
console.log('⚠️ Some requirements are not satisfied.\n');

// Check if Node.js version failed
if (!nodeResult.passed) {
console.log('📌 Node.js must be manually installed or updated.');
console.log(' Download from: https://nodejs.org/ (LTS version recommended)\n');
}

if (!autoFix) {
console.log('Run with --fix to automatically install missing requirements:\n');
console.log(' patternfly-cli doctor --fix\n');
console.log('Note: --fix can install corepack and GitHub CLI, but NOT Node.js.\n');
}
}
}
Loading