Skip to content

Commit bb664e7

Browse files
committed
fix: gracefully handle missing gh CLI during push repo creation
1 parent dec7917 commit bb664e7

4 files changed

Lines changed: 102 additions & 11 deletions

File tree

src/commands/push.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/commands/push.test.ts
22
import { describe, it, expect, vi, beforeEach } from 'vitest';
33
import { getGitHubToken } from '../auth.js';
4-
import { pushSkills, getRepoRemoteUrl, ensureGitRepo, ensureRemote, createGitHubRepo } from '../git-ops.js';
4+
import { pushSkills, getRepoRemoteUrl, ensureGitRepo, ensureRemote, createGitHubRepo, isGhInstalled } from '../git-ops.js';
55

66
const mockPrompts = vi.hoisted(() => ({
77
intro: vi.fn(),
@@ -28,6 +28,7 @@ vi.mock('../git-ops.js', () => ({
2828
ensureGitRepo: vi.fn(),
2929
ensureRemote: vi.fn(),
3030
createGitHubRepo: vi.fn(),
31+
isGhInstalled: vi.fn(),
3132
buildRemoteUrl: vi.fn((repo: string) => `https://github.com/${repo}.git`),
3233
}));
3334

@@ -66,6 +67,7 @@ describe('push command logic', () => {
6667
remoteUrl: 'https://github.com/owner/my-skills.git',
6768
added: true,
6869
});
70+
vi.mocked(isGhInstalled).mockReturnValue(true);
6971
// User declines creating a remote repo
7072
mockPrompts.confirm.mockResolvedValue(false);
7173
vi.mocked(pushSkills).mockResolvedValue({ committed: true, pushed: true });
@@ -88,6 +90,7 @@ describe('push command logic', () => {
8890
remoteUrl: 'https://github.com/owner/my-skills.git',
8991
added: true,
9092
});
93+
vi.mocked(isGhInstalled).mockReturnValue(true);
9194
// User confirms creating repo
9295
mockPrompts.confirm.mockResolvedValue(true);
9396
vi.mocked(createGitHubRepo).mockReturnValue(undefined);
@@ -150,6 +153,7 @@ describe('push command logic', () => {
150153
remoteUrl: 'https://github.com/owner/my-skills.git',
151154
added: true,
152155
});
156+
vi.mocked(isGhInstalled).mockReturnValue(true);
153157
// Simulate user pressing Ctrl+C on confirm prompt
154158
mockPrompts.confirm.mockResolvedValue(Symbol('cancel'));
155159
mockPrompts.isCancel.mockImplementation((val) => typeof val === 'symbol');
@@ -173,4 +177,44 @@ describe('push command logic', () => {
173177

174178
expect(spinnerStop).toHaveBeenCalledWith('Unpushed commits pushed successfully!');
175179
});
180+
181+
it('shows note instead of confirm when gh CLI is not installed', async () => {
182+
vi.mocked(getGitHubToken).mockReturnValue('ghp_test_token');
183+
vi.mocked(ensureGitRepo).mockResolvedValue({ initialized: true });
184+
vi.mocked(getRepoRemoteUrl).mockResolvedValue(null);
185+
mockPrompts.text.mockResolvedValue('owner/my-skills');
186+
vi.mocked(ensureRemote).mockResolvedValue({
187+
remoteUrl: 'https://github.com/owner/my-skills.git',
188+
added: true,
189+
});
190+
vi.mocked(isGhInstalled).mockReturnValue(false);
191+
vi.mocked(pushSkills).mockResolvedValue({ committed: true, pushed: true });
192+
193+
const { pushCommand } = await import('./push.js');
194+
await pushCommand({});
195+
196+
// Should show a note, not a confirm prompt
197+
expect(mockPrompts.confirm).not.toHaveBeenCalled();
198+
expect(createGitHubRepo).not.toHaveBeenCalled();
199+
expect(mockPrompts.note).toHaveBeenCalledWith(
200+
expect.stringContaining('gh CLI not found'),
201+
expect.stringContaining('GitHub CLI not installed'),
202+
);
203+
// Should still proceed with push
204+
expect(pushSkills).toHaveBeenCalledOnce();
205+
});
206+
207+
it('skips gh check entirely when remote already exists', async () => {
208+
vi.mocked(getGitHubToken).mockReturnValue('ghp_test_token');
209+
vi.mocked(ensureGitRepo).mockResolvedValue({ initialized: false });
210+
vi.mocked(getRepoRemoteUrl).mockResolvedValue('https://github.com/user/repo.git');
211+
vi.mocked(pushSkills).mockResolvedValue({ committed: true, pushed: true });
212+
213+
const { pushCommand } = await import('./push.js');
214+
await pushCommand({});
215+
216+
expect(isGhInstalled).not.toHaveBeenCalled();
217+
expect(mockPrompts.confirm).not.toHaveBeenCalled();
218+
expect(createGitHubRepo).not.toHaveBeenCalled();
219+
});
176220
});

src/commands/push.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ensureGitRepo,
99
ensureRemote,
1010
createGitHubRepo,
11+
isGhInstalled,
1112
} from '../git-ops.js';
1213
import * as p from '@clack/prompts';
1314

@@ -37,19 +38,27 @@ export async function pushCommand(options: { message?: string }): Promise<void>
3738

3839
await ensureRemote(AGENTS_DIR, repo);
3940

40-
const shouldCreate = await p.confirm({
41-
message: 'Create this repo on GitHub? (requires gh CLI)',
42-
});
43-
if (p.isCancel(shouldCreate)) {
44-
p.cancel('Push cancelled.');
45-
throw new CliError('Push cancelled by user.');
46-
}
41+
if (isGhInstalled()) {
42+
const shouldCreate = await p.confirm({
43+
message: 'Create this repo on GitHub? (requires gh CLI)',
44+
});
45+
if (p.isCancel(shouldCreate)) {
46+
p.cancel('Push cancelled.');
47+
throw new CliError('Push cancelled by user.');
48+
}
4749

48-
if (shouldCreate === true) {
49-
createGitHubRepo(repo);
50+
if (shouldCreate === true) {
51+
createGitHubRepo(repo);
52+
}
53+
} else {
54+
p.note(
55+
`gh CLI not found — cannot create the repo automatically.\nPlease create it manually at https://github.com/new and then re-run push.`,
56+
'⚠ GitHub CLI not installed',
57+
);
5058
}
5159
}
5260

61+
5362
// 5. Push
5463
const spinner = p.spinner();
5564
spinner.start('Pushing skills to GitHub...');

src/git-ops.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// src/git-ops.test.ts
22
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3-
import { buildRemoteUrl, detectSuspiciousFiles, ensureGitignore, getRepoRemoteUrl, pushSkills, pullSkills, ensureGitRepo, ensureRemote, createGitHubRepo } from './git-ops.js';
3+
import { buildRemoteUrl, detectSuspiciousFiles, ensureGitignore, getRepoRemoteUrl, pushSkills, pullSkills, ensureGitRepo, ensureRemote, createGitHubRepo, isGhInstalled } from './git-ops.js';
44
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
55
import { join } from 'node:path';
66
import { tmpdir } from 'node:os';
@@ -654,3 +654,28 @@ describe('createGitHubRepo', () => {
654654
expect(() => createGitHubRepo('owner/my-skills')).toThrow('Failed to create GitHub repository');
655655
});
656656
});
657+
658+
// ---------------------------------------------------------------------------
659+
// isGhInstalled
660+
// ---------------------------------------------------------------------------
661+
describe('isGhInstalled', () => {
662+
beforeEach(() => {
663+
vi.resetAllMocks();
664+
});
665+
666+
it('returns true when gh --version succeeds', () => {
667+
mockExecSync.mockReturnValue('gh version 2.40.0');
668+
669+
expect(isGhInstalled()).toBe(true);
670+
expect(mockExecSync).toHaveBeenCalledWith(
671+
'gh --version',
672+
expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }),
673+
);
674+
});
675+
676+
it('returns false when gh is not installed', () => {
677+
mockExecSync.mockImplementation(() => { throw new Error('command not found: gh'); });
678+
679+
expect(isGhInstalled()).toBe(false);
680+
});
681+
});

src/git-ops.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,19 @@ export async function ensureRemote(
332332
return { remoteUrl, added: true };
333333
}
334334

335+
/**
336+
* Check whether the `gh` CLI is available on the system PATH.
337+
*/
338+
export function isGhInstalled(): boolean {
339+
try {
340+
execSync('gh --version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
341+
return true;
342+
} catch {
343+
return false;
344+
}
345+
}
346+
347+
335348
/**
336349
* Create a GitHub repository using the `gh` CLI.
337350
* Throws if `gh` is not installed or the command fails.

0 commit comments

Comments
 (0)