@xec-sh/cli
Version:
Xec: The Universal Shell for TypeScript
519 lines • 19.1 kB
JavaScript
import chalk from 'chalk';
import { Command } from 'commander';
import * as clack from '@clack/prompts';
import { ConfigAwareCommand } from '../utils/command-base.js';
import { InteractiveHelpers } from '../utils/interactive-helpers.js';
export class SecretsCommand extends ConfigAwareCommand {
constructor() {
super({
name: 'secrets',
description: 'Manage secrets securely',
aliases: ['secret', 's']
});
}
getCommandConfigKey() {
return 'secrets';
}
create() {
const command = new Command(this.config.name)
.description(this.config.description);
if (this.config.aliases) {
this.config.aliases.forEach(alias => command.alias(alias));
}
command.action(async () => {
await this.execute([]);
});
this.setupSubcommands(command);
return command;
}
setupSubcommands(command) {
command
.command('set <key>')
.description('Set a secret value')
.option('-v, --value <value>', 'Secret value (prompt if not provided)')
.action(async (key, options) => {
await this.handleSubcommand(async () => {
await this.setSecret(key, options);
});
});
command
.command('get <key>')
.description('Get a secret value')
.action(async (key) => {
await this.handleSubcommand(async () => {
await this.getSecret(key);
});
});
command
.command('list')
.alias('ls')
.description('List all secret keys')
.action(async () => {
await this.handleSubcommand(async () => {
await this.listSecrets();
});
});
command
.command('delete <key>')
.alias('rm')
.description('Delete a secret')
.option('-f, --force', 'Skip confirmation')
.action(async (key, options) => {
await this.handleSubcommand(async () => {
await this.deleteSecret(key, options);
});
});
command
.command('generate <key>')
.description('Generate a random secret')
.option('-l, --length <length>', 'Secret length', '32')
.option('-f, --force', 'Overwrite existing secret without confirmation')
.action(async (key, options) => {
await this.handleSubcommand(async () => {
await this.generateSecret(key, options);
});
});
command
.command('export')
.description('Export all secrets (WARNING: outputs plain text)')
.option('-f, --format <format>', 'Output format (json, env)', 'json')
.option('--force', 'Skip confirmation (use with caution)')
.action(async (options) => {
await this.handleSubcommand(async () => {
await this.exportSecrets(options);
});
});
command
.command('import')
.description('Import secrets from JSON or env format')
.option('-f, --file <file>', 'Input file (or stdin if not provided)')
.option('--format <format>', 'Input format (json, env)', 'json')
.action(async (options) => {
await this.handleSubcommand(async () => {
await this.importSecrets(options);
});
});
}
async execute(args) {
await this.runInteractiveMode();
}
async runInteractiveMode() {
InteractiveHelpers.startInteractiveMode('🔐 Secrets Manager');
try {
while (true) {
const action = await clack.select({
message: 'What would you like to do?',
options: [
{ value: 'set', label: '🔒 Set a secret' },
{ value: 'get', label: '🔓 Get a secret' },
{ value: 'list', label: '📋 List all secrets' },
{ value: 'delete', label: '🗑️ Delete a secret' },
{ value: 'generate', label: '🎲 Generate a random secret' },
{ value: 'export', label: '📤 Export secrets (dangerous!)' },
{ value: 'import', label: '📥 Import secrets' },
{ value: 'exit', label: chalk.gray('Exit') },
],
});
if (clack.isCancel(action) || action === 'exit') {
break;
}
await this.handleInteractiveAction(action);
const continueAction = await clack.confirm({
message: 'Would you like to perform another action?',
initialValue: true,
});
if (clack.isCancel(continueAction) || !continueAction) {
break;
}
}
InteractiveHelpers.endInteractiveMode('✓ Secrets management complete');
}
catch (error) {
InteractiveHelpers.showError(error instanceof Error ? error.message : 'An unknown error occurred');
process.exit(1);
}
}
async handleInteractiveAction(action) {
switch (action) {
case 'set':
await this.interactiveSetSecret();
break;
case 'get':
await this.interactiveGetSecret();
break;
case 'list':
await this.listSecrets();
break;
case 'delete':
await this.interactiveDeleteSecret();
break;
case 'generate':
await this.interactiveGenerateSecret();
break;
case 'export':
await this.interactiveExportSecrets();
break;
case 'import':
await this.interactiveImportSecrets();
break;
}
}
async interactiveSetSecret() {
const key = await clack.text({
message: 'Enter secret key:',
validate: (input) => {
if (!input || input.length === 0) {
return 'Secret key cannot be empty';
}
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(input)) {
return 'Secret key must start with a letter and contain only letters, numbers, hyphens, dots, and underscores';
}
return undefined;
}
});
if (clack.isCancel(key))
return;
await this.setSecret(key, {});
}
async interactiveGetSecret() {
const manager = await this.getSecretManager();
const keys = await manager.list();
if (keys.length === 0) {
InteractiveHelpers.showInfo('No secrets found');
return;
}
const key = await clack.select({
message: 'Select secret to retrieve:',
options: keys.sort().map(k => ({ value: k, label: k })),
});
if (clack.isCancel(key))
return;
await this.getSecret(key);
}
async interactiveDeleteSecret() {
const manager = await this.getSecretManager();
const keys = await manager.list();
if (keys.length === 0) {
InteractiveHelpers.showInfo('No secrets found');
return;
}
const key = await clack.select({
message: 'Select secret to delete:',
options: keys.sort().map(k => ({ value: k, label: k })),
});
if (clack.isCancel(key))
return;
await this.deleteSecret(key, { force: false });
}
async interactiveGenerateSecret() {
const key = await clack.text({
message: 'Enter secret key:',
validate: (input) => {
if (!input || input.length === 0) {
return 'Secret key cannot be empty';
}
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(input)) {
return 'Secret key must start with a letter and contain only letters, numbers, hyphens, dots, and underscores';
}
return undefined;
}
});
if (clack.isCancel(key))
return;
const lengthInput = await clack.text({
message: 'Enter secret length:',
initialValue: '32',
validate: (input) => {
const length = parseInt(input, 10);
if (isNaN(length) || length < 1 || length > 256) {
return 'Length must be a number between 1 and 256';
}
return undefined;
}
});
if (clack.isCancel(lengthInput))
return;
await this.generateSecret(key, { length: lengthInput, force: false });
}
async interactiveExportSecrets() {
const format = await clack.select({
message: 'Select export format:',
options: [
{ value: 'json', label: 'JSON format' },
{ value: 'env', label: 'Environment variables' },
],
});
if (clack.isCancel(format))
return;
await this.exportSecrets({ format, force: false });
}
async interactiveImportSecrets() {
const source = await clack.select({
message: 'Import from:',
options: [
{ value: 'file', label: 'File' },
{ value: 'stdin', label: 'Standard input (paste/pipe)' },
],
});
if (clack.isCancel(source))
return;
const format = await clack.select({
message: 'Select input format:',
options: [
{ value: 'json', label: 'JSON format' },
{ value: 'env', label: 'Environment variables' },
],
});
if (clack.isCancel(format))
return;
let file;
if (source === 'file') {
const filePath = await clack.text({
message: 'Enter file path:',
validate: (input) => {
if (!input || input.length === 0) {
return 'File path cannot be empty';
}
return undefined;
}
});
if (clack.isCancel(filePath))
return;
file = filePath;
}
await this.importSecrets({ file, format });
}
async handleSubcommand(fn) {
try {
await fn();
}
catch (error) {
if (error instanceof Error) {
clack.log.error(error.message);
}
else {
clack.log.error('An unknown error occurred');
}
process.exit(1);
}
}
async getSecretManager() {
await this.initializeConfig({});
return this.configManager.getSecretManager();
}
async setSecret(key, options) {
const manager = await this.getSecretManager();
let value = options.value;
if (value === undefined) {
const input = await clack.password({
message: `Enter value for secret '${key}':`,
validate: (input) => {
if (!input || input.length === 0) {
return 'Secret value cannot be empty';
}
return undefined;
}
});
if (clack.isCancel(input)) {
clack.cancel('Operation cancelled');
process.exit(1);
}
value = input;
}
const spinner = clack.spinner();
spinner.start(`Setting secret '${key}'`);
try {
await manager.set(key, value);
spinner.stop(`Secret '${key}' set successfully`);
clack.outro(chalk.green('✓') + ' Secret stored securely');
}
catch (error) {
spinner.stop('Failed to set secret');
throw error;
}
}
async getSecret(key) {
const manager = await this.getSecretManager();
const spinner = clack.spinner();
spinner.start(`Retrieving secret '${key}'`);
try {
const value = await manager.get(key);
spinner.stop();
if (value === null) {
clack.log.error(`Secret '${key}' not found`);
process.exit(1);
}
console.log(value);
}
catch (error) {
spinner.stop('Failed to get secret');
throw error;
}
}
async listSecrets() {
const manager = await this.getSecretManager();
const spinner = clack.spinner();
spinner.start('Loading secrets');
try {
const keys = await manager.list();
spinner.stop();
if (keys.length === 0) {
clack.log.info('No secrets found');
return;
}
clack.log.message(chalk.bold(`Found ${keys.length} secret${keys.length === 1 ? '' : 's'}:`));
for (const key of keys.sort()) {
console.log(` ${chalk.cyan('•')} ${key}`);
}
}
catch (error) {
spinner.stop('Failed to list secrets');
throw error;
}
}
async deleteSecret(key, options) {
const manager = await this.getSecretManager();
if (!options.force) {
const confirm = await clack.confirm({
message: `Are you sure you want to delete secret '${key}'?`
});
if (clack.isCancel(confirm) || !confirm) {
clack.cancel('Operation cancelled');
process.exit(1);
}
}
const spinner = clack.spinner();
spinner.start(`Deleting secret '${key}'`);
try {
await manager.delete(key);
spinner.stop(`Secret '${key}' deleted`);
clack.outro(chalk.green('✓') + ' Secret removed');
}
catch (error) {
spinner.stop('Failed to delete secret');
throw error;
}
}
async generateSecret(key, options) {
const manager = await this.getSecretManager();
const length = parseInt(options.length, 10);
if (isNaN(length) || length < 1 || length > 256) {
clack.log.error('Invalid length. Must be between 1 and 256.');
process.exit(1);
}
if (await manager.has(key) && !options.force) {
const confirm = await clack.confirm({
message: `Secret '${key}' already exists. Overwrite?`
});
if (clack.isCancel(confirm) || !confirm) {
clack.cancel('Operation cancelled');
process.exit(1);
}
}
const spinner = clack.spinner();
spinner.start(`Generating ${length}-character secret`);
try {
const { generateSecret } = await import('../secrets/crypto.js');
const value = generateSecret(length);
await manager.set(key, value);
spinner.stop(`Secret '${key}' generated and stored`);
clack.log.message(`Generated value: ${chalk.gray(value)}`);
clack.outro(chalk.green('✓') + ' Secret stored securely');
}
catch (error) {
spinner.stop('Failed to generate secret');
throw error;
}
}
async exportSecrets(options) {
const manager = await this.getSecretManager();
if (!options.force) {
const confirm = await clack.confirm({
message: chalk.yellow('WARNING: This will output all secrets in plain text. Continue?')
});
if (clack.isCancel(confirm) || !confirm) {
clack.cancel('Export cancelled');
process.exit(1);
}
}
const spinner = clack.spinner();
spinner.start('Exporting secrets');
try {
const keys = await manager.list();
const secrets = {};
for (const key of keys) {
const value = await manager.get(key);
if (value !== null) {
secrets[key] = value;
}
}
spinner.stop();
if (options.format === 'env') {
for (const [key, value] of Object.entries(secrets)) {
const envKey = `SECRET_${key.toUpperCase().replace(/[.-]/g, '_')}`;
console.log(`export ${envKey}="${value.replace(/"/g, '\\"')}"`);
}
}
else {
console.log(JSON.stringify(secrets, null, 2));
}
}
catch (error) {
spinner.stop('Failed to export secrets');
throw error;
}
}
async importSecrets(options) {
const manager = await this.getSecretManager();
let content;
if (options.file) {
const fs = await import('fs/promises');
content = await fs.readFile(options.file, 'utf-8');
}
else {
content = await new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => data += chunk);
process.stdin.on('end', () => resolve(data));
});
}
const spinner = clack.spinner();
spinner.start('Importing secrets');
try {
let secrets = {};
if (options.format === 'env') {
const lines = content.split('\n');
for (const line of lines) {
const match = line.match(/^(?:export\s+)?SECRET_([A-Z0-9_]+)=["']?(.+?)["']?$/);
if (match) {
const key = match[1].toLowerCase().replace(/_/g, '-');
const value = match[2];
secrets[key] = value;
}
}
}
else {
secrets = JSON.parse(content);
}
const keys = Object.keys(secrets);
let imported = 0;
for (const [key, value] of Object.entries(secrets)) {
await manager.set(key, value);
imported++;
}
spinner.stop(`Imported ${imported} secret${imported === 1 ? '' : 's'}`);
clack.outro(chalk.green('✓') + ' Secrets imported successfully');
}
catch (error) {
spinner.stop('Failed to import secrets');
throw error;
}
}
}
export default function command(program) {
const command = new SecretsCommand();
const secretsCmd = command.create();
program.addCommand(secretsCmd);
}
//# sourceMappingURL=secrets.js.map