diff --git a/README.md b/README.md index f8370b3..52f9a97 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Provide options up front: - `gh-ssh --key-name id_github_work` - `gh-ssh --update-config` - `gh-ssh --skip-config` +- `gh-ssh --upload` +- `gh-ssh --skip-upload` +- `gh-ssh --key-title "My MacBook (work)"` Options: @@ -44,6 +47,9 @@ Options: - `--key-name ` Key filename in ~/.ssh (default: id_ed25519 or id_rsa) - `--update-config` Update ~/.ssh/config with the selected key - `--skip-config` Skip updating ~/.ssh/config +- `--upload` Upload the public key to GitHub via gh (errors if not possible) +- `--skip-upload` Skip uploading via gh and show the manual flow +- `--key-title ` Title to use when uploading via gh ## How it works @@ -52,7 +58,7 @@ Options: 3. Start ssh-agent if it is not already running. 4. Add the selected key to ssh-agent. 5. Optionally update ~/.ssh/config (with an optional GitHub host alias). -6. Copy the public key to clipboard (macOS/Linux) or print it to the terminal, then add it at https://github.com/settings/keys. +6. Copy the public key to clipboard (macOS/Linux) or print it to the terminal, then add it at [GitHub Settings](https://github.com/settings/keys). 7. Prompt to verify with `ssh -T git@github.com` (or your alias). ## Platform notes diff --git a/eslint.config.mjs b/eslint.config.mjs index 8e9a28b..abc3bd2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,9 +19,14 @@ export default defineConfig( 'error', { checksVoidReturn: false, + checksConditionals: true, + checksSpreads: true, }, ], - '@typescript-eslint/ban-ts-comment': 'off', }, + }, + { + files: ['*.mjs', '*.cjs', '*.js', '*.jsx'], + extends: [tseslint.configs.disableTypeChecked], } ); diff --git a/package-lock.json b/package-lock.json index 86a173b..a726fef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1404,6 +1404,7 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1453,6 +1454,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1781,6 +1783,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2065,6 +2068,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2755,6 +2759,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3092,6 +3097,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3125,6 +3131,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3180,6 +3187,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/lib/cli/args.ts b/src/lib/cli/args.ts index dd41953..ce99b2a 100644 --- a/src/lib/cli/args.ts +++ b/src/lib/cli/args.ts @@ -61,6 +61,20 @@ export const parseArgs = ( case '--skip-config': options.skipConfig = true; break; + case '--upload': + options.upload = true; + break; + case '--skip-upload': + options.skipUpload = true; + break; + case '--key-title': + if (hasValue(argv[i + 1])) { + options.keyTitle = argv[i + 1]; + i += 1; + } else { + unknown.push(arg); + } + break; default: unknown.push(arg); break; diff --git a/src/lib/cli/help.ts b/src/lib/cli/help.ts index 6b1342b..cfdd8ab 100644 --- a/src/lib/cli/help.ts +++ b/src/lib/cli/help.ts @@ -12,4 +12,7 @@ Options: --key-name <name> Key filename in ~/.ssh (default: id_ed25519 or id_rsa) --update-config Update ~/.ssh/config with the selected key --skip-config Skip updating ~/.ssh/config + --upload Upload the public key to GitHub via gh + --skip-upload Skip uploading via gh and show the manual flow + --key-title <title> Title to use when uploading via gh `; diff --git a/src/lib/cli/types.ts b/src/lib/cli/types.ts index 0e22029..ae5486f 100644 --- a/src/lib/cli/types.ts +++ b/src/lib/cli/types.ts @@ -6,6 +6,9 @@ export interface CliOptions { keyName?: string; updateConfig?: boolean; skipConfig?: boolean; + upload?: boolean; + skipUpload?: boolean; + keyTitle?: string; help: boolean; version: boolean; } diff --git a/src/lib/cli/workflow.ts b/src/lib/cli/workflow.ts index 5d0a704..94fa3d7 100644 --- a/src/lib/cli/workflow.ts +++ b/src/lib/cli/workflow.ts @@ -1,9 +1,15 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, hostname } from 'node:os'; import { basename, join } from 'node:path'; import { CliOptions, KeyType } from './types.js'; import { copyToClipboard } from '../services/clipboard.js'; +import { + addSshKeyViaGh, + isGhAuthenticated, + isGhInstalled, + runGhAuthLogin, +} from '../services/gh.js'; import { getGitEmail } from '../services/git.js'; import { addKeyToAgent, @@ -257,28 +263,115 @@ export const runWorkflow = async (options: CliOptions): Promise<void> => { await waitForNextStep(); printStep(6, 'Add the public key to GitHub', emoji.step6); - const publicKey = readFileSync(publicKeyPath, 'utf8'); - const copied = copyToClipboard(publicKey); - if (copied) { - logSuccess('Public key copied to clipboard.'); - } else { - logWarn('Copy failed. The public key is printed below:'); - console.log(publicKey.trim()); - } + const manualAddToGitHub = async (): Promise<void> => { + const publicKey = readFileSync(publicKeyPath, 'utf8'); + const copied = copyToClipboard(publicKey); + if (copied) { + logSuccess('Public key copied to clipboard.'); + } else { + logWarn('Copy failed. The public key is printed below:'); + console.log(publicKey.trim()); + } - logInfo('Open https://github.com/settings/keys to add a new SSH key.'); + logInfo('Open https://github.com/settings/keys to add a new SSH key.'); - let keyAdded = await promptYesNo( - 'Did you add the key to your GitHub SSH keys page', - false - ); - while (!keyAdded) { - logInfo('Paste the key in GitHub, then return here.'); - await waitForNextStep(); - keyAdded = await promptYesNo( - 'Have you added the key to your GitHub SSH keys page', + let keyAdded = await promptYesNo( + 'Did you add the key to your GitHub SSH keys page', false ); + while (!keyAdded) { + logInfo('Paste the key in GitHub, then return here.'); + await waitForNextStep(); + keyAdded = await promptYesNo( + 'Have you added the key to your GitHub SSH keys page', + false + ); + } + }; + + const ghHostname = 'github.com'; + const ghAvailable = isGhInstalled(); + + if (options.upload && options.skipUpload) { + logWarn('Both --upload and --skip-upload were provided. Skipping upload.'); + } + + const wantsUpload = options.skipUpload + ? false + : options.upload + ? true + : ghAvailable + ? await promptYesNo('Upload the public key to GitHub via gh now?', true) + : false; + + if (!wantsUpload) { + await manualAddToGitHub(); + } else if (!ghAvailable) { + const message = 'GitHub CLI (gh) not found. Install gh to auto-upload.'; + if (options.upload) { + logError(message); + process.exit(1); + } + + logWarn(message); + await manualAddToGitHub(); + } else { + const ensureGhAuth = async (): Promise<boolean> => { + if (isGhAuthenticated(ghHostname)) { + return true; + } + + const login = await promptYesNo('Run `gh auth login` now?', true); + if (!login) { + return false; + } + + if (!runGhAuthLogin()) { + return false; + } + + return isGhAuthenticated(ghHostname); + }; + + const authed = await ensureGhAuth(); + if (!authed) { + const message = + 'GitHub CLI is not authenticated. Run `gh auth login` and try again.'; + if (options.upload) { + logError(message); + process.exit(1); + } + + logWarn(message); + await manualAddToGitHub(); + } else { + const defaultTitle = `gh-ssh ${hostname()} (${basename(selectedKeyPath)})`; + let title = options.keyTitle?.trim(); + if (!title) { + title = ( + await promptInput('GitHub SSH key title', defaultTitle) + ).trim(); + if (!title) { + title = defaultTitle; + } + } + + const uploaded = addSshKeyViaGh(publicKeyPath, title); + if (uploaded) { + logSuccess('SSH key uploaded to GitHub via gh.'); + logInfo('Manage keys at https://github.com/settings/keys.'); + } else { + const message = + 'Failed to upload SSH key via gh. You can add it manually instead.'; + if (options.upload) { + logError(message); + process.exit(1); + } + + logWarn(message); + await manualAddToGitHub(); + } + } } await waitForNextStep(); diff --git a/src/lib/services/gh.ts b/src/lib/services/gh.ts new file mode 100644 index 0000000..be6dc50 --- /dev/null +++ b/src/lib/services/gh.ts @@ -0,0 +1,24 @@ +import { runCommand } from './command.js'; + +export const isGhInstalled = (): boolean => runCommand('gh', ['--version']).ok; + +export const isGhAuthenticated = (hostname: string): boolean => + runCommand('gh', ['auth', 'status', '--active', '--hostname', hostname]).ok; + +export const runGhAuthLogin = (): boolean => + runCommand('gh', ['auth', 'login'], { inheritStdio: true }).ok; + +export const addSshKeyViaGh = (publicKeyPath: string, title: string): boolean => + runCommand( + 'gh', + [ + 'ssh-key', + 'add', + publicKeyPath, + '--title', + title, + '--type', + 'authentication', + ], + { inheritStdio: true } + ).ok; diff --git a/tests/args.test.ts b/tests/args.test.ts index 7470e13..5c61129 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -21,6 +21,9 @@ describe('parseArgs', () => { 'id_rsa_custom', '--update-config', '--skip-config', + '--upload', + '--key-title', + 'My key title', ]); expect(options.help).toBe(true); @@ -30,6 +33,8 @@ describe('parseArgs', () => { expect(options.keyName).toBe('id_rsa_custom'); expect(options.updateConfig).toBe(true); expect(options.skipConfig).toBe(true); + expect(options.upload).toBe(true); + expect(options.keyTitle).toBe('My key title'); expect(unknown).toEqual([]); }); @@ -46,4 +51,12 @@ describe('parseArgs', () => { expect(options.help).toBe(true); expect(unknown).toEqual(['--type', '--email']); }); + + it('tracks missing key title without consuming next flag', () => { + const { options, unknown } = parseArgs(['--key-title', '--upload']); + + expect(options.upload).toBe(true); + expect(options.keyTitle).toBeUndefined(); + expect(unknown).toEqual(['--key-title']); + }); }); diff --git a/tests/gh.test.ts b/tests/gh.test.ts new file mode 100644 index 0000000..22711f5 --- /dev/null +++ b/tests/gh.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/lib/services/command.js', () => ({ + runCommand: vi.fn(), +})); + +import { runCommand } from '../src/lib/services/command.js'; +import { + addSshKeyViaGh, + isGhAuthenticated, + isGhInstalled, + runGhAuthLogin, +} from '../src/lib/services/gh.js'; + +const runCommandMock = vi.mocked(runCommand); + +describe('gh service', () => { + beforeEach(() => { + runCommandMock.mockReset(); + }); + + it('detects whether gh is installed', () => { + runCommandMock.mockReturnValue({ ok: true }); + + expect(isGhInstalled()).toBe(true); + expect(runCommandMock).toHaveBeenCalledWith('gh', ['--version']); + }); + + it('checks authentication for a hostname', () => { + runCommandMock.mockReturnValue({ ok: false }); + + expect(isGhAuthenticated('github.com')).toBe(false); + expect(runCommandMock).toHaveBeenCalledWith('gh', [ + 'auth', + 'status', + '--active', + '--hostname', + 'github.com', + ]); + }); + + it('runs gh auth login interactively', () => { + runCommandMock.mockReturnValue({ ok: true }); + + expect(runGhAuthLogin()).toBe(true); + expect(runCommandMock).toHaveBeenCalledWith( + 'gh', + ['auth', 'login'], + { inheritStdio: true } + ); + }); + + it('adds an SSH key via gh', () => { + runCommandMock.mockReturnValue({ ok: true }); + + expect(addSshKeyViaGh('/tmp/test.pub', 'My key')).toBe(true); + expect(runCommandMock).toHaveBeenCalledWith( + 'gh', + [ + 'ssh-key', + 'add', + '/tmp/test.pub', + '--title', + 'My key', + '--type', + 'authentication', + ], + { inheritStdio: true } + ); + }); +}); diff --git a/tests/help.test.ts b/tests/help.test.ts index 4f4ac05..d8d8a8e 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -8,5 +8,8 @@ describe('helpText', () => { expect(helpText).toContain('--type <ed25519|rsa>'); expect(helpText).toContain('--update-config'); expect(helpText).toContain('--skip-config'); + expect(helpText).toContain('--upload'); + expect(helpText).toContain('--skip-upload'); + expect(helpText).toContain('--key-title <title>'); }); });