diff --git a/TODO.md b/TODO.md index d1fa3cc..bc14865 100644 --- a/TODO.md +++ b/TODO.md @@ -21,12 +21,9 @@ This document outlines the development tasks for the Tangled CLI, based on the ` ## Git SSH Key Management -- [ ] Implement `tangled ssh-key add ` command. - - [ ] This command should upload the provided public SSH key to the user's tangled.org account via the API, similar to how `gh ssh-key add` works. If no path is provided, it should default to `~/.ssh/id_rsa.pub` or prompt the user for a path. - - [ ] The CLI is not responsible for generating SSH keys or managing the local ssh-agent; users are expected to handle these steps externally. -- [ ] Implement `tangled ssh-key verify` command. - - [ ] This command should execute `ssh -T git@tangled.org`, parse the DID from its output, and then resolve that DID to a Bluesky handle, displaying the result to the user. -- [ ] Ensure all Git operations leverage SSH keys for authentication, as `tangled.org` exclusively supports SSH for Git. +- [x] Implement `tangled ssh-key verify` command. + - [x] This command executes `ssh -T git@tangled.org`, parses the DID from its output, and displays it to the user. + - [x] If the user is logged in with the CLI and their DID matches the SSH DID, their handle is also displayed. ## Context Engine (Git Integration) @@ -78,6 +75,18 @@ This section outlines the phased implementation for Pull Request (PR) support, f - [ ] This phase primarily involves local Git operations (pushing new commits) and using `tangled pr comment` for clarifications, which are covered by existing or planned commands. +## SSH Key Upload & Management (Phase 4) + +This phase adds CLI-based SSH key management for users who want to upload keys programmatically. + +- [ ] Implement `tangled ssh-key add ` command. + - [ ] This command should upload the provided public SSH key to the user's tangled.org account via the API, similar to how `gh ssh-key add` works. If no path is provided, it should default to `~/.ssh/id_ed25519.pub` or prompt the user for a path. + - [ ] Support reading keys from SSH agent via `ssh-add -L` for 1Password SSH agent users. + - [ ] The CLI is not responsible for generating SSH keys or managing the local ssh-agent; users are expected to handle these steps externally. +- [ ] Implement `tangled ssh-key list` command. + - [ ] List all SSH keys stored in the user's PDS. + - [ ] Display key type, name, creation date, and URI. + ## Output & LLM Integration - [ ] Implement output formatting based on `is-interactive` check. @@ -88,9 +97,10 @@ This section outlines the phased implementation for Pull Request (PR) support, f ## Testing -- [ ] Set up a testing framework (e.g., Jest, Vitest). -- [ ] Write unit tests for core modules (Auth, Context Resolver, API client). -- [ ] Write integration tests for CLI commands. +- [x] Set up a testing framework (Vitest). +- [x] Write unit tests for core modules (Auth, Session, API client, Validation, Prompts). +- [x] Write integration tests for CLI commands (Auth, SSH key verify). +- [ ] Add integration tests for remaining commands as they are implemented. ## Documentation & Deployment @@ -99,7 +109,9 @@ This section outlines the phased implementation for Pull Request (PR) support, f ## Outstanding Issues / Future Considerations (from README) -- [ ] Secure cross-platform AT Proto session storage (OS keychain). -- [ ] Git authentication management similar to GitHub CLI (SSH keys, 1Password integration). +- [x] Secure cross-platform AT Proto session storage (OS keychain) - Implemented with @napi-rs/keyring. +- [x] SSH key verification for Git authentication - Implemented `tangled ssh-key verify`. +- [ ] SSH key upload management (See Phase 4 above). +- [ ] 1Password SSH agent integration for key upload (See Phase 4 above). - [ ] Define clear precedence order for settings resolution (local config, home folder, CLI flags). - [ ] Consider adding extensions/plugins (Out of Scope for V1, but keep in mind). diff --git a/src/commands/ssh-key.ts b/src/commands/ssh-key.ts new file mode 100644 index 0000000..633534a --- /dev/null +++ b/src/commands/ssh-key.ts @@ -0,0 +1,75 @@ +import { execSync } from 'node:child_process'; +import { Command } from 'commander'; +import { getCurrentSessionMetadata } from '../lib/session.js'; + +/** + * Create the ssh-key command with subcommands for managing SSH keys + */ +export function createSshKeyCommand(): Command { + const sshKey = new Command('ssh-key'); + sshKey.description('Verify SSH key setup for Git authentication'); + + // Verify command + sshKey + .command('verify') + .description('Verify SSH key authentication with git@tangled.org') + .action(async () => { + try { + console.log('Testing SSH connection to git@tangled.org...\n'); + + // Execute ssh -T git@tangled.org to test authentication + let output: string; + try { + output = execSync('ssh -T git@tangled.org', { + encoding: 'utf-8', + stdio: 'pipe', + }); + } catch (error) { + // ssh -T returns non-zero exit code even on success + // Capture stderr which contains the authentication message + if (error instanceof Error && 'stderr' in error) { + output = (error as { stderr: string }).stderr; + } else { + throw error; + } + } + + // Parse the DID from the output + // Expected format: "Hi @did:plc:...! You've successfully authenticated." + const didMatch = output.match(/@(did:plc:[a-z0-9]+)/i); + + if (!didMatch) { + console.error('āœ— SSH authentication failed'); + console.error('Could not find authenticated DID in response'); + console.error('\nPlease ensure you have:'); + console.error('1. Generated an SSH key (ssh-keygen -t ed25519)'); + console.error('2. Added your public key to https://tangled.org/settings/keys'); + console.error('3. Your SSH agent is running (ssh-add -l)'); + process.exit(1); + } + + const did = didMatch[1]; + console.log('āœ“ SSH authentication successful'); + console.log(` Authenticated as: ${did}`); + + // Check if this matches the logged-in user + const session = await getCurrentSessionMetadata(); + if (session && session.did === did) { + console.log(` Handle: @${session.handle}`); + } + + console.log('\nāœ“ Your SSH setup is working correctly!'); + } catch (error) { + console.error( + `\nāœ— Failed to verify SSH setup: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + console.error('\nPlease ensure you have:'); + console.error('1. Generated an SSH key (ssh-keygen -t ed25519)'); + console.error('2. Added your public key to https://tangled.org/settings/keys'); + console.error('3. Your SSH agent is running (ssh-add -l)'); + process.exit(1); + } + }); + + return sshKey; +} diff --git a/src/index.ts b/src/index.ts index 3d02da3..a8bcbe2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { createAuthCommand } from './commands/auth.js'; +import { createSshKeyCommand } from './commands/ssh-key.js'; // Get package.json for version const __filename = fileURLToPath(import.meta.url); @@ -19,5 +20,6 @@ program // Register commands program.addCommand(createAuthCommand()); +program.addCommand(createSshKeyCommand()); program.parse(process.argv); diff --git a/tests/commands/ssh-key.test.ts b/tests/commands/ssh-key.test.ts new file mode 100644 index 0000000..e98cfb2 --- /dev/null +++ b/tests/commands/ssh-key.test.ts @@ -0,0 +1,150 @@ +import { execSync } from 'node:child_process'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSshKeyCommand } from '../../src/commands/ssh-key.js'; +import * as sessionModule from '../../src/lib/session.js'; + +vi.mock('node:child_process'); +vi.mock('../../src/lib/session.js'); + +describe('SSH Key Commands', () => { + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock console methods + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; + + // Mock process.exit to throw to stop execution + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + }); + + describe('verify command', () => { + it('should parse DID from successful SSH response', async () => { + // Mock successful SSH response with actual format from tangled.org + const mockSshOutput = + "Hi @did:plc:b2mcbcamkwyznc5fkplwlxbf! You've successfully authenticated.\n"; + + vi.mocked(execSync).mockImplementation(() => { + // ssh -T returns non-zero exit code even on success, throw with stderr + const error = new Error('SSH command') as Error & { stderr: string }; + error.stderr = mockSshOutput; + throw error; + }); + + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + + const sshKey = createSshKeyCommand(); + await sshKey.parseAsync(['node', 'test', 'verify']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('SSH authentication successful') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Your SSH setup is working correctly') + ); + }); + + it('should show handle when logged in user matches SSH DID', async () => { + const mockDid = 'did:plc:b2mcbcamkwyznc5fkplwlxbf'; + const mockSshOutput = `Hi @${mockDid}! You've successfully authenticated.\n`; + + vi.mocked(execSync).mockImplementation(() => { + const error = new Error('SSH command') as Error & { stderr: string }; + error.stderr = mockSshOutput; + throw error; + }); + + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ + handle: 'user.bsky.social', + did: mockDid, + pds: 'https://bsky.social', + lastUsed: new Date().toISOString(), + }); + + const sshKey = createSshKeyCommand(); + await sshKey.parseAsync(['node', 'test', 'verify']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('@user.bsky.social')); + }); + + it('should not show handle when logged in user does not match SSH DID', async () => { + const mockSshOutput = + "Hi @did:plc:b2mcbcamkwyznc5fkplwlxbf! You've successfully authenticated.\n"; + + vi.mocked(execSync).mockImplementation(() => { + const error = new Error('SSH command') as Error & { stderr: string }; + error.stderr = mockSshOutput; + throw error; + }); + + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ + handle: 'otheruser.bsky.social', + did: 'did:plc:differentuser', + pds: 'https://bsky.social', + lastUsed: new Date().toISOString(), + }); + + const sshKey = createSshKeyCommand(); + await sshKey.parseAsync(['node', 'test', 'verify']); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') + ); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining('@otheruser.bsky.social') + ); + }); + + it('should handle SSH authentication failure', async () => { + const mockSshOutput = 'Permission denied (publickey).\n'; + + vi.mocked(execSync).mockImplementation(() => { + const error = new Error('SSH command') as Error & { stderr: string }; + error.stderr = mockSshOutput; + throw error; + }); + + const sshKey = createSshKeyCommand(); + await expect(sshKey.parseAsync(['node', 'test', 'verify'])).rejects.toThrow('process.exit'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('SSH authentication failed') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not find authenticated DID') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should provide helpful error message on failure', async () => { + const mockSshOutput = 'Connection refused'; + + vi.mocked(execSync).mockImplementation(() => { + const error = new Error('SSH command') as Error & { stderr: string }; + error.stderr = mockSshOutput; + throw error; + }); + + const sshKey = createSshKeyCommand(); + await expect(sshKey.parseAsync(['node', 'test', 'verify'])).rejects.toThrow('process.exit'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Generated an SSH key')); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('tangled.org/settings/keys') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('SSH agent is running')); + }); + }); +});