diff --git a/src/__tests__/create.test.ts b/src/__tests__/create.test.ts index 729755a..67f7339 100644 --- a/src/__tests__/create.test.ts +++ b/src/__tests__/create.test.ts @@ -36,10 +36,11 @@ import { sanitizeRepoName, offerAndCreateGitHubRepo } from '../github.js'; import { runCreate } from '../create.js'; import { defaultTemplates } from '../templates.js'; -const mockPathExists = fs.pathExists as jest.MockedFunction; +/** Partial `fs-extra` mock: use `jest.Mock` for `mockResolvedValue` (typed mocks infer `never` here). */ +const mockPathExists = fs.pathExists as jest.MockedFunction & jest.Mock; const mockReadJson = fs.readJson as jest.MockedFunction; const mockWriteJson = fs.writeJson as jest.MockedFunction; -const mockRemove = fs.remove as jest.MockedFunction; +const mockRemove = fs.remove as jest.MockedFunction & jest.Mock; const mockExeca = execa as jest.MockedFunction; const mockPrompt = inquirer.prompt as jest.MockedFunction; const mockOfferAndCreateGitHubRepo = offerAndCreateGitHubRepo as jest.MockedFunction< @@ -62,7 +63,7 @@ function setupHappyPathMocks() { mockReadJson.mockResolvedValue({ name: 'template-name', version: '0.0.0' }); mockWriteJson.mockResolvedValue(undefined); mockRemove.mockResolvedValue(undefined); - mockOfferAndCreateGitHubRepo.mockResolvedValue(undefined); + mockOfferAndCreateGitHubRepo.mockResolvedValue(false); mockPrompt.mockResolvedValue(projectData); return projectData; } diff --git a/src/__tests__/git-user-config.test.ts b/src/__tests__/git-user-config.test.ts new file mode 100644 index 0000000..dacb3cc --- /dev/null +++ b/src/__tests__/git-user-config.test.ts @@ -0,0 +1,82 @@ +jest.mock('inquirer', () => ({ + __esModule: true, + default: { prompt: jest.fn() }, +})); + +jest.mock('execa', () => ({ + __esModule: true, + execa: jest.fn(), +})); + +import inquirer from 'inquirer'; +import { execa } from 'execa'; +import { promptAndSetLocalGitUser } from '../git-user-config.js'; + +const mockPrompt = inquirer.prompt as jest.MockedFunction; +const mockExeca = execa as jest.MockedFunction; + +describe('promptAndSetLocalGitUser', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it('reads global defaults then sets local user.name and user.email', async () => { + mockExeca + .mockResolvedValueOnce({ stdout: 'Jane Doe\n', stderr: '', exitCode: 0 } as Awaited>) + .mockResolvedValueOnce({ stdout: 'jane@example.com\n', stderr: '', exitCode: 0 } as Awaited< + ReturnType + >) + .mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + mockPrompt.mockResolvedValue({ + userName: ' Local Name ', + userEmail: ' local@example.com ', + }); + + await promptAndSetLocalGitUser('/tmp/proj'); + + expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['config', '--global', 'user.name'], { + reject: false, + }); + expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['config', '--global', 'user.email'], { + reject: false, + }); + expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', ['config', '--local', 'user.name', 'Local Name'], { + cwd: '/tmp/proj', + stdio: 'inherit', + }); + expect(mockExeca).toHaveBeenNthCalledWith( + 4, + 'git', + ['config', '--local', 'user.email', 'local@example.com'], + { cwd: '/tmp/proj', stdio: 'inherit' }, + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Set local git user.name and user.email'), + ); + }); + + it('skips git config when name or email is empty after trim', async () => { + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + mockPrompt.mockResolvedValue({ userName: '', userEmail: 'a@b.com' }); + + await promptAndSetLocalGitUser('/tmp/proj'); + + const localConfigCalls = mockExeca.mock.calls.filter( + ([cmd, args]) => + cmd === 'git' && Array.isArray(args) && (args as string[]).includes('--local'), + ); + expect(localConfigCalls).toHaveLength(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Both user.name and user.email are required'), + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index e312141..6a49f03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { runSave } from './save.js'; import { runLoad } from './load.js'; import { runDeployToGitHubPages } from './gh-pages.js'; import { readPackageVersion } from './read-package-version.js'; +import { promptAndSetLocalGitUser } from './git-user-config.js'; const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); const packageVersion = readPackageVersion(packageJsonPath); @@ -42,14 +43,27 @@ 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'); + .option('--git-init', 'Prompt for git user.name and user.email and store them locally for this repository') + .action(async (dirPath, options) => { + try { + 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'); + } + if (options.gitInit) { + await promptAndSetLocalGitUser(cwd); + } + await offerAndCreateGitHubRepo(cwd); + } catch (error) { + if (error instanceof Error) { + console.error(`\n❌ ${error.message}\n`); + } else { + console.error(error); + } + process.exit(1); } - await offerAndCreateGitHubRepo(cwd); }); /** Command to list all available templates */ diff --git a/src/create.ts b/src/create.ts index b2d6ea9..18eb01b 100644 --- a/src/create.ts +++ b/src/create.ts @@ -159,7 +159,7 @@ export async function runCreate( await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' }); console.log('✅ Dependencies installed.'); - // Optional: Create GitHub repository + // Optional: Create GitHub repository (explains what to check if it does not complete) await offerAndCreateGitHubRepo(projectPath); // Let the user know the project was created successfully diff --git a/src/git-user-config.ts b/src/git-user-config.ts new file mode 100644 index 0000000..18cd69f --- /dev/null +++ b/src/git-user-config.ts @@ -0,0 +1,48 @@ +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +async function getGlobalGitValue(key: 'user.name' | 'user.email'): Promise { + const result = await execa('git', ['config', '--global', key], { reject: false }); + const value = result.stdout?.trim(); + return value || undefined; +} + +/** + * Prompts for user.name and user.email and sets them locally for the repository at cwd. + * Defaults are taken from global git config when present. + */ +export async function promptAndSetLocalGitUser(cwd: string): Promise { + const defaultName = await getGlobalGitValue('user.name'); + const defaultEmail = await getGlobalGitValue('user.email'); + + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'userName', + message: 'Git user.name for this repository:', + default: defaultName ?? '', + }, + { + type: 'input', + name: 'userEmail', + message: 'Git user.email for this repository:', + default: defaultEmail ?? '', + }, + ]); + + const name = typeof answers.userName === 'string' ? answers.userName.trim() : ''; + const email = typeof answers.userEmail === 'string' ? answers.userEmail.trim() : ''; + + if (!name || !email) { + console.error('\n⚠️ Both user.name and user.email are required. Git user was not configured.\n'); + return; + } + + try { + await execa('git', ['config', '--local', 'user.name', name], { cwd, stdio: 'inherit' }); + await execa('git', ['config', '--local', 'user.email', email], { cwd, stdio: 'inherit' }); + console.log('\n✅ Set local git user.name and user.email for this repository.\n'); + } catch { + console.error('\n⚠️ Could not set git config. Ensure git is installed and this directory is a repository.\n'); + } +} diff --git a/src/github.ts b/src/github.ts index 4840d74..7739473 100644 --- a/src/github.ts +++ b/src/github.ts @@ -61,15 +61,53 @@ async function ensureInitialCommit(projectPath: string): Promise { }); } catch { await execa('git', ['add', '.'], { stdio: 'inherit', cwd: projectPath }); - await execa('git', ['commit', '-m', 'Initial commit'], { - stdio: 'inherit', - cwd: projectPath, - }); + try { + await execa('git', ['commit', '-m', 'Initial commit'], { + stdio: 'inherit', + cwd: projectPath, + }); + } catch (err) { + const stderr = + err && typeof err === 'object' && 'stderr' in err + ? String((err as { stderr?: unknown }).stderr ?? '') + : ''; + const msg = err instanceof Error ? err.message : String(err); + const combined = `${msg}\n${stderr}`; + const looksLikeIdentityError = + /author identity unknown|please tell me who you are|unable to auto-detect email address|user\.email is not set|user\.name is not set/i.test( + combined, + ); + if (looksLikeIdentityError) { + throw new Error( + 'Could not create the initial git commit. Set your git identity, then try again:\n' + + ' git config --global user.name "Your Name"\n' + + ' git config --global user.email "you@example.com"', + ); + } + throw err; + } } } -/** - * Create a new GitHub repository and return its URL. Does not push. +/** + * After `create` removes the template `.git`, only a successful GitHub flow adds a repo back. + * Call this when the user asked for GitHub but setup did not finish. + */ +function logGitHubSetupDidNotComplete(projectPath: string): void { + const resolved = path.resolve(projectPath); + console.log('\n⚠️ Git repository setup did not complete.'); + console.log(' The template’s .git directory was removed after clone, so this folder is not a git repo yet.\n'); + console.log(' Check:'); + console.log(' • GitHub CLI: `gh auth status` — if not logged in, run `gh auth login`'); + console.log(' • Network and API errors above (permissions, repo name already exists, etc.)'); + console.log( + ' • Your git user.name and/or user.email may not be set. Run `patternfly-cli init --git-init` in the project directory to set local git identity and try again.', + ); + console.log(`\n Project path: ${resolved}\n`); +} + +/** + * Create a new GitHub repository and return its URL. Pushes the current branch via `gh repo create --push`. */ export async function createRepo(options: { repoName: string; @@ -114,7 +152,8 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise