< Summary - Envilder CLI

Information
Class: src/envilder/apps/cli/entry/Cli.ts
Assembly: Default
File(s): src/envilder/apps/cli/entry/Cli.ts
Tag: 561_41292037308
Line coverage
97%
Covered lines: 234
Uncovered lines: 5
Coverable lines: 239
Total lines: 974
Line coverage: 97.9%
Branch coverage
92%
Covered branches: 144
Total branches: 156
Branch coverage: 92.3%
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, CommanderError, Option } 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 {
 11  formatArgument,
 12  formatProgram,
 13} from '../../../core/application/execWithSecrets/CommandLineFormatter.js';
 14import { ValidateMapFileCommand } from '../../../core/application/validateMapFile/ValidateMapFileCommand.js';
 15import type { ValidateMapFileCommandHandler } from '../../../core/application/validateMapFile/ValidateMapFileCommandHand
 16import { ValidateRemoteKeysCommand } from '../../../core/application/validateRemoteKeys/ValidateRemoteKeysCommand.js';
 17import type { ValidateRemoteKeysCommandHandler } from '../../../core/application/validateRemoteKeys/ValidateRemoteKeysCo
 18import type { CliOptions } from '../../../core/domain/CliOptions.js';
 19import {
 20  EXEC_COMMAND_NOT_EXECUTABLE,
 21  EXEC_COMMAND_NOT_FOUND,
 22  EXEC_ENVILDER_FAILURE,
 23} from '../../../core/domain/ExecExitCode.js';
 24import {
 25  CommandNotExecutableError,
 26  CommandNotFoundError,
 27  InvalidArgumentError,
 28} from '../../../core/domain/errors/DomainErrors.js';
 29import type { MapFileConfig } from '../../../core/domain/MapFileConfig.js';
 30import { resolveMapFileTarget } from '../../../core/domain/MapFileUpdate.js';
 31import { OperationMode } from '../../../core/domain/OperationMode.js';
 32import type { ILogger } from '../../../core/domain/ports/ILogger.js';
 33import type { ISecretMasker } from '../../../core/domain/ports/ISecretMasker.js';
 34import {
 35  describeSecretExistenceStatus,
 36  type RemoteKeyCheck,
 37  summarizeFailures,
 38} from '../../../core/domain/SecretExistence.js';
 39import { GitHubActionsLogger } from '../../../core/infrastructure/github/GitHubActionsLogger.js';
 40import { GitHubActionsSecretMasker } from '../../../core/infrastructure/github/GitHubActionsSecretMasker.js';
 41import { NoOpSecretMasker } from '../../../core/infrastructure/github/NoOpSecretMasker.js';
 42import { StderrConsoleLogger } from '../../../core/infrastructure/logger/StderrConsoleLogger.js';
 43import { PackageVersionReader } from '../../../core/infrastructure/package/PackageVersionReader.js';
 44import { readMapFileConfig } from '../../../core/infrastructure/variableStore/FileVariableStore.js';
 45import { TYPES } from '../../../core/types.js';
 46import { SUPPORTED_PROVIDERS } from '../../shared/ContainerConfiguration.js';
 47import { presentError } from '../errors/CliErrorPresenter.js';
 48import {
 49  type KnownOption,
 50  missingArgumentMessage,
 51  redactParseError,
 52} from '../errors/ParseErrorRedactor.js';
 53import { SilentExitError } from '../errors/SilentExitError.js';
 54import {
 55  createConfirm,
 56  createRunLogin,
 57  executeWithSsoRecovery,
 58} from '../recovery/SsoLoginRecovery.js';
 59import { Startup } from '../Startup.js';
 60
 561const DEFAULT_MAP_FILE = 'envilder.json';
 562const DEFAULT_ENV_FILE = '.env';
 563const EXEC_USAGE = 'envilder exec [options] [--] <command> [args...]';
 64
 65type ValidateOptions = {
 66  offline?: boolean;
 67  map?: string;
 68  profile?: string;
 69  provider?: string;
 70  vaultUrl?: string;
 71};
 72
 73type ExecOptions = {
 74  map?: string;
 75  profile?: string;
 76  provider?: string;
 77  vaultUrl?: string;
 78  envfile?: string;
 79  push?: boolean;
 80  key?: string;
 81  value?: string;
 82  secretPath?: string;
 83  ssmPath?: string;
 84};
 85
 86let serviceProvider: Container;
 87
 88async function executeCommand(options: CliOptions): Promise<void> {
 2289  const commandHandler = serviceProvider.get<DispatchActionCommandHandler>(
 90    TYPES.DispatchActionCommandHandler,
 91  );
 92
 2293  const command = DispatchActionCommand.fromCliOptions(options);
 2294  await commandHandler.handleCommand(command);
 95}
 96
 97export async function main() {
 18398  const program = new Command();
 18399  const version = await readPackageVersion();
 100
 183101  const banner = `
 102  ${pc.green('███████╗')}${pc.cyan('███╗   ██╗')}${pc.magenta('██╗   ██╗')}${pc.yellow('██╗')}${pc.red('██╗     ')}${pc.
 103  ${pc.green('██╔════╝')}${pc.cyan('████╗  ██║')}${pc.magenta('██║   ██║')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 104  ${pc.green('█████╗  ')}${pc.cyan('██╔██╗ ██║')}${pc.magenta('██║   ██║')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 105  ${pc.green('██╔══╝  ')}${pc.cyan('██║╚██╗██║')}${pc.magenta('╚██╗ ██╔╝')}${pc.yellow('██║')}${pc.red('██║     ')}${pc.
 106  ${pc.green('███████╗')}${pc.cyan('██║ ╚████║')}${pc.magenta(' ╚████╔╝ ')}${pc.yellow('██║')}${pc.red('███████╗')}${pc.
 107  ${pc.green('╚══════╝')}${pc.cyan('╚═╝  ╚═══╝')}${pc.magenta('  ╚═══╝  ')}${pc.yellow('╚═╝')}${pc.red('╚══════╝')}${pc.
 108  ${pc.dim('Your secrets, one command away')}          ${pc.dim('aws & azure')}
 109
 110  ${pc.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
 111  ${pc.green('WORLD 1-1')} ${pc.dim('— SELECT YOUR MISSION')}
 112  ${pc.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}
 113
 114  ${pc.green('>')} ${pc.bold('Generate a .env file')}  ${pc.dim('(pull secrets from the cloud)')}
 115    ${pc.cyan('envilder')}  ${pc.dim('# uses envilder.json → .env by default')}
 116
 117  ${pc.magenta('>')} ${pc.bold('Sync .env back to cloud')}  ${pc.dim('(push secrets up)')}
 118    ${pc.cyan('envilder --push')}  ${pc.dim('# uses envilder.json + .env by default')}
 119
 120  ${pc.red('>')} ${pc.bold('Push a single secret')}  ${pc.dim('(also records it in envilder.json)')}
 121    ${pc.cyan('envilder --key=API_KEY --value=s3cret --secret-path=/my/path')}
 122    ${pc.cyan('envilder --key=API_KEY --value=s3cret --secret-path=/my/path --no-map-update')}  ${pc.dim('# push only')}
 123
 124  ${pc.blue('>')} ${pc.bold('Use Azure Key Vault')}
 125    ${pc.cyan('envilder --provider=azure --vault-url=https://my-vault.vault.azure.net')}
 126
 127  ${pc.yellow('>')} ${pc.bold('Run a command with secrets')}  ${pc.dim('(nothing written to disk)')}
 128    ${pc.cyan('envilder exec -- npm run dev')}  ${pc.dim('# see: envilder exec --help')}
 129
 130  ${pc.green('>')} ${pc.bold('Check the map file and its secrets')}  ${pc.dim('(no value is read)')}
 131    ${pc.cyan('envilder validate')}  ${pc.dim('# see: envilder validate --help')}
 132`;
 133
 183134  program
 135    .name('envilder')
 136    .description(banner)
 137    .version(version)
 138    .enablePositionalOptions()
 139    .helpCommand(false)
 140    // Inherited by `exec`, which is created after this call.
 141    .configureOutput({
 142      // Parse errors can quote secrets typed by the user (#554).
 143      outputError: (message, write) =>
 13144        write(redactParseError(message, knownOptionsOf(program))),
 145    })
 146    .option(
 147      '--map <path>',
 148      `Path to the JSON file with environment variable mapping (default: ${DEFAULT_MAP_FILE}); a single-secret push crea
 149    )
 150    .option(
 151      '--envfile <path>',
 152      `Path to the .env file to be generated or imported (default: ${DEFAULT_ENV_FILE})`,
 153    )
 154    .option('--profile <name>', 'AWS CLI profile to use (optional)')
 155    .option(
 156      '--provider <name>',
 157      'Cloud provider to use: aws or azure (default: aws)',
 158    )
 159    .option(
 160      '--vault-url <url>',
 161      'Azure Key Vault URL (overrides $config.vaultUrl in map file)',
 162    )
 163    .option('--push', 'Push a map-file-backed .env file to the cloud provider')
 164    .option(
 165      '--key <name>',
 166      'Environment variable name; with --value and --secret-path, performs a single-secret push (no --push required)',
 167    )
 168    .option(
 169      '--value <value>',
 170      'Secret value; with --key and --secret-path, performs a single-secret push (no --push required)',
 171    )
 172    .option(
 173      '--secret-path <path>',
 174      'Cloud secret path; with --key and --value, performs a single-secret push (no --push required)',
 175    )
 176    .option(
 177      '--ssm-path <path>',
 178      '[DEPRECATED: use --secret-path] Alias for --secret-path',
 179    )
 180    .option(
 181      '--no-map-update',
 182      `Single-secret push only: do not read, create or update the map file (by default the mapping is recorded in ${DEFA
 183    )
 184    .hook('preAction', (thisCommand, actionCommand) => {
 185      // Root options placed before a subcommand are rejected by it instead.
 149186      if (actionCommand !== thisCommand) {
 102187        return;
 188      }
 47189      const opts = thisCommand.opts();
 190      // Alias on presence, not truthiness: an explicitly empty --ssm-path must
 191      // reach the single-secret guard below instead of vanishing silently.
 47192      if (opts.ssmPath !== undefined) {
 4193        console.warn(
 194          pc.yellow(
 195            '⚠️  --ssm-path is deprecated and will be removed in a future release. Use --secret-path instead.',
 196          ),
 197        );
 4198        if (opts.secretPath === undefined) {
 4199          thisCommand.setOptionValue('secretPath', opts.ssmPath);
 200        }
 201      }
 202    })
 203    .action(
 204      async ({
 205        provider,
 206        vaultUrl,
 207        ...options
 208      }: CliOptions & { provider?: string; vaultUrl?: string }) => {
 47209        const singleSecretOptions = [
 210          options.key,
 211          options.value,
 212          options.secretPath,
 213        ];
 214        const hasPartialSingleSecretOptions =
 81215          singleSecretOptions.some((option) => option !== undefined) &&
 82216          !singleSecretOptions.every((option) => Boolean(option));
 47217        if (hasPartialSingleSecretOptions) {
 22218          throw new InvalidArgumentError(
 219            'Single-secret push requires non-empty --key, --value, and --secret-path.',
 220          );
 221        }
 222
 25223        const mode = DispatchActionCommand.determineOperationMode(options);
 25224        const isPushSingle = mode === OperationMode.PUSH_SINGLE;
 25225        const resolvedMap = isPushSingle
 226          ? resolveSingleSecretMapFile(options.map, options.mapUpdate)
 227          : resolveMapFile(options.map);
 47228        const resolvedEnvfile = resolveEnvfile(options.envfile);
 229
 230        // A single-secret push may name a map file that does not exist yet:
 231        // it is created once the push options are validated.
 232        const mapFileExists =
 47233          resolvedMap !== undefined &&
 234          (!isPushSingle || existsSync(resolvedMap));
 235        const fileConfig =
 47236          resolvedMap !== undefined && mapFileExists
 237            ? await readMapFileConfig(resolvedMap)
 238            : {};
 239
 47240        const config: MapFileConfig = {
 241          ...fileConfig,
 242          ...(provider && { provider }),
 243          ...(vaultUrl && { vaultUrl }),
 244          ...(options.profile && { profile: options.profile }),
 245        };
 246
 47247        const resolvedOptions: CliOptions = {
 248          ...options,
 249          map: resolvedMap,
 250          envfile: resolvedEnvfile,
 251          ...(isPushSingle &&
 252            resolvedMap !== undefined && {
 253              mapTarget: resolveMapFileTarget(config),
 254            }),
 255        };
 256
 47257        serviceProvider = Startup.build()
 258          .configureServices()
 259          .configureInfrastructure(config)
 260          .create();
 261
 47262        if (isPushSingle) {
 13263          const logger = serviceProvider.get<ILogger>(TYPES.ILogger);
 13264          const providerName = config.provider?.toLowerCase() || 'aws';
 265          const origin =
 13266            providerName === 'azure' ? vaultOrigin(config.vaultUrl) : undefined;
 13267          const vaultInfo = origin ? `, vault=${origin}` : '';
 13268          const source = mapFileExists
 269            ? `configuration from ${resolvedMap}`
 270            : 'configuration';
 13271          logger.info(`Using ${source}: provider=${providerName}${vaultInfo}`);
 272        }
 273
 22274        await executeWithSsoRecovery(() => executeCommand(resolvedOptions));
 275      },
 276    );
 277
 183278  registerExecCommand(program);
 183279  registerValidateCommand(program);
 183280  rejectDashValues(program, 1);
 281
 183282  try {
 183283    await program.parseAsync(process.argv);
 284  } catch (error) {
 131285    if (error instanceof DashValueError) {
 286      // Same plain format as the parser's own errors.
 12287      process.stderr.write(missingArgumentMessage(error.flags));
 12288      throw new SilentExitError(error.exitCode);
 289    }
 290    // Only the exec subcommand overrides Commander's exit, so a parse error
 291    // reaching this point belongs to exec and uses its exit code. Commander
 292    // has already printed the message. `validate` fails with 1, which
 293    // Commander's own exit already uses.
 119294    if (error instanceof CommanderError) {
 8295      if (error.exitCode === 0) {
 0296        return;
 297      }
 8298      throw new SilentExitError(EXEC_ENVILDER_FAILURE);
 299    }
 111300    throw error;
 301  }
 302}
 303
 304/**
 305 * The options a command registers, flagged with whether `--help` lists them,
 306 * so parse-error suggestions only ever name visible options of the command
 307 * being parsed.
 308 */
 309function knownOptionsOf(command: Command): KnownOption[] {
 21310  const visible = command.createHelp().visibleOptions(command);
 21311  const all = [...command.options, ...visible];
 21312  const byFlags = new Map<string, KnownOption>();
 21313  for (const option of all) {
 445314    byFlags.set(option.flags, {
 315      flags: option.flags,
 316      long: option.long,
 317      visible: visible.includes(option),
 318    });
 319  }
 21320  return [...byFlags.values()];
 321}
 322
 323/**
 324 * Thrown by the option parser when a path or name option would take the next
 325 * option as its value, for example `--map --key=...`.
 326 */
 327class DashValueError extends Error {
 328  constructor(
 12329    readonly flags: string,
 12330    readonly exitCode: number,
 331  ) {
 12332    super(`option '${flags}' argument missing`);
 333  }
 334}
 335
 5336const DASH_GUARDED_OPTIONS = new Set([
 337  '--map',
 338  '--envfile',
 339  '--profile',
 340  '--provider',
 341  '--vault-url',
 342  '--key',
 343  '--secret-path',
 344  '--ssm-path',
 345]);
 346
 347/**
 348 * Path and name options never accept a value that starts with `-`: such a
 349 * value is almost always the next option, and accepting it would silently
 350 * swallow that option (and later echo it). `--value` is exempt because a
 351 * secret may legitimately start with `-`. One parser serves the root command
 352 * and `exec`.
 353 */
 354function rejectDashValues(command: Command, exitCode: number): void {
 549355  for (const option of command.options) {
 4941356    if (!option.required || !DASH_GUARDED_OPTIONS.has(option.long ?? '')) {
 1281357      continue;
 358    }
 3660359    option.argParser((value: string) => {
 187360      if (value.startsWith('-')) {
 12361        throw new DashValueError(option.flags, exitCode);
 362      }
 175363      return value;
 364    });
 365  }
 366}
 367
 368function registerExecCommand(program: Command): void {
 183369  const exec = program
 370    .command('exec')
 371    .summary('Run a command with the resolved secrets in its environment')
 372    .description(
 373      'Resolve every secret declared in the map file and run <command> with ' +
 374        'those values in its environment, without writing anything to disk.\n\n' +
 375        'Envilder options come first. The command starts at the first ' +
 376        'argument that is not an Envilder option (or right after "--"), and ' +
 377        'everything from there on is passed to the command verbatim.',
 378    )
 379    .usage('[options] [--] <command> [args...]')
 380    .argument('[command]', 'Command to run')
 381    .argument('[args...]', 'Arguments passed to the command verbatim')
 382    .option(
 383      '--map <path>',
 384      `Path to the JSON file with environment variable mapping (default: ${DEFAULT_MAP_FILE})`,
 385    )
 386    .option('--profile <name>', 'AWS CLI profile to use (optional)')
 387    .option(
 388      '--provider <name>',
 389      'Cloud provider to use: aws or azure (default: aws)',
 390    )
 391    .option(
 392      '--vault-url <url>',
 393      'Azure Key Vault URL (overrides $config.vaultUrl in map file)',
 394    )
 395    // Pull and push options are declared only to reject them with a clear
 396    // message instead of a generic "unknown option".
 397    .addOption(new Option('--envfile <path>').hideHelp())
 398    .addOption(new Option('--push').hideHelp())
 399    .addOption(new Option('--key <name>').hideHelp())
 400    .addOption(new Option('--value <value>').hideHelp())
 401    .addOption(new Option('--secret-path <path>').hideHelp())
 402    .addOption(new Option('--ssm-path <path>').hideHelp())
 403    .addHelpText('after', EXEC_HELP)
 404    .passThroughOptions()
 405    .exitOverride()
 406    .action(
 407      async (
 408        commandName: string | undefined,
 409        childArgs: string[] | undefined,
 410        options: ExecOptions,
 411        execCommand: Command,
 412      ) => {
 413        // Exec's own usage errors come first: their messages never echo
 414        // option values, unlike the root-option suggestion.
 76415        const usageError = findExecUsageError(commandName, options);
 76416        if (usageError !== undefined || commandName === undefined) {
 15417          console.error(pc.red(`error: ${usageError}`));
 15418          throw new SilentExitError(EXEC_ENVILDER_FAILURE);
 419        }
 61420        rejectRootOptionsBeforeExec(
 421          (execCommand.parent?.opts() ?? {}) as ExecOptions,
 422          options,
 423          commandName,
 424          childArgs ?? [],
 425        );
 76426        await runExec(commandName, childArgs ?? [], options);
 427      },
 428    );
 429
 430  // Suggestions for exec's parse errors come from exec's own options.
 183431  exec.configureOutput({
 432    outputError: (message, write) =>
 8433      write(redactParseError(message, knownOptionsOf(exec))),
 434  });
 183435  rejectDashValues(exec, EXEC_ENVILDER_FAILURE);
 436}
 437
 438function registerValidateCommand(program: Command): void {
 183439  const validate = program
 440    .command('validate')
 441    .summary('Check the map file before pulling, pushing or running a command')
 442    .description(
 443      'Check that the map file is valid JSON, matches the map file schema, ' +
 444        'and resolves to a coherent provider configuration (flags take ' +
 445        'precedence over $config), then check that every secret it declares ' +
 446        'exists in the provider with the current credentials. Every problem ' +
 447        'is reported with the key that causes it. Secret values are never ' +
 448        'read or printed.\n\n' +
 449        'With --offline nothing but the map file is read: no provider is ' +
 450        'called and no credentials are needed.',
 451    )
 452    .option(
 453      '--offline',
 454      'Validate the map file locally without calling the provider',
 455    )
 456    .option(
 457      '--map <path>',
 458      `Path to the JSON file with environment variable mapping (default: ${DEFAULT_MAP_FILE})`,
 459    )
 460    .option('--profile <name>', 'AWS CLI profile to use (optional)')
 461    .option(
 462      '--provider <name>',
 463      'Cloud provider to use: aws or azure (default: aws)',
 464    )
 465    .option(
 466      '--vault-url <url>',
 467      'Azure Key Vault URL (overrides $config.vaultUrl in map file)',
 468    )
 469    .addHelpText('after', VALIDATE_HELP)
 470    .action(async (options: ValidateOptions, validateCommand: Command) => {
 26471      rejectRootOptionsBeforeValidate(
 472        (validateCommand.parent?.opts() ?? {}) as ExecOptions,
 473      );
 26474      if (options.offline) {
 10475        await runOfflineValidation(options);
 5476        return;
 477      }
 15478      await runRemoteValidation(options);
 479    });
 480
 183481  validate.configureOutput({
 482    outputError: (message, write) =>
 0483      write(redactParseError(message, knownOptionsOf(validate))),
 484  });
 183485  rejectDashValues(validate, 1);
 486}
 487
 5488const VALIDATE_HELP = `
 489Examples:
 490  envilder validate
 491  envilder validate --map=config/envilder.json --profile=dev
 492  envilder validate --provider=azure --vault-url=https://my-vault.vault.azure.net
 493  envilder validate --offline
 494
 495Exit codes:
 496  0  the map file is valid and every declared secret exists
 497  1  the map file or the options are not valid, a secret is missing or
 498     cannot be checked, or the credentials are missing or expired
 499
 500See docs/validate-command.md.`;
 501
 502/**
 503 * Root options written before `validate` are parsed by the root command and
 504 * never reach it. Reject them by name instead of validating something else
 505 * than what was asked; values are not echoed, since `--value` can be a secret.
 506 */
 507function rejectRootOptionsBeforeValidate(rootOptions: ExecOptions): void {
 26508  const misplaced = [
 509    ...EXEC_COMPATIBLE_ROOT_OPTIONS,
 510    ...EXEC_INCOMPATIBLE_ROOT_OPTIONS,
 260511  ].filter(([name]) => rootOptions[name] !== undefined);
 26512  if (misplaced.length === 0) {
 25513    return;
 514  }
 1515  const flags = misplaced.map(([, flag]) => flag).join(', ');
 1516  console.error(
 517    pc.red(`error: Put envilder options after "validate" (found ${flags}).`),
 518  );
 1519  throw new SilentExitError(1);
 520}
 521
 522async function runOfflineValidation(options: ValidateOptions): Promise<void> {
 10523  await validateMapFileOffline(options);
 5524  console.log(
 525    pc.dim(
 526      '  Remote validation skipped (--offline): no provider was called, so ' +
 527        'the secrets were not checked.',
 528    ),
 529  );
 530}
 531
 532/**
 533 * Checks the map file without calling any provider. Prints the result and
 534 * returns the map file path when it is valid; reports every violation and
 535 * exits with 1 otherwise.
 536 */
 537async function validateMapFileOffline(
 538  options: ValidateOptions,
 539): Promise<string> {
 25540  const mapPath = resolveMapFile(options.map);
 25541  const container = Startup.build().configureOfflineValidation().create();
 25542  const handler = container.get<ValidateMapFileCommandHandler>(
 543    TYPES.ValidateMapFileCommandHandler,
 544  );
 25545  const violations = await handler.handle(
 546    ValidateMapFileCommand.create(
 547      mapPath,
 548      {
 549        provider: options.provider,
 550        profile: options.profile,
 551        vaultUrl: options.vaultUrl,
 552      },
 553      SUPPORTED_PROVIDERS,
 554    ),
 555  );
 556
 24557  if (violations.length > 0) {
 558    const count =
 6559      violations.length === 1 ? '1 problem' : `${violations.length} problems`;
 6560    console.error(pc.red(`✗ Map file invalid: ${mapPath} (${count})`));
 6561    for (const violation of violations) {
 9562      console.error(`  ${pc.red('•')} ${violation.message}`);
 563    }
 6564    throw new SilentExitError(1);
 565  }
 566
 18567  console.log(pc.green(`✓ Map file valid: ${mapPath}`));
 18568  return mapPath;
 569}
 570
 571/**
 572 * Validates the map file offline first, so an invalid file never reaches the
 573 * provider, then checks that every declared secret exists. Lists each
 574 * variable with its path and status; values are never read.
 575 */
 576async function runRemoteValidation(options: ValidateOptions): Promise<void> {
 15577  const mapPath = await validateMapFileOffline(options);
 13578  const fileConfig = await readMapFileConfig(mapPath);
 13579  const config: MapFileConfig = {
 580    ...fileConfig,
 581    ...(options.provider && { provider: options.provider }),
 582    ...(options.vaultUrl && { vaultUrl: options.vaultUrl }),
 583    ...(options.profile && { profile: options.profile }),
 584  };
 585
 15586  const container = Startup.build()
 587    .configureRemoteValidation()
 588    .configureInfrastructure(config)
 589    .create();
 15590  const handler = container.get<ValidateRemoteKeysCommandHandler>(
 591    TYPES.ValidateRemoteKeysCommandHandler,
 592  );
 593
 15594  let checks: RemoteKeyCheck[] = [];
 15595  await executeWithSsoRecovery(async () => {
 13596    checks = await handler.handle(ValidateRemoteKeysCommand.create(mapPath));
 597  });
 598
 11599  printRemoteKeyChecks(checks, describeSecretSource(config));
 11600  const failures = summarizeFailures(checks);
 11601  if (failures !== '') {
 7602    console.error(pc.red(`✗ Validation failed: ${failures}`));
 7603    throw new SilentExitError(1);
 604  }
 605}
 606
 607function printRemoteKeyChecks(
 608  checks: readonly RemoteKeyCheck[],
 609  source: string,
 610): void {
 11611  const count = checks.length === 1 ? '1 secret' : `${checks.length} secrets`;
 11612  console.log(pc.bold(`Checking ${count} in ${source}`));
 21613  const variableWidth = Math.max(0, ...checks.map((c) => c.variable.length));
 21614  const pathWidth = Math.max(0, ...checks.map((c) => c.path.length));
 11615  for (const check of checks) {
 21616    const exists = check.status === 'exists';
 21617    const status = describeSecretExistenceStatus(check.status);
 618    const reason =
 21619      check.reason === undefined ? '' : pc.dim(` (${check.reason})`);
 21620    console.log(
 621      `  ${exists ? pc.green('✓') : pc.red('✗')} ` +
 622        `${pc.bold(check.variable.padEnd(variableWidth))}  ` +
 623        `${pc.dim(check.path.padEnd(pathWidth))}  ` +
 624        `${exists ? pc.green(status) : pc.red(status)}${reason}`,
 625    );
 626  }
 11627  if (summarizeFailures(checks) === '') {
 4628    console.log(
 629      pc.green(
 630        checks.length === 1 ? '✓ 1 secret exists' : `✓ All ${count} exist`,
 631      ),
 632    );
 633  }
 634}
 635
 5636const EXEC_HELP = `
 637Examples:
 638  envilder exec -- npm run dev
 639  envilder exec --map=config/envilder.json node server.js
 640  envilder exec --provider=azure --vault-url=https://my-vault.vault.azure.net -- dotnet run
 641  envilder exec -- -command-starting-with-dash
 642
 643Exit codes:
 644  n      the command's own exit code
 645  128+n  the command was killed by signal n
 646  125    Envilder failed before starting the command (bad options, missing
 647         command, unresolved secrets, credentials)
 648  126    the command exists but cannot be executed
 649  127    the command was not found
 650
 651Exec mode is fail-closed: the command never starts when a secret is missing,
 652empty or unreadable. Mapped values override inherited variables. No shell is
 653involved; use "envilder exec -- sh -c '...'" for shell features.
 654The command and everything it starts can read the secrets: exec mode is not
 655a sandbox. See docs/exec-command.md.`;
 656
 657const EXEC_COMPATIBLE_ROOT_OPTIONS: ReadonlyArray<[keyof ExecOptions, string]> =
 5658  [
 659    ['map', '--map'],
 660    ['profile', '--profile'],
 661    ['provider', '--provider'],
 662    ['vaultUrl', '--vault-url'],
 663  ];
 664const EXEC_INCOMPATIBLE_ROOT_OPTIONS: ReadonlyArray<
 665  [keyof ExecOptions, string]
 5666> = [
 667  ['envfile', '--envfile'],
 668  ['push', '--push'],
 669  ['key', '--key'],
 670  ['value', '--value'],
 671  ['secretPath', '--secret-path'],
 672  ['ssmPath', '--ssm-path'],
 673];
 674/**
 675 * Root options written before `exec` are parsed by the root command, which
 676 * exec never reads. Reject them instead of silently ignoring them. Options
 677 * exec supports are shown moved after `exec`; options it does not support
 678 * are named only, because their values (for example `--value`) can be
 679 * secrets. The command is shown by program name only: its arguments can
 680 * carry secrets too.
 681 */
 682function rejectRootOptionsBeforeExec(
 683  rootOptions: ExecOptions,
 684  execOptions: ExecOptions,
 685  commandName: string,
 686  childArgs: string[],
 687): void {
 61688  const isSetIn =
 129689    (options: ExecOptions) =>
 690    ([name]: [keyof ExecOptions, string]) =>
 638691      options[name] !== undefined;
 61692  const compatible = EXEC_COMPATIBLE_ROOT_OPTIONS.filter(isSetIn(rootOptions));
 61693  const incompatible = EXEC_INCOMPATIBLE_ROOT_OPTIONS.filter(
 694    isSetIn(rootOptions),
 695  );
 61696  if (compatible.length === 0 && incompatible.length === 0) {
 40697    return;
 698  }
 699
 21700  const lines: string[] = [];
 21701  if (compatible.length > 0) {
 7702    const optionFor =
 14703      (options: ExecOptions) =>
 704      ([name, flag]: [keyof ExecOptions, string]) =>
 7705        formatArgument(`${flag}=${options[name]}`);
 7706    const suggestion = [
 707      'envilder exec',
 708      ...compatible.map(optionFor(rootOptions)),
 709      ...EXEC_COMPATIBLE_ROOT_OPTIONS.filter(isSetIn(execOptions)).map(
 710        optionFor(execOptions),
 711      ),
 712      '--',
 713      formatProgram(commandName, childArgs),
 714    ].join(' ');
 7715    lines.push(`error: Put envilder options after "exec": ${suggestion}`);
 716  } else {
 14717    lines.push('error: Put envilder options after "exec".');
 718  }
 21719  if (incompatible.length > 0) {
 14720    const flags = incompatible.map(([, flag]) => flag).join(', ');
 14721    lines.push(`${flags} cannot be used with exec.`);
 722  }
 21723  console.error(pc.red(lines.join('\n')));
 21724  throw new SilentExitError(EXEC_ENVILDER_FAILURE);
 725}
 726
 727async function runExec(
 728  commandName: string,
 729  childArgs: string[],
 730  options: ExecOptions,
 731): Promise<void> {
 732  let exitCode: number;
 40733  try {
 40734    exitCode = await resolveSecretsAndRun(commandName, childArgs, options);
 735  } catch (error) {
 17736    throw new SilentExitError(reportExecFailure(error));
 737  }
 23738  process.exitCode = exitCode;
 739}
 740
 741function findExecUsageError(
 742  commandName: string | undefined,
 743  options: ExecOptions,
 744): string | undefined {
 76745  if (options.envfile !== undefined) {
 2746    return '--envfile cannot be used with exec: exec mode never reads or writes a .env file.';
 747  }
 74748  if (options.push !== undefined) {
 1749    return '--push cannot be used with exec.';
 750  }
 73751  const singleSecretOptions = [
 752    options.key,
 753    options.value,
 754    options.secretPath,
 755    options.ssmPath,
 756  ];
 278757  if (singleSecretOptions.some((option) => option !== undefined)) {
 9758    return 'Single-secret push options cannot be used with exec.';
 759  }
 64760  if (!commandName) {
 3761    return `Missing command. Usage: ${EXEC_USAGE}`;
 762  }
 61763  return undefined;
 764}
 765
 766async function resolveSecretsAndRun(
 767  commandName: string,
 768  childArgs: string[],
 769  options: ExecOptions,
 770): Promise<number> {
 40771  const mapPath = resolveMapFile(options.map);
 40772  const fileConfig = await readMapFileConfig(mapPath);
 38773  const config: MapFileConfig = {
 774    ...fileConfig,
 775    ...(options.provider && { provider: options.provider }),
 776    ...(options.vaultUrl && { vaultUrl: options.vaultUrl }),
 777    ...(options.profile && { profile: options.profile }),
 778  };
 779
 40780  const container = Startup.build()
 781    .configureExec(createExecSecretMasker(), createExecLogger())
 782    .configureServices()
 783    .configureInfrastructure(config)
 784    .create();
 40785  const dispatcher = container.get<DispatchActionCommandHandler>(
 786    TYPES.DispatchActionCommandHandler,
 787  );
 40788  const command = DispatchActionCommand.forExec(
 789    mapPath,
 790    commandName,
 791    childArgs,
 792    describeSecretSource(config),
 793  );
 794
 795  // A write error on stdout or stderr (for example EPIPE on a mask
 796  // registration) also reaches the write callback, so the masker's flush
 797  // rejects and the child never starts. Without a listener the 'error' event
 798  // would crash Envilder.
 799  // The listener is never removed: the event can be emitted after exec has
 800  // already failed (for example when resolution rejects after some values
 801  // were masked), and exec is the last thing this process does, so keeping
 802  // it has no effect on pull or push.
 40803  for (const stream of [process.stdout, process.stderr]) {
 76804    if (!stream.listeners('error').includes(ignoreStreamError)) {
 4805      stream.on('error', ignoreStreamError);
 806    }
 807  }
 38808  return dispatchExec(dispatcher, command);
 809}
 810
 811function ignoreStreamError(): void {}
 812
 813async function dispatchExec(
 814  dispatcher: DispatchActionCommandHandler,
 815  command: DispatchActionCommand,
 816): Promise<number> {
 817  // An SSO session error can only be raised while resolving secrets, before
 818  // the child is spawned, so retrying the dispatch never starts it twice.
 819  let exitCode: number | undefined;
 38820  await executeWithSsoRecovery(
 821    async () => {
 41822      exitCode = await dispatcher.handleCommand(command);
 823    },
 824    {
 825      // Exec prompts on stderr, so stdout may be redirected for the child.
 826      isInteractive: () =>
 4827        Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY),
 828      confirm: createConfirm(process.stderr),
 829      runLogin: createRunLogin({ stdout: 'stderr' }),
 830    },
 831  );
 23832  if (exitCode === undefined) {
 0833    throw new InvalidArgumentError('Secrets could not be resolved.');
 834  }
 23835  return exitCode;
 836}
 837
 838/**
 839 * Writes through the exec logger, which may fail before the container
 840 * exists (reading the map file), so input-derived text in the message is
 841 * escaped like any other exec output under GitHub Actions.
 842 */
 843function reportExecFailure(error: unknown): number {
 17844  if (error instanceof SilentExitError) {
 845    // SSO recovery was declined or failed and already explained why.
 2846    return EXEC_ENVILDER_FAILURE;
 847  }
 15848  const logger = createExecLogger();
 15849  const logLines = (text: string) => {
 15850    for (const line of text.split('\n')) {
 73851      logger.error(line);
 852    }
 853  };
 15854  if (error instanceof CommandNotFoundError) {
 1855    logLines(pc.red(error.message));
 1856    return EXEC_COMMAND_NOT_FOUND;
 857  }
 14858  if (error instanceof CommandNotExecutableError) {
 1859    logLines(pc.red(error.message));
 1860    return EXEC_COMMAND_NOT_EXECUTABLE;
 861  }
 13862  logLines(presentError(error));
 13863  return EXEC_ENVILDER_FAILURE;
 864}
 865
 866function describeSecretSource(config: MapFileConfig): string {
 49867  const provider = config.provider?.toLowerCase() || 'aws';
 49868  if (provider === 'aws' && config.profile) {
 1869    return `${provider}, profile=${config.profile}`;
 870  }
 48871  if (provider === 'azure' && config.vaultUrl) {
 3872    try {
 3873      return `${provider}, vault=${new URL(config.vaultUrl).host}`;
 874    } catch {
 0875      return provider;
 876    }
 877  }
 45878  return provider;
 879}
 880
 881/**
 882 * Exec writes mask registrations to stderr: the runner reads workflow
 883 * commands from both streams, and stdout belongs to the child.
 884 */
 885function createExecSecretMasker(): ISecretMasker {
 38886  return process.env.GITHUB_ACTIONS === 'true'
 887    ? new GitHubActionsSecretMasker({ stream: process.stderr })
 888    : new NoOpSecretMasker();
 889}
 890
 891/**
 892 * Exec logs to stderr, keeping stdout for the child. Under GitHub Actions
 893 * each message stays one inert line, so input-derived text cannot issue
 894 * workflow commands ahead of the mask registrations.
 895 */
 896function createExecLogger(): ILogger {
 53897  return process.env.GITHUB_ACTIONS === 'true'
 898    ? new GitHubActionsLogger(new StderrConsoleLogger())
 899    : new StderrConsoleLogger();
 900}
 901
 902/**
 903 * The vault origin to log, never the full URL, which can carry credentials.
 904 * `undefined` when the URL does not parse: the Azure client reports that.
 905 */
 906function vaultOrigin(vaultUrl: string | undefined): string | undefined {
 6907  if (!vaultUrl) {
 0908    return undefined;
 909  }
 6910  try {
 6911    return new URL(vaultUrl).origin;
 912  } catch {
 1913    return undefined;
 914  }
 915}
 916
 917/**
 918 * The map file a single-secret push records its mapping in: `--map`, or the
 919 * default in the current directory, which may not exist yet. `undefined` when
 920 * `--no-map-update` turns the map file off.
 921 */
 922function resolveSingleSecretMapFile(
 923  mapOption: string | undefined,
 924  mapUpdate: boolean | undefined,
 925): string | undefined {
 14926  if (mapUpdate === false) {
 2927    return undefined;
 928  }
 12929  return mapOption === undefined ? DEFAULT_MAP_FILE : resolveMapFile(mapOption);
 930}
 931
 932function resolveMapFile(mapOption: string | undefined): string {
 81933  if (mapOption !== undefined) {
 39934    const trimmed = mapOption.trim();
 39935    if (trimmed.length === 0) {
 2936      throw new InvalidArgumentError(
 937        'Invalid --map value: path must not be empty.',
 938      );
 939    }
 37940    return trimmed;
 941  }
 942
 42943  const defaultPath = join(process.cwd(), DEFAULT_MAP_FILE);
 42944  if (existsSync(defaultPath)) {
 39945    return DEFAULT_MAP_FILE;
 946  }
 947
 3948  throw new InvalidArgumentError(
 949    `No map file found. Provide --map or create ${DEFAULT_MAP_FILE} in the current directory.`,
 950  );
 951}
 952
 953function resolveEnvfile(envfileOption: string | undefined): string {
 22954  if (envfileOption === undefined) {
 15955    return DEFAULT_ENV_FILE;
 956  }
 957
 7958  const trimmed = envfileOption.trim();
 7959  if (trimmed.length === 0) {
 1960    throw new InvalidArgumentError(
 961      'Invalid --envfile value: path must not be empty.',
 962    );
 963  }
 964
 6965  return trimmed;
 966}
 967
 968function readPackageVersion(): Promise<string> {
 183969  const __filename = fileURLToPath(import.meta.url);
 183970  const __dirname = dirname(__filename);
 183971  const packageJsonPath = join(__dirname, '../../../../../package.json');
 972
 183973  return new PackageVersionReader().getVersion(packageJsonPath);
 974}