Skip to content
Merged
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
7 changes: 4 additions & 3 deletions src/__tests__/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fs.pathExists>;
/** Partial `fs-extra` mock: use `jest.Mock` for `mockResolvedValue` (typed mocks infer `never` here). */
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists> & jest.Mock;
const mockReadJson = fs.readJson as jest.MockedFunction<typeof fs.readJson>;
const mockWriteJson = fs.writeJson as jest.MockedFunction<typeof fs.writeJson>;
const mockRemove = fs.remove as jest.MockedFunction<typeof fs.remove>;
const mockRemove = fs.remove as jest.MockedFunction<typeof fs.remove> & jest.Mock;
const mockExeca = execa as jest.MockedFunction<typeof execa>;
const mockPrompt = inquirer.prompt as jest.MockedFunction<typeof inquirer.prompt>;
const mockOfferAndCreateGitHubRepo = offerAndCreateGitHubRepo as jest.MockedFunction<
Expand All @@ -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;
}
Expand Down
82 changes: 82 additions & 0 deletions src/__tests__/git-user-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof inquirer.prompt>;
const mockExeca = execa as jest.MockedFunction<typeof execa>;

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<ReturnType<typeof execa>>)
.mockResolvedValueOnce({ stdout: 'jane@example.com\n', stderr: '', exitCode: 0 } as Awaited<
ReturnType<typeof execa>
>)
.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);

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<ReturnType<typeof execa>>);
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'),
);
});
});
28 changes: 21 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions src/git-user-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { execa } from 'execa';
import inquirer from 'inquirer';

async function getGlobalGitValue(key: 'user.name' | 'user.email'): Promise<string | undefined> {
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<void> {
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');
}
}
67 changes: 56 additions & 11 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,53 @@ async function ensureInitialCommit(projectPath: string): Promise<void> {
});
} 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;
Expand Down Expand Up @@ -114,7 +152,8 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise<boo
{
type: 'confirm',
name: 'createGitHub',
message: 'Would you like to create a GitHub repository for this project?',
message:
'Would you like to create a GitHub repository for this project? (requires GitHub CLI and gh auth login)',
default: false,
},
]);
Expand All @@ -124,7 +163,8 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise<boo
const auth = await checkGhAuth();
if (!auth.ok) {
console.log(`\n⚠️ ${auth.message}`);
console.log(' Skipping GitHub repository creation.\n');
console.log(' Skipping GitHub repository creation.');
logGitHubSetupDidNotComplete(projectPath);
return false;
}

Expand All @@ -149,7 +189,10 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise<boo
repoName = sanitizeRepoName(alternativeName.trim());
}

if (!repoName) return false;
if (!repoName) {
logGitHubSetupDidNotComplete(projectPath);
return false;
}

const repoUrl = `https://github.com/${auth.username}/${repoName}`;
console.log('\n📋 The following will happen:\n');
Expand All @@ -168,7 +211,8 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise<boo
]);

if (!confirmCreate) {
console.log('\n❌ GitHub repository was not created.\n');
console.log('\n❌ GitHub repository was not created.');
logGitHubSetupDidNotComplete(projectPath);
return false;
}

Expand All @@ -187,7 +231,8 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise<boo
return true;
} catch (err) {
console.error('\n❌ Failed to create GitHub repository:');
if (err instanceof Error) console.error(` ${err.message}\n`);
if (err instanceof Error) console.error(` ${err.message}`);
logGitHubSetupDidNotComplete(projectPath);
return false;
}
}
Loading