diff --git a/src/ask-messages.ts b/src/ask-messages.ts index 112fdd8..99684f8 100644 --- a/src/ask-messages.ts +++ b/src/ask-messages.ts @@ -48,5 +48,5 @@ export const formatAskMessage = ( Repository: ${serviceName}${envSuffix} -lpop give ${publicKey}`; +npx @loggipop/lpop give ${publicKey}`; }; diff --git a/src/cli.ts b/src/cli.ts index 5aa3393..5747914 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,11 @@ import { } from './env-file-parser.js'; import { GitPathResolver, getServicePrefix } from './git-path-resolver.js'; import { KeychainManager } from './keychain-manager.js'; -import { getOrCreateDeviceKey } from './quantum-keys.js'; +import { + decryptWithPrivateKey, + encryptForPublicKey, + getOrCreateDeviceKey, +} from './quantum-keys.js'; type Options = { env?: string; @@ -146,6 +150,24 @@ export class LpopCLI { const mergedOptions = { ...globalOptions, ...options }; await this.handleAsk(mergedOptions); }); + + this.program + .command('give ') + .description('Encrypt environment variables for sharing with a colleague') + .action(async (publicKey: string, options: CommandOptions) => { + const globalOptions = this.program.opts(); + const mergedOptions = { ...globalOptions, ...options }; + await this.handleGive(publicKey, mergedOptions); + }); + + this.program + .command('receive ') + .description('Receive and decrypt shared environment variables') + .action(async (encryptedData: string, options: CommandOptions) => { + const globalOptions = this.program.opts(); + const mergedOptions = { ...globalOptions, ...options }; + await this.handleReceive(encryptedData, mergedOptions); + }); } private async handleSmartCommand( @@ -267,7 +289,6 @@ export class LpopCLI { if (variables.length === 0) { console.log(chalk.yellow(`No variables found for ${repoDisplayName}`)); - return; } if (key) { @@ -545,7 +566,6 @@ export class LpopCLI { await clipboardy.write(message); - console.log(chalk.green('✓ Message copied to clipboard!')); console.log(chalk.gray('\nGenerated message:')); console.log(chalk.gray('─'.repeat(50))); console.log(message); @@ -555,6 +575,7 @@ export class LpopCLI { '\nShare this message with your colleague to request environment variables!', ), ); + console.log(chalk.green('✓ Message copied to clipboard!')); } catch (error) { console.error( chalk.red( @@ -565,6 +586,133 @@ export class LpopCLI { } } + private async handleGive( + publicKey: string, + options: CommandOptions, + ): Promise { + try { + const serviceName = await this.getServiceName(options); + const repoDisplayName = await this.getRepositoryDisplayName(options); + const keychain = new KeychainManager(serviceName, options.env); + + console.log( + chalk.blue( + `Encrypting variables for ${repoDisplayName}${options.env ? ` [${options.env}]` : ''}`, + ), + ); + + // Get all variables for the repository/environment + const variables = await keychain.getEnvironmentVariables(); + + if (variables.length === 0) { + console.log(chalk.yellow(`No variables found for ${repoDisplayName}`)); + return; + } + + // Convert to object format for JSON serialization + const variablesObject = variables.reduce( + (obj, { key, value }) => { + obj[key] = value; + return obj; + }, + {} as Record, + ); + + // Encrypt the variables JSON against the provided public key + const variablesJson = JSON.stringify(variablesObject); + const encrypted = encryptForPublicKey(variablesJson, publicKey); + + // Create the encrypted blob to share + const encryptedBlob = JSON.stringify(encrypted); + + // Create the friendly message to send back + const message = `Okey dokey, here's a mystery blob with the new variables. Add them locally with: + +npx @loggipop/lpop receive ${encryptedBlob} + +(copied to clipboard)`; + + await clipboardy.write(message); + + console.log( + chalk.green('✓ Encrypted variables and copied message to clipboard!'), + ); + console.log(chalk.blue(`✓ Encrypted ${variables.length} variables`)); + console.log(chalk.gray('\nMessage to send back:')); + console.log(chalk.gray('─'.repeat(50))); + console.log(message); + console.log(chalk.gray('─'.repeat(50))); + } catch (error) { + console.error( + chalk.red( + `Error encrypting variables: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + process.exit(1); + } + } + + private async handleReceive( + encryptedData: string, + options: CommandOptions, + ): Promise { + try { + const serviceName = await this.getServiceName(options); + const repoDisplayName = await this.getRepositoryDisplayName(options); + const keychain = new KeychainManager(serviceName, options.env); + + console.log( + chalk.blue( + `Decrypting variables for ${repoDisplayName}${options.env ? ` [${options.env}]` : ''}`, + ), + ); + + // Get or create device key to decrypt with our private key + const deviceKey = getOrCreateDeviceKey(); + + // Parse the encrypted data + const encrypted = JSON.parse(encryptedData); + + // Decrypt the variables + const decryptedJson = decryptWithPrivateKey( + encrypted, + deviceKey.privateKey, + ); + const variablesObject = JSON.parse(decryptedJson) as Record< + string, + string + >; + + // Convert to keychain format + const variables = Object.entries(variablesObject).map(([key, value]) => ({ + key, + value, + })); + + // Store in keychain + await keychain.setEnvironmentVariables(variables); + + console.log( + chalk.green( + `✓ Received and stored ${variables.length} variables for ${repoDisplayName}`, + ), + ); + + // Show what was received + console.log(chalk.blue('\nReceived variables:')); + for (const { key } of variables) { + console.log(chalk.gray(` ${key}`)); + } + } catch (error) { + console.error( + chalk.red( + `Error receiving variables: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + process.exit(1); + } + } + private async getServiceName(options: CommandOptions): Promise { if (options.repo) { return `${getServicePrefix()}${options.repo}`; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index c7ba4d9..28e05ef 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -23,7 +23,11 @@ import { } from '../src/env-file-parser'; import { GitPathResolver, getServicePrefix } from '../src/git-path-resolver'; import { KeychainManager } from '../src/keychain-manager'; -import { getOrCreateDeviceKey } from '../src/quantum-keys'; +import { + decryptWithPrivateKey, + encryptForPublicKey, + getOrCreateDeviceKey, +} from '../src/quantum-keys'; // Mock modules vi.mock('../src/keychain-manager'); @@ -50,6 +54,8 @@ vi.mock('clipboardy', () => ({ // Mock quantum keys vi.mock('../src/quantum-keys', () => ({ getOrCreateDeviceKey: vi.fn(), + encryptForPublicKey: vi.fn(), + decryptWithPrivateKey: vi.fn(), })); // Mock ask messages @@ -109,6 +115,7 @@ describe('LpopCLI', () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetAllMocks(); // Setup console mocks consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -117,29 +124,35 @@ describe('LpopCLI', () => { throw new Error('process.exit called'); }); - // Setup KeychainManager mock + // Setup KeychainManager mock with default resolved values mockKeychainManager = { - setEnvironmentVariables: vi.fn(), - getEnvironmentVariables: vi.fn(), - removeEnvironmentVariable: vi.fn(), - clearAllEnvironmentVariables: vi.fn(), - setPassword: vi.fn(), - getPassword: vi.fn(), - deletePassword: vi.fn(), - findServiceCredentials: vi.fn(), - updateEnvironmentVariable: vi.fn(), + setEnvironmentVariables: vi.fn().mockResolvedValue(undefined), + getEnvironmentVariables: vi.fn().mockResolvedValue([]), + removeEnvironmentVariable: vi.fn().mockResolvedValue(true), + clearAllEnvironmentVariables: vi.fn().mockResolvedValue(undefined), + setPassword: vi.fn().mockResolvedValue(undefined), + getPassword: vi.fn().mockResolvedValue(''), + deletePassword: vi.fn().mockResolvedValue(true), + findServiceCredentials: vi.fn().mockResolvedValue([]), + updateEnvironmentVariable: vi.fn().mockResolvedValue(undefined), }; MockedKeychainManager.mockImplementation( () => mockKeychainManager as unknown as InstanceType, ); - // Setup GitPathResolver mock + // Setup GitPathResolver mock with default resolved values mockGitResolver = { generateServiceNameAsync: vi.fn().mockResolvedValue('lpop://user/repo'), - isGitRepository: vi.fn(), - getRemoteUrl: vi.fn(), - getGitInfo: vi.fn(), + isGitRepository: vi.fn().mockReturnValue(true), + getRemoteUrl: vi + .fn() + .mockResolvedValue('https://github.com/user/repo.git'), + getGitInfo: vi.fn().mockResolvedValue({ + owner: 'user', + name: 'repo', + full_name: 'user/repo', + }), }; MockedGitPathResolver.mockImplementation( () => mockGitResolver as unknown as InstanceType, @@ -266,6 +279,7 @@ describe('LpopCLI', () => { it('should add single variable', async () => { mockedExistsSync.mockReturnValue(false); + mockedParseVariable.mockReturnValue(asVariable('API_KEY', 'secret123')); process.argv = ['node', 'lpop', 'add', 'API_KEY=secret123']; await cli.run(); @@ -472,6 +486,11 @@ describe('LpopCLI', () => { describe('Service Name Resolution', () => { it('should use git resolver when no repo specified', async () => { mockKeychainManager.getEnvironmentVariables.mockResolvedValue([]); + mockGitResolver.getGitInfo.mockResolvedValue({ + owner: 'user', + name: 'repo', + full_name: 'user/repo', + }); process.argv = ['node', 'lpop', 'get']; await cli.run(); @@ -1051,4 +1070,356 @@ describe('LpopCLI', () => { await expect(cli.run()).rejects.toThrow(); }); }); + + describe('give command', () => { + const mockedClipboardy = vi.mocked(clipboardy); + const mockedEncryptForPublicKey = vi.mocked(encryptForPublicKey); + + beforeEach(() => { + mockGitResolver.generateServiceNameAsync.mockResolvedValue( + 'github.com/user/test-repo', + ); + mockGitResolver.getGitInfo.mockResolvedValue({ + full_name: 'user/test-repo', + owner: { login: 'user' }, + name: 'test-repo', + html_url: 'https://github.com/user/test-repo', + }); + + mockKeychainManager.getEnvironmentVariables.mockResolvedValue([ + { key: 'API_KEY', value: 'secret123' }, + { key: 'DB_URL', value: 'postgres://localhost:5432/mydb' }, + ]); + + mockedEncryptForPublicKey.mockReturnValue({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + mockedClipboardy.write.mockResolvedValue(); + }); + + it('should encrypt variables and generate give message', async () => { + process.argv = ['node', 'lpop', 'give', 'recipient-public-key-123']; + await cli.run(); + + expect(mockKeychainManager.getEnvironmentVariables).toHaveBeenCalled(); + expect(mockedEncryptForPublicKey).toHaveBeenCalledWith( + JSON.stringify({ + API_KEY: 'secret123', + DB_URL: 'postgres://localhost:5432/mydb', + }), + 'recipient-public-key-123', + ); + + const expectedEncryptedBlob = JSON.stringify({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + const expectedMessage = `Okey dokey, here's a mystery blob with the new variables. Add them locally with: + +npx @loggipop/lpop receive ${expectedEncryptedBlob} + +(copied to clipboard)`; + + expect(mockedClipboardy.write).toHaveBeenCalledWith(expectedMessage); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Encrypted variables and copied message to clipboard', + ), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Encrypted 2 variables'), + ); + }); + + it('should handle no variables found', async () => { + mockKeychainManager.getEnvironmentVariables.mockResolvedValue([]); + + process.argv = ['node', 'lpop', 'give', 'recipient-public-key-123']; + await cli.run(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('No variables found'), + ); + expect(mockedEncryptForPublicKey).not.toHaveBeenCalled(); + expect(mockedClipboardy.write).not.toHaveBeenCalled(); + }); + + it('should use environment option', async () => { + process.argv = [ + 'node', + 'lpop', + 'give', + 'recipient-key', + '-e', + 'production', + ]; + await cli.run(); + + expect(MockedKeychainManager).toHaveBeenCalledWith( + expect.any(String), + 'production', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[production]'), + ); + }); + + it('should use custom repo option', async () => { + process.argv = [ + 'node', + 'lpop', + 'give', + 'recipient-key', + '-r', + 'custom/repo', + ]; + await cli.run(); + + expect(MockedKeychainManager).toHaveBeenCalledWith( + expect.stringContaining('custom/repo'), + undefined, + ); + }); + + it('should handle encryption errors', async () => { + mockedEncryptForPublicKey.mockImplementation(() => { + throw new Error('Invalid public key format'); + }); + + process.argv = ['node', 'lpop', 'give', 'invalid-key']; + + try { + await cli.run(); + } catch { + // Expected to throw due to process.exit mock + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid public key format'), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle keychain access errors', async () => { + mockKeychainManager.getEnvironmentVariables.mockRejectedValue( + new Error('Keychain access denied'), + ); + + process.argv = ['node', 'lpop', 'give', 'recipient-key']; + + try { + await cli.run(); + } catch { + // Expected to throw due to process.exit mock + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Keychain access denied'), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('receive command', () => { + const mockedGetOrCreateDeviceKey = vi.mocked(getOrCreateDeviceKey); + const mockedDecryptWithPrivateKey = vi.mocked(decryptWithPrivateKey); + + beforeEach(() => { + mockGitResolver.generateServiceNameAsync.mockResolvedValue( + 'github.com/user/test-repo', + ); + mockGitResolver.getGitInfo.mockResolvedValue({ + full_name: 'user/test-repo', + owner: { login: 'user' }, + name: 'test-repo', + html_url: 'https://github.com/user/test-repo', + }); + + mockedGetOrCreateDeviceKey.mockReturnValue({ + publicKey: 'my-public-key-123', + privateKey: 'my-private-key-456', + createdAt: Date.now(), + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + }); + + mockedDecryptWithPrivateKey.mockReturnValue( + JSON.stringify({ + API_KEY: 'secret123', + DB_URL: 'postgres://localhost:5432/mydb', + NODE_ENV: 'production', + }), + ); + + mockKeychainManager.setEnvironmentVariables.mockResolvedValue(undefined); + }); + + it('should decrypt and store received variables', async () => { + const encryptedData = JSON.stringify({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + process.argv = ['node', 'lpop', 'receive', encryptedData]; + await cli.run(); + + expect(mockedGetOrCreateDeviceKey).toHaveBeenCalled(); + expect(mockedDecryptWithPrivateKey).toHaveBeenCalledWith( + { + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }, + 'my-private-key-456', + ); + + expect(mockKeychainManager.setEnvironmentVariables).toHaveBeenCalledWith([ + { key: 'API_KEY', value: 'secret123' }, + { key: 'DB_URL', value: 'postgres://localhost:5432/mydb' }, + { key: 'NODE_ENV', value: 'production' }, + ]); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Received and stored 3 variables'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Received variables:'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith(' API_KEY'); + expect(consoleLogSpy).toHaveBeenCalledWith(' DB_URL'); + expect(consoleLogSpy).toHaveBeenCalledWith(' NODE_ENV'); + }); + + it('should use environment option', async () => { + const encryptedData = JSON.stringify({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + process.argv = [ + 'node', + 'lpop', + 'receive', + encryptedData, + '-e', + 'staging', + ]; + await cli.run(); + + expect(MockedKeychainManager).toHaveBeenCalledWith( + expect.any(String), + 'staging', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[staging]'), + ); + }); + + it('should use custom repo option', async () => { + const encryptedData = JSON.stringify({ + encryptedKey: 'test-key', + ciphertext: 'test-data', + }); + + process.argv = [ + 'node', + 'lpop', + 'receive', + encryptedData, + '-r', + 'custom/repo', + ]; + await cli.run(); + + expect(MockedKeychainManager).toHaveBeenCalledWith( + expect.stringContaining('custom/repo'), + undefined, + ); + }); + + it('should handle invalid JSON input', async () => { + process.argv = ['node', 'lpop', 'receive', 'invalid-json-data']; + + try { + await cli.run(); + } catch { + // Expected to throw due to process.exit mock + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error receiving variables'), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle decryption errors', async () => { + mockedDecryptWithPrivateKey.mockImplementation(() => { + throw new Error('Decryption failed - wrong key or corrupted data'); + }); + + const encryptedData = JSON.stringify({ + encryptedKey: 'wrong-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + process.argv = ['node', 'lpop', 'receive', encryptedData]; + + try { + await cli.run(); + } catch { + // Expected to throw due to process.exit mock + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Decryption failed - wrong key or corrupted data', + ), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle keychain storage errors', async () => { + mockKeychainManager.setEnvironmentVariables.mockRejectedValue( + new Error('Keychain write failed'), + ); + + const encryptedData = JSON.stringify({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + process.argv = ['node', 'lpop', 'receive', encryptedData]; + + try { + await cli.run(); + } catch { + // Expected to throw due to process.exit mock + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Keychain write failed'), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle empty variables object', async () => { + mockedDecryptWithPrivateKey.mockReturnValue(JSON.stringify({})); + + const encryptedData = JSON.stringify({ + encryptedKey: 'encrypted-key-blob', + ciphertext: 'encrypted-data-blob', + }); + + process.argv = ['node', 'lpop', 'receive', encryptedData]; + await cli.run(); + + expect(mockKeychainManager.setEnvironmentVariables).toHaveBeenCalledWith( + [], + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Received and stored 0 variables'), + ); + }); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 8d52790..187bdea 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,11 @@ +import { webcrypto } from 'node:crypto'; import { afterAll, beforeAll, vi } from 'vitest'; +// Polyfill crypto.getRandomValues for tests +if (!globalThis.crypto) { + globalThis.crypto = webcrypto as unknown as Crypto; +} + beforeAll(() => { // Mock console methods to avoid noise in tests vi.spyOn(console, 'log').mockImplementation(() => {});