< Summary - Envilder CLI

Information
Class: src/envilder/apps/cli/entry/Cli.ts
Assembly: Default
File(s): src/envilder/apps/cli/entry/Cli.ts
Tag: 502_37137557711
Line coverage
100%
Covered lines: 62
Uncovered lines: 0
Coverable lines: 62
Total lines: 245
Line coverage: 100%
Branch coverage
92%
Covered branches: 38
Total branches: 41
Branch coverage: 92.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/apps/cli/entry/Cli.ts

#LineLine coverage
 1import 'reflect-metadata';
 2import { existsSync } from 'node:fs';
 3import { dirname, join } from 'node:path';
 4import { fileURLToPath } from 'node:url';
 5import { Command } from 'commander';
 6import type { Container } from 'inversify';
 7import pc from 'picocolors';
 8import { DispatchActionCommand } from '../../../core/application/dispatch/DispatchActionCommand.js';
 9import type { DispatchActionCommandHandler } from '../../../core/application/dispatch/DispatchActionCommandHandler.js';
 10import type { CliOptions } from '../../../core/domain/CliOptions.js';
 11import { InvalidArgumentError } from '../../../core/domain/errors/DomainErrors.js';
 12import type { MapFileConfig } from '../../../core/domain/MapFileConfig.js';
 13import { OperationMode } from '../../../core/domain/OperationMode.js';
 14import type { ILogger } from '../../../core/domain/ports/ILogger.js';
 15import { PackageVersionReader } from '../../../core/infrastructure/package/PackageVersionReader.js';
 16import { readMapFileConfig } from '../../../core/infrastructure/variableStore/FileVariableStore.js';
 17import { TYPES } from '../../../core/types.js';
 18import { executeWithSsoRecovery } from '../recovery/SsoLoginRecovery.js';
 19import { Startup } from '../Startup.js';
 20
 121const DEFAULT_MAP_FILE = 'envilder.json';
 122const DEFAULT_ENV_FILE = '.env';
 23
 24let serviceProvider: Container;
 25
 26async function executeCommand(options: CliOptions): Promise<void> {
 927  const commandHandler = serviceProvider.get<DispatchActionCommandHandler>(
 28    TYPES.DispatchActionCommandHandler,
 29  );
 30
 931  const command = DispatchActionCommand.fromCliOptions(options);
 932  await commandHandler.handleCommand(command);
 33}
 34
 35export async function main() {
 3536  const program = new Command();
 3537  const version = await readPackageVersion();
 38
 3539  const banner = `
 40  ${pc.green('███████╗')}${pc.cyan('███╗   ██╗')}${pc.magenta('██╗   ██╗')}${pc.yellow('██╗')}${pc.red('██╗     ')}${pc.
 41  ${pc.green('██╔════╝')}${pc.cyan('████╗  ██║')}${pc.magenta('██║   ██║')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 42  ${pc.green('█████╗  ')}${pc.cyan('██╔██╗ ██║')}${pc.magenta('██║   ██║')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 43  ${pc.green('██╔══╝  ')}${pc.cyan('██║╚██╗██║')}${pc.magenta('╚██╗ ██╔╝')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 44  ${pc.green('███████╗')}${pc.cyan('██║ ╚████║')}${pc.magenta(' ╚████╔╝ ')}${pc.yellow('██║')}${pc.red('███████╗')}${pc.
 45  ${pc.green('╚══════╝')}${pc.cyan('╚═╝  ╚═══╝')}${pc.magenta('  ╚═══╝  ')}${pc.yellow('╚═╝')}${pc.red('╚══════╝')}${pc.
 46  ${pc.dim('Your secrets, one command away')}          ${pc.dim('aws & azure')}
 47
 48  ${pc.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
 49  ${pc.green('WORLD 1-1')} ${pc.dim('— SELECT YOUR MISSION')}
 50  ${pc.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
 51
 52  ${pc.green('>')} ${pc.bold('Generate a .env file')}  ${pc.dim('(pull secrets from the cloud)')}
 53    ${pc.cyan('envilder')}  ${pc.dim('# uses envilder.json → .env by default')}
 54
 55  ${pc.magenta('>')} ${pc.bold('Sync .env back to cloud')}  ${pc.dim('(push secrets up)')}
 56    ${pc.cyan('envilder --push')}  ${pc.dim('# uses envilder.json + .env by default')}
 57
 58  ${pc.red('>')} ${pc.bold('Push a single secret')}
 59    ${pc.cyan('envilder --key=API_KEY --value=s3cret --secret-path=/my/path')}
 60
 61  ${pc.blue('>')} ${pc.bold('Use Azure Key Vault')}
 62    ${pc.cyan('envilder --provider=azure --vault-url=https://my-vault.vault.azure.net')}
 63`;
 64
 3565  program
 66    .name('envilder')
 67    .description(banner)
 68    .version(version)
 69    .option(
 70      '--map <path>',
 71      `Path to the JSON file with environment variable mapping (default: ${DEFAULT_MAP_FILE})`,
 72    )
 73    .option(
 74      '--envfile <path>',
 75      `Path to the .env file to be generated or imported (default: ${DEFAULT_ENV_FILE})`,
 76    )
 77    .option('--profile <name>', 'AWS CLI profile to use (optional)')
 78    .option(
 79      '--provider <name>',
 80      'Cloud provider to use: aws or azure (default: aws)',
 81    )
 82    .option(
 83      '--vault-url <url>',
 84      'Azure Key Vault URL (overrides $config.vaultUrl in map file)',
 85    )
 86    .option('--push', 'Push a map-file-backed .env file to the cloud provider')
 87    .option(
 88      '--key <name>',
 89      'Environment variable name; with --value and --secret-path, performs a single-secret push (no --push required)',
 90    )
 91    .option(
 92      '--value <value>',
 93      'Secret value; with --key and --secret-path, performs a single-secret push (no --push required)',
 94    )
 95    .option(
 96      '--secret-path <path>',
 97      'Cloud secret path; with --key and --value, performs a single-secret push (no --push required)',
 98    )
 99    .option(
 100      '--ssm-path <path>',
 101      '[DEPRECATED: use --secret-path] Alias for --secret-path',
 102    )
 103    .hook('preAction', (thisCommand) => {
 34104      const opts = thisCommand.opts();
 105      // Alias on presence, not truthiness: an explicitly empty --ssm-path must
 106      // reach the single-secret guard below instead of vanishing silently.
 34107      if (opts.ssmPath !== undefined) {
 4108        console.warn(
 109          pc.yellow(
 110            '⚠️  --ssm-path is deprecated and will be removed in a future release. Use --secret-path instead.',
 111          ),
 112        );
 4113        if (opts.secretPath === undefined) {
 4114          thisCommand.setOptionValue('secretPath', opts.ssmPath);
 115        }
 116      }
 117    })
 118    .action(
 119      async ({
 120        provider,
 121        vaultUrl,
 122        ...options
 123      }: CliOptions & { provider?: string; vaultUrl?: string }) => {
 34124        const singleSecretOptions = [
 125          options.key,
 126          options.value,
 127          options.secretPath,
 128        ];
 129        const hasPartialSingleSecretOptions =
 62130          singleSecretOptions.some((option) => option !== undefined) &&
 52131          !singleSecretOptions.every((option) => Boolean(option));
 34132        if (hasPartialSingleSecretOptions) {
 22133          throw new InvalidArgumentError(
 134            'Single-secret push requires non-empty --key, --value, and --secret-path.',
 135          );
 136        }
 137
 12138        const mode = DispatchActionCommand.determineOperationMode(options);
 12139        const isPushSingle = mode === OperationMode.PUSH_SINGLE;
 12140        const resolvedMap = resolveMapFile(options.map, {
 141          required: !isPushSingle,
 142        });
 12143        const resolvedEnvfile = resolveEnvfile(options.envfile);
 144
 12145        const resolvedOptions: CliOptions = {
 146          ...options,
 147          map: resolvedMap,
 148          envfile: resolvedEnvfile,
 149        };
 150
 12151        const fileConfig = resolvedMap
 152          ? await readMapFileConfig(resolvedMap)
 153          : {};
 154
 34155        const config: MapFileConfig = {
 156          ...fileConfig,
 157          ...(provider && { provider }),
 158          ...(vaultUrl && { vaultUrl }),
 159          ...(options.profile && { profile: options.profile }),
 160        };
 161
 34162        const infraOptions: Record<string, unknown> = {};
 34163        const extraHosts = process.env.ENVILDER_ALLOWED_VAULT_HOSTS;
 34164        if (extraHosts) {
 1165          infraOptions.allowedVaultHosts = extraHosts
 166            .split(',')
 2167            .map((h) => h.trim());
 1168          infraOptions.disableChallengeResourceVerification = true;
 169        }
 170
 9171        serviceProvider = Startup.build()
 172          .configureServices()
 173          .configureInfrastructure(config, infraOptions)
 174          .create();
 175
 9176        if (isPushSingle) {
 4177          const logger = serviceProvider.get<ILogger>(TYPES.ILogger);
 4178          const providerName = config.provider?.toLowerCase() || 'aws';
 179          const vaultInfo =
 4180            providerName === 'azure' && config.vaultUrl
 181              ? `, vault=${new URL(config.vaultUrl).origin}`
 182              : '';
 4183          const source = resolvedMap
 184            ? `configuration from ${resolvedMap}`
 185            : 'configuration';
 4186          logger.info(`Using ${source}: provider=${providerName}${vaultInfo}`);
 187        }
 188
 9189        await executeWithSsoRecovery(() => executeCommand(resolvedOptions));
 190      },
 191    );
 192
 35193  await program.parseAsync(process.argv);
 194}
 195
 196function resolveMapFile(
 197  mapOption: string | undefined,
 198  options: { required: boolean } = { required: true },
 199): string | undefined {
 12200  if (mapOption !== undefined) {
 8201    const trimmed = mapOption.trim();
 8202    if (trimmed.length === 0) {
 1203      throw new InvalidArgumentError(
 204        'Invalid --map value: path must not be empty.',
 205      );
 206    }
 7207    return trimmed;
 208  }
 209
 4210  const defaultPath = join(process.cwd(), DEFAULT_MAP_FILE);
 4211  if (existsSync(defaultPath)) {
 2212    return DEFAULT_MAP_FILE;
 213  }
 214
 2215  if (!options.required) {
 1216    return undefined;
 217  }
 218
 1219  throw new InvalidArgumentError(
 220    `No map file found. Provide --map or create ${DEFAULT_MAP_FILE} in the current directory.`,
 221  );
 222}
 223
 224function resolveEnvfile(envfileOption: string | undefined): string {
 10225  if (envfileOption === undefined) {
 5226    return DEFAULT_ENV_FILE;
 227  }
 228
 5229  const trimmed = envfileOption.trim();
 5230  if (trimmed.length === 0) {
 1231    throw new InvalidArgumentError(
 232      'Invalid --envfile value: path must not be empty.',
 233    );
 234  }
 235
 4236  return trimmed;
 237}
 238
 239function readPackageVersion(): Promise<string> {
 35240  const __filename = fileURLToPath(import.meta.url);
 35241  const __dirname = dirname(__filename);
 35242  const packageJsonPath = join(__dirname, '../../../../../package.json');
 243
 35244  return new PackageVersionReader().getVersion(packageJsonPath);
 245}