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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -44,6 +47,9 @@ 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 (errors if not possible)
- `--skip-upload` Skip uploading via gh and show the manual flow
- `--key-title <title>` Title to use when uploading via gh

## How it works

Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
);
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions src/lib/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`;
3 changes: 3 additions & 0 deletions src/lib/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export interface CliOptions {
keyName?: string;
updateConfig?: boolean;
skipConfig?: boolean;
upload?: boolean;
skipUpload?: boolean;
keyTitle?: string;
help: boolean;
version: boolean;
}
131 changes: 112 additions & 19 deletions src/lib/cli/workflow.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions src/lib/services/gh.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions tests/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ describe('parseArgs', () => {
'id_rsa_custom',
'--update-config',
'--skip-config',
'--upload',
'--key-title',
'My key title',
]);

expect(options.help).toBe(true);
Expand All @@ -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([]);
});

Expand All @@ -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']);
});
});
Loading
Loading