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
2 changes: 1 addition & 1 deletion src/ask-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@ export const formatAskMessage = (

Repository: ${serviceName}${envSuffix}

lpop give ${publicKey}`;
npx @loggipop/lpop give ${publicKey}`;
};
154 changes: 151 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -146,6 +150,24 @@ export class LpopCLI {
const mergedOptions = { ...globalOptions, ...options };
await this.handleAsk(mergedOptions);
});

this.program
.command('give <publicKey>')
.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 <encryptedData>')
.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(
Expand Down Expand Up @@ -267,7 +289,6 @@ export class LpopCLI {

if (variables.length === 0) {
console.log(chalk.yellow(`No variables found for ${repoDisplayName}`));
return;
}

if (key) {
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -565,6 +586,133 @@ export class LpopCLI {
}
}

private async handleGive(
publicKey: string,
options: CommandOptions,
): Promise<void> {
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<string, string>,
);

// 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<void> {
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<string> {
if (options.repo) {
return `${getServicePrefix()}${options.repo}`;
Expand Down
Loading
Loading