| | | 1 | | import 'reflect-metadata'; |
| | | 2 | | import { existsSync } from 'node:fs'; |
| | | 3 | | import { dirname, join } from 'node:path'; |
| | | 4 | | import { fileURLToPath } from 'node:url'; |
| | | 5 | | import { Command, CommanderError, Option } from 'commander'; |
| | | 6 | | import type { Container } from 'inversify'; |
| | | 7 | | import pc from 'picocolors'; |
| | | 8 | | import { DispatchActionCommand } from '../../../core/application/dispatch/DispatchActionCommand.js'; |
| | | 9 | | import type { DispatchActionCommandHandler } from '../../../core/application/dispatch/DispatchActionCommandHandler.js'; |
| | | 10 | | import { |
| | | 11 | | formatArgument, |
| | | 12 | | formatProgram, |
| | | 13 | | } from '../../../core/application/execWithSecrets/CommandLineFormatter.js'; |
| | | 14 | | import { ValidateMapFileCommand } from '../../../core/application/validateMapFile/ValidateMapFileCommand.js'; |
| | | 15 | | import type { ValidateMapFileCommandHandler } from '../../../core/application/validateMapFile/ValidateMapFileCommandHand |
| | | 16 | | import { ValidateRemoteKeysCommand } from '../../../core/application/validateRemoteKeys/ValidateRemoteKeysCommand.js'; |
| | | 17 | | import type { ValidateRemoteKeysCommandHandler } from '../../../core/application/validateRemoteKeys/ValidateRemoteKeysCo |
| | | 18 | | import type { CliOptions } from '../../../core/domain/CliOptions.js'; |
| | | 19 | | import { |
| | | 20 | | EXEC_COMMAND_NOT_EXECUTABLE, |
| | | 21 | | EXEC_COMMAND_NOT_FOUND, |
| | | 22 | | EXEC_ENVILDER_FAILURE, |
| | | 23 | | } from '../../../core/domain/ExecExitCode.js'; |
| | | 24 | | import { |
| | | 25 | | CommandNotExecutableError, |
| | | 26 | | CommandNotFoundError, |
| | | 27 | | InvalidArgumentError, |
| | | 28 | | } from '../../../core/domain/errors/DomainErrors.js'; |
| | | 29 | | import type { MapFileConfig } from '../../../core/domain/MapFileConfig.js'; |
| | | 30 | | import { resolveMapFileTarget } from '../../../core/domain/MapFileUpdate.js'; |
| | | 31 | | import { OperationMode } from '../../../core/domain/OperationMode.js'; |
| | | 32 | | import type { ILogger } from '../../../core/domain/ports/ILogger.js'; |
| | | 33 | | import type { ISecretMasker } from '../../../core/domain/ports/ISecretMasker.js'; |
| | | 34 | | import { |
| | | 35 | | describeSecretExistenceStatus, |
| | | 36 | | type RemoteKeyCheck, |
| | | 37 | | summarizeFailures, |
| | | 38 | | } from '../../../core/domain/SecretExistence.js'; |
| | | 39 | | import { GitHubActionsLogger } from '../../../core/infrastructure/github/GitHubActionsLogger.js'; |
| | | 40 | | import { GitHubActionsSecretMasker } from '../../../core/infrastructure/github/GitHubActionsSecretMasker.js'; |
| | | 41 | | import { NoOpSecretMasker } from '../../../core/infrastructure/github/NoOpSecretMasker.js'; |
| | | 42 | | import { StderrConsoleLogger } from '../../../core/infrastructure/logger/StderrConsoleLogger.js'; |
| | | 43 | | import { PackageVersionReader } from '../../../core/infrastructure/package/PackageVersionReader.js'; |
| | | 44 | | import { readMapFileConfig } from '../../../core/infrastructure/variableStore/FileVariableStore.js'; |
| | | 45 | | import { TYPES } from '../../../core/types.js'; |
| | | 46 | | import { SUPPORTED_PROVIDERS } from '../../shared/ContainerConfiguration.js'; |
| | | 47 | | import { presentError } from '../errors/CliErrorPresenter.js'; |
| | | 48 | | import { |
| | | 49 | | type KnownOption, |
| | | 50 | | missingArgumentMessage, |
| | | 51 | | redactParseError, |
| | | 52 | | } from '../errors/ParseErrorRedactor.js'; |
| | | 53 | | import { SilentExitError } from '../errors/SilentExitError.js'; |
| | | 54 | | import { |
| | | 55 | | createConfirm, |
| | | 56 | | createRunLogin, |
| | | 57 | | executeWithSsoRecovery, |
| | | 58 | | } from '../recovery/SsoLoginRecovery.js'; |
| | | 59 | | import { Startup } from '../Startup.js'; |
| | | 60 | | |
| | 5 | 61 | | const DEFAULT_MAP_FILE = 'envilder.json'; |
| | 5 | 62 | | const DEFAULT_ENV_FILE = '.env'; |
| | 5 | 63 | | const EXEC_USAGE = 'envilder exec [options] [--] <command> [args...]'; |
| | | 64 | | |
| | | 65 | | type ValidateOptions = { |
| | | 66 | | offline?: boolean; |
| | | 67 | | map?: string; |
| | | 68 | | profile?: string; |
| | | 69 | | provider?: string; |
| | | 70 | | vaultUrl?: string; |
| | | 71 | | }; |
| | | 72 | | |
| | | 73 | | type 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 | | |
| | | 86 | | let serviceProvider: Container; |
| | | 87 | | |
| | | 88 | | async function executeCommand(options: CliOptions): Promise<void> { |
| | 22 | 89 | | const commandHandler = serviceProvider.get<DispatchActionCommandHandler>( |
| | | 90 | | TYPES.DispatchActionCommandHandler, |
| | | 91 | | ); |
| | | 92 | | |
| | 22 | 93 | | const command = DispatchActionCommand.fromCliOptions(options); |
| | 22 | 94 | | await commandHandler.handleCommand(command); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | export async function main() { |
| | 183 | 98 | | const program = new Command(); |
| | 183 | 99 | | const version = await readPackageVersion(); |
| | | 100 | | |
| | 183 | 101 | | 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 | | |
| | 183 | 134 | | 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) => |
| | 13 | 144 | | 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. |
| | 149 | 186 | | if (actionCommand !== thisCommand) { |
| | 102 | 187 | | return; |
| | | 188 | | } |
| | 47 | 189 | | 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. |
| | 47 | 192 | | if (opts.ssmPath !== undefined) { |
| | 4 | 193 | | console.warn( |
| | | 194 | | pc.yellow( |
| | | 195 | | '⚠️ --ssm-path is deprecated and will be removed in a future release. Use --secret-path instead.', |
| | | 196 | | ), |
| | | 197 | | ); |
| | 4 | 198 | | if (opts.secretPath === undefined) { |
| | 4 | 199 | | thisCommand.setOptionValue('secretPath', opts.ssmPath); |
| | | 200 | | } |
| | | 201 | | } |
| | | 202 | | }) |
| | | 203 | | .action( |
| | | 204 | | async ({ |
| | | 205 | | provider, |
| | | 206 | | vaultUrl, |
| | | 207 | | ...options |
| | | 208 | | }: CliOptions & { provider?: string; vaultUrl?: string }) => { |
| | 47 | 209 | | const singleSecretOptions = [ |
| | | 210 | | options.key, |
| | | 211 | | options.value, |
| | | 212 | | options.secretPath, |
| | | 213 | | ]; |
| | | 214 | | const hasPartialSingleSecretOptions = |
| | 81 | 215 | | singleSecretOptions.some((option) => option !== undefined) && |
| | 82 | 216 | | !singleSecretOptions.every((option) => Boolean(option)); |
| | 47 | 217 | | if (hasPartialSingleSecretOptions) { |
| | 22 | 218 | | throw new InvalidArgumentError( |
| | | 219 | | 'Single-secret push requires non-empty --key, --value, and --secret-path.', |
| | | 220 | | ); |
| | | 221 | | } |
| | | 222 | | |
| | 25 | 223 | | const mode = DispatchActionCommand.determineOperationMode(options); |
| | 25 | 224 | | const isPushSingle = mode === OperationMode.PUSH_SINGLE; |
| | 25 | 225 | | const resolvedMap = isPushSingle |
| | | 226 | | ? resolveSingleSecretMapFile(options.map, options.mapUpdate) |
| | | 227 | | : resolveMapFile(options.map); |
| | 47 | 228 | | 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 = |
| | 47 | 233 | | resolvedMap !== undefined && |
| | | 234 | | (!isPushSingle || existsSync(resolvedMap)); |
| | | 235 | | const fileConfig = |
| | 47 | 236 | | resolvedMap !== undefined && mapFileExists |
| | | 237 | | ? await readMapFileConfig(resolvedMap) |
| | | 238 | | : {}; |
| | | 239 | | |
| | 47 | 240 | | const config: MapFileConfig = { |
| | | 241 | | ...fileConfig, |
| | | 242 | | ...(provider && { provider }), |
| | | 243 | | ...(vaultUrl && { vaultUrl }), |
| | | 244 | | ...(options.profile && { profile: options.profile }), |
| | | 245 | | }; |
| | | 246 | | |
| | 47 | 247 | | const resolvedOptions: CliOptions = { |
| | | 248 | | ...options, |
| | | 249 | | map: resolvedMap, |
| | | 250 | | envfile: resolvedEnvfile, |
| | | 251 | | ...(isPushSingle && |
| | | 252 | | resolvedMap !== undefined && { |
| | | 253 | | mapTarget: resolveMapFileTarget(config), |
| | | 254 | | }), |
| | | 255 | | }; |
| | | 256 | | |
| | 47 | 257 | | serviceProvider = Startup.build() |
| | | 258 | | .configureServices() |
| | | 259 | | .configureInfrastructure(config) |
| | | 260 | | .create(); |
| | | 261 | | |
| | 47 | 262 | | if (isPushSingle) { |
| | 13 | 263 | | const logger = serviceProvider.get<ILogger>(TYPES.ILogger); |
| | 13 | 264 | | const providerName = config.provider?.toLowerCase() || 'aws'; |
| | | 265 | | const origin = |
| | 13 | 266 | | providerName === 'azure' ? vaultOrigin(config.vaultUrl) : undefined; |
| | 13 | 267 | | const vaultInfo = origin ? `, vault=${origin}` : ''; |
| | 13 | 268 | | const source = mapFileExists |
| | | 269 | | ? `configuration from ${resolvedMap}` |
| | | 270 | | : 'configuration'; |
| | 13 | 271 | | logger.info(`Using ${source}: provider=${providerName}${vaultInfo}`); |
| | | 272 | | } |
| | | 273 | | |
| | 22 | 274 | | await executeWithSsoRecovery(() => executeCommand(resolvedOptions)); |
| | | 275 | | }, |
| | | 276 | | ); |
| | | 277 | | |
| | 183 | 278 | | registerExecCommand(program); |
| | 183 | 279 | | registerValidateCommand(program); |
| | 183 | 280 | | rejectDashValues(program, 1); |
| | | 281 | | |
| | 183 | 282 | | try { |
| | 183 | 283 | | await program.parseAsync(process.argv); |
| | | 284 | | } catch (error) { |
| | 131 | 285 | | if (error instanceof DashValueError) { |
| | | 286 | | // Same plain format as the parser's own errors. |
| | 12 | 287 | | process.stderr.write(missingArgumentMessage(error.flags)); |
| | 12 | 288 | | 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. |
| | 119 | 294 | | if (error instanceof CommanderError) { |
| | 8 | 295 | | if (error.exitCode === 0) { |
| | 0 | 296 | | return; |
| | | 297 | | } |
| | 8 | 298 | | throw new SilentExitError(EXEC_ENVILDER_FAILURE); |
| | | 299 | | } |
| | 111 | 300 | | 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 | | */ |
| | | 309 | | function knownOptionsOf(command: Command): KnownOption[] { |
| | 21 | 310 | | const visible = command.createHelp().visibleOptions(command); |
| | 21 | 311 | | const all = [...command.options, ...visible]; |
| | 21 | 312 | | const byFlags = new Map<string, KnownOption>(); |
| | 21 | 313 | | for (const option of all) { |
| | 445 | 314 | | byFlags.set(option.flags, { |
| | | 315 | | flags: option.flags, |
| | | 316 | | long: option.long, |
| | | 317 | | visible: visible.includes(option), |
| | | 318 | | }); |
| | | 319 | | } |
| | 21 | 320 | | 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 | | */ |
| | | 327 | | class DashValueError extends Error { |
| | | 328 | | constructor( |
| | 12 | 329 | | readonly flags: string, |
| | 12 | 330 | | readonly exitCode: number, |
| | | 331 | | ) { |
| | 12 | 332 | | super(`option '${flags}' argument missing`); |
| | | 333 | | } |
| | | 334 | | } |
| | | 335 | | |
| | 5 | 336 | | const 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 | | */ |
| | | 354 | | function rejectDashValues(command: Command, exitCode: number): void { |
| | 549 | 355 | | for (const option of command.options) { |
| | 4941 | 356 | | if (!option.required || !DASH_GUARDED_OPTIONS.has(option.long ?? '')) { |
| | 1281 | 357 | | continue; |
| | | 358 | | } |
| | 3660 | 359 | | option.argParser((value: string) => { |
| | 187 | 360 | | if (value.startsWith('-')) { |
| | 12 | 361 | | throw new DashValueError(option.flags, exitCode); |
| | | 362 | | } |
| | 175 | 363 | | return value; |
| | | 364 | | }); |
| | | 365 | | } |
| | | 366 | | } |
| | | 367 | | |
| | | 368 | | function registerExecCommand(program: Command): void { |
| | 183 | 369 | | 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. |
| | 76 | 415 | | const usageError = findExecUsageError(commandName, options); |
| | 76 | 416 | | if (usageError !== undefined || commandName === undefined) { |
| | 15 | 417 | | console.error(pc.red(`error: ${usageError}`)); |
| | 15 | 418 | | throw new SilentExitError(EXEC_ENVILDER_FAILURE); |
| | | 419 | | } |
| | 61 | 420 | | rejectRootOptionsBeforeExec( |
| | | 421 | | (execCommand.parent?.opts() ?? {}) as ExecOptions, |
| | | 422 | | options, |
| | | 423 | | commandName, |
| | | 424 | | childArgs ?? [], |
| | | 425 | | ); |
| | 76 | 426 | | await runExec(commandName, childArgs ?? [], options); |
| | | 427 | | }, |
| | | 428 | | ); |
| | | 429 | | |
| | | 430 | | // Suggestions for exec's parse errors come from exec's own options. |
| | 183 | 431 | | exec.configureOutput({ |
| | | 432 | | outputError: (message, write) => |
| | 8 | 433 | | write(redactParseError(message, knownOptionsOf(exec))), |
| | | 434 | | }); |
| | 183 | 435 | | rejectDashValues(exec, EXEC_ENVILDER_FAILURE); |
| | | 436 | | } |
| | | 437 | | |
| | | 438 | | function registerValidateCommand(program: Command): void { |
| | 183 | 439 | | 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) => { |
| | 26 | 471 | | rejectRootOptionsBeforeValidate( |
| | | 472 | | (validateCommand.parent?.opts() ?? {}) as ExecOptions, |
| | | 473 | | ); |
| | 26 | 474 | | if (options.offline) { |
| | 10 | 475 | | await runOfflineValidation(options); |
| | 5 | 476 | | return; |
| | | 477 | | } |
| | 15 | 478 | | await runRemoteValidation(options); |
| | | 479 | | }); |
| | | 480 | | |
| | 183 | 481 | | validate.configureOutput({ |
| | | 482 | | outputError: (message, write) => |
| | 0 | 483 | | write(redactParseError(message, knownOptionsOf(validate))), |
| | | 484 | | }); |
| | 183 | 485 | | rejectDashValues(validate, 1); |
| | | 486 | | } |
| | | 487 | | |
| | 5 | 488 | | const VALIDATE_HELP = ` |
| | | 489 | | Examples: |
| | | 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 | | |
| | | 495 | | Exit 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 | | |
| | | 500 | | See 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 | | */ |
| | | 507 | | function rejectRootOptionsBeforeValidate(rootOptions: ExecOptions): void { |
| | 26 | 508 | | const misplaced = [ |
| | | 509 | | ...EXEC_COMPATIBLE_ROOT_OPTIONS, |
| | | 510 | | ...EXEC_INCOMPATIBLE_ROOT_OPTIONS, |
| | 260 | 511 | | ].filter(([name]) => rootOptions[name] !== undefined); |
| | 26 | 512 | | if (misplaced.length === 0) { |
| | 25 | 513 | | return; |
| | | 514 | | } |
| | 1 | 515 | | const flags = misplaced.map(([, flag]) => flag).join(', '); |
| | 1 | 516 | | console.error( |
| | | 517 | | pc.red(`error: Put envilder options after "validate" (found ${flags}).`), |
| | | 518 | | ); |
| | 1 | 519 | | throw new SilentExitError(1); |
| | | 520 | | } |
| | | 521 | | |
| | | 522 | | async function runOfflineValidation(options: ValidateOptions): Promise<void> { |
| | 10 | 523 | | await validateMapFileOffline(options); |
| | 5 | 524 | | 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 | | */ |
| | | 537 | | async function validateMapFileOffline( |
| | | 538 | | options: ValidateOptions, |
| | | 539 | | ): Promise<string> { |
| | 25 | 540 | | const mapPath = resolveMapFile(options.map); |
| | 25 | 541 | | const container = Startup.build().configureOfflineValidation().create(); |
| | 25 | 542 | | const handler = container.get<ValidateMapFileCommandHandler>( |
| | | 543 | | TYPES.ValidateMapFileCommandHandler, |
| | | 544 | | ); |
| | 25 | 545 | | 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 | | |
| | 24 | 557 | | if (violations.length > 0) { |
| | | 558 | | const count = |
| | 6 | 559 | | violations.length === 1 ? '1 problem' : `${violations.length} problems`; |
| | 6 | 560 | | console.error(pc.red(`✗ Map file invalid: ${mapPath} (${count})`)); |
| | 6 | 561 | | for (const violation of violations) { |
| | 9 | 562 | | console.error(` ${pc.red('•')} ${violation.message}`); |
| | | 563 | | } |
| | 6 | 564 | | throw new SilentExitError(1); |
| | | 565 | | } |
| | | 566 | | |
| | 18 | 567 | | console.log(pc.green(`✓ Map file valid: ${mapPath}`)); |
| | 18 | 568 | | 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 | | */ |
| | | 576 | | async function runRemoteValidation(options: ValidateOptions): Promise<void> { |
| | 15 | 577 | | const mapPath = await validateMapFileOffline(options); |
| | 13 | 578 | | const fileConfig = await readMapFileConfig(mapPath); |
| | 13 | 579 | | 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 | | |
| | 15 | 586 | | const container = Startup.build() |
| | | 587 | | .configureRemoteValidation() |
| | | 588 | | .configureInfrastructure(config) |
| | | 589 | | .create(); |
| | 15 | 590 | | const handler = container.get<ValidateRemoteKeysCommandHandler>( |
| | | 591 | | TYPES.ValidateRemoteKeysCommandHandler, |
| | | 592 | | ); |
| | | 593 | | |
| | 15 | 594 | | let checks: RemoteKeyCheck[] = []; |
| | 15 | 595 | | await executeWithSsoRecovery(async () => { |
| | 13 | 596 | | checks = await handler.handle(ValidateRemoteKeysCommand.create(mapPath)); |
| | | 597 | | }); |
| | | 598 | | |
| | 11 | 599 | | printRemoteKeyChecks(checks, describeSecretSource(config)); |
| | 11 | 600 | | const failures = summarizeFailures(checks); |
| | 11 | 601 | | if (failures !== '') { |
| | 7 | 602 | | console.error(pc.red(`✗ Validation failed: ${failures}`)); |
| | 7 | 603 | | throw new SilentExitError(1); |
| | | 604 | | } |
| | | 605 | | } |
| | | 606 | | |
| | | 607 | | function printRemoteKeyChecks( |
| | | 608 | | checks: readonly RemoteKeyCheck[], |
| | | 609 | | source: string, |
| | | 610 | | ): void { |
| | 11 | 611 | | const count = checks.length === 1 ? '1 secret' : `${checks.length} secrets`; |
| | 11 | 612 | | console.log(pc.bold(`Checking ${count} in ${source}`)); |
| | 21 | 613 | | const variableWidth = Math.max(0, ...checks.map((c) => c.variable.length)); |
| | 21 | 614 | | const pathWidth = Math.max(0, ...checks.map((c) => c.path.length)); |
| | 11 | 615 | | for (const check of checks) { |
| | 21 | 616 | | const exists = check.status === 'exists'; |
| | 21 | 617 | | const status = describeSecretExistenceStatus(check.status); |
| | | 618 | | const reason = |
| | 21 | 619 | | check.reason === undefined ? '' : pc.dim(` (${check.reason})`); |
| | 21 | 620 | | 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 | | } |
| | 11 | 627 | | if (summarizeFailures(checks) === '') { |
| | 4 | 628 | | console.log( |
| | | 629 | | pc.green( |
| | | 630 | | checks.length === 1 ? '✓ 1 secret exists' : `✓ All ${count} exist`, |
| | | 631 | | ), |
| | | 632 | | ); |
| | | 633 | | } |
| | | 634 | | } |
| | | 635 | | |
| | 5 | 636 | | const EXEC_HELP = ` |
| | | 637 | | Examples: |
| | | 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 | | |
| | | 643 | | Exit 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 | | |
| | | 651 | | Exec mode is fail-closed: the command never starts when a secret is missing, |
| | | 652 | | empty or unreadable. Mapped values override inherited variables. No shell is |
| | | 653 | | involved; use "envilder exec -- sh -c '...'" for shell features. |
| | | 654 | | The command and everything it starts can read the secrets: exec mode is not |
| | | 655 | | a sandbox. See docs/exec-command.md.`; |
| | | 656 | | |
| | | 657 | | const EXEC_COMPATIBLE_ROOT_OPTIONS: ReadonlyArray<[keyof ExecOptions, string]> = |
| | 5 | 658 | | [ |
| | | 659 | | ['map', '--map'], |
| | | 660 | | ['profile', '--profile'], |
| | | 661 | | ['provider', '--provider'], |
| | | 662 | | ['vaultUrl', '--vault-url'], |
| | | 663 | | ]; |
| | | 664 | | const EXEC_INCOMPATIBLE_ROOT_OPTIONS: ReadonlyArray< |
| | | 665 | | [keyof ExecOptions, string] |
| | 5 | 666 | | > = [ |
| | | 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 | | */ |
| | | 682 | | function rejectRootOptionsBeforeExec( |
| | | 683 | | rootOptions: ExecOptions, |
| | | 684 | | execOptions: ExecOptions, |
| | | 685 | | commandName: string, |
| | | 686 | | childArgs: string[], |
| | | 687 | | ): void { |
| | 61 | 688 | | const isSetIn = |
| | 129 | 689 | | (options: ExecOptions) => |
| | | 690 | | ([name]: [keyof ExecOptions, string]) => |
| | 638 | 691 | | options[name] !== undefined; |
| | 61 | 692 | | const compatible = EXEC_COMPATIBLE_ROOT_OPTIONS.filter(isSetIn(rootOptions)); |
| | 61 | 693 | | const incompatible = EXEC_INCOMPATIBLE_ROOT_OPTIONS.filter( |
| | | 694 | | isSetIn(rootOptions), |
| | | 695 | | ); |
| | 61 | 696 | | if (compatible.length === 0 && incompatible.length === 0) { |
| | 40 | 697 | | return; |
| | | 698 | | } |
| | | 699 | | |
| | 21 | 700 | | const lines: string[] = []; |
| | 21 | 701 | | if (compatible.length > 0) { |
| | 7 | 702 | | const optionFor = |
| | 14 | 703 | | (options: ExecOptions) => |
| | | 704 | | ([name, flag]: [keyof ExecOptions, string]) => |
| | 7 | 705 | | formatArgument(`${flag}=${options[name]}`); |
| | 7 | 706 | | 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(' '); |
| | 7 | 715 | | lines.push(`error: Put envilder options after "exec": ${suggestion}`); |
| | | 716 | | } else { |
| | 14 | 717 | | lines.push('error: Put envilder options after "exec".'); |
| | | 718 | | } |
| | 21 | 719 | | if (incompatible.length > 0) { |
| | 14 | 720 | | const flags = incompatible.map(([, flag]) => flag).join(', '); |
| | 14 | 721 | | lines.push(`${flags} cannot be used with exec.`); |
| | | 722 | | } |
| | 21 | 723 | | console.error(pc.red(lines.join('\n'))); |
| | 21 | 724 | | throw new SilentExitError(EXEC_ENVILDER_FAILURE); |
| | | 725 | | } |
| | | 726 | | |
| | | 727 | | async function runExec( |
| | | 728 | | commandName: string, |
| | | 729 | | childArgs: string[], |
| | | 730 | | options: ExecOptions, |
| | | 731 | | ): Promise<void> { |
| | | 732 | | let exitCode: number; |
| | 40 | 733 | | try { |
| | 40 | 734 | | exitCode = await resolveSecretsAndRun(commandName, childArgs, options); |
| | | 735 | | } catch (error) { |
| | 17 | 736 | | throw new SilentExitError(reportExecFailure(error)); |
| | | 737 | | } |
| | 23 | 738 | | process.exitCode = exitCode; |
| | | 739 | | } |
| | | 740 | | |
| | | 741 | | function findExecUsageError( |
| | | 742 | | commandName: string | undefined, |
| | | 743 | | options: ExecOptions, |
| | | 744 | | ): string | undefined { |
| | 76 | 745 | | if (options.envfile !== undefined) { |
| | 2 | 746 | | return '--envfile cannot be used with exec: exec mode never reads or writes a .env file.'; |
| | | 747 | | } |
| | 74 | 748 | | if (options.push !== undefined) { |
| | 1 | 749 | | return '--push cannot be used with exec.'; |
| | | 750 | | } |
| | 73 | 751 | | const singleSecretOptions = [ |
| | | 752 | | options.key, |
| | | 753 | | options.value, |
| | | 754 | | options.secretPath, |
| | | 755 | | options.ssmPath, |
| | | 756 | | ]; |
| | 278 | 757 | | if (singleSecretOptions.some((option) => option !== undefined)) { |
| | 9 | 758 | | return 'Single-secret push options cannot be used with exec.'; |
| | | 759 | | } |
| | 64 | 760 | | if (!commandName) { |
| | 3 | 761 | | return `Missing command. Usage: ${EXEC_USAGE}`; |
| | | 762 | | } |
| | 61 | 763 | | return undefined; |
| | | 764 | | } |
| | | 765 | | |
| | | 766 | | async function resolveSecretsAndRun( |
| | | 767 | | commandName: string, |
| | | 768 | | childArgs: string[], |
| | | 769 | | options: ExecOptions, |
| | | 770 | | ): Promise<number> { |
| | 40 | 771 | | const mapPath = resolveMapFile(options.map); |
| | 40 | 772 | | const fileConfig = await readMapFileConfig(mapPath); |
| | 38 | 773 | | 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 | | |
| | 40 | 780 | | const container = Startup.build() |
| | | 781 | | .configureExec(createExecSecretMasker(), createExecLogger()) |
| | | 782 | | .configureServices() |
| | | 783 | | .configureInfrastructure(config) |
| | | 784 | | .create(); |
| | 40 | 785 | | const dispatcher = container.get<DispatchActionCommandHandler>( |
| | | 786 | | TYPES.DispatchActionCommandHandler, |
| | | 787 | | ); |
| | 40 | 788 | | 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. |
| | 40 | 803 | | for (const stream of [process.stdout, process.stderr]) { |
| | 76 | 804 | | if (!stream.listeners('error').includes(ignoreStreamError)) { |
| | 4 | 805 | | stream.on('error', ignoreStreamError); |
| | | 806 | | } |
| | | 807 | | } |
| | 38 | 808 | | return dispatchExec(dispatcher, command); |
| | | 809 | | } |
| | | 810 | | |
| | | 811 | | function ignoreStreamError(): void {} |
| | | 812 | | |
| | | 813 | | async 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; |
| | 38 | 820 | | await executeWithSsoRecovery( |
| | | 821 | | async () => { |
| | 41 | 822 | | exitCode = await dispatcher.handleCommand(command); |
| | | 823 | | }, |
| | | 824 | | { |
| | | 825 | | // Exec prompts on stderr, so stdout may be redirected for the child. |
| | | 826 | | isInteractive: () => |
| | 4 | 827 | | Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY), |
| | | 828 | | confirm: createConfirm(process.stderr), |
| | | 829 | | runLogin: createRunLogin({ stdout: 'stderr' }), |
| | | 830 | | }, |
| | | 831 | | ); |
| | 23 | 832 | | if (exitCode === undefined) { |
| | 0 | 833 | | throw new InvalidArgumentError('Secrets could not be resolved.'); |
| | | 834 | | } |
| | 23 | 835 | | 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 | | */ |
| | | 843 | | function reportExecFailure(error: unknown): number { |
| | 17 | 844 | | if (error instanceof SilentExitError) { |
| | | 845 | | // SSO recovery was declined or failed and already explained why. |
| | 2 | 846 | | return EXEC_ENVILDER_FAILURE; |
| | | 847 | | } |
| | 15 | 848 | | const logger = createExecLogger(); |
| | 15 | 849 | | const logLines = (text: string) => { |
| | 15 | 850 | | for (const line of text.split('\n')) { |
| | 73 | 851 | | logger.error(line); |
| | | 852 | | } |
| | | 853 | | }; |
| | 15 | 854 | | if (error instanceof CommandNotFoundError) { |
| | 1 | 855 | | logLines(pc.red(error.message)); |
| | 1 | 856 | | return EXEC_COMMAND_NOT_FOUND; |
| | | 857 | | } |
| | 14 | 858 | | if (error instanceof CommandNotExecutableError) { |
| | 1 | 859 | | logLines(pc.red(error.message)); |
| | 1 | 860 | | return EXEC_COMMAND_NOT_EXECUTABLE; |
| | | 861 | | } |
| | 13 | 862 | | logLines(presentError(error)); |
| | 13 | 863 | | return EXEC_ENVILDER_FAILURE; |
| | | 864 | | } |
| | | 865 | | |
| | | 866 | | function describeSecretSource(config: MapFileConfig): string { |
| | 49 | 867 | | const provider = config.provider?.toLowerCase() || 'aws'; |
| | 49 | 868 | | if (provider === 'aws' && config.profile) { |
| | 1 | 869 | | return `${provider}, profile=${config.profile}`; |
| | | 870 | | } |
| | 48 | 871 | | if (provider === 'azure' && config.vaultUrl) { |
| | 3 | 872 | | try { |
| | 3 | 873 | | return `${provider}, vault=${new URL(config.vaultUrl).host}`; |
| | | 874 | | } catch { |
| | 0 | 875 | | return provider; |
| | | 876 | | } |
| | | 877 | | } |
| | 45 | 878 | | 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 | | */ |
| | | 885 | | function createExecSecretMasker(): ISecretMasker { |
| | 38 | 886 | | 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 | | */ |
| | | 896 | | function createExecLogger(): ILogger { |
| | 53 | 897 | | 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 | | */ |
| | | 906 | | function vaultOrigin(vaultUrl: string | undefined): string | undefined { |
| | 6 | 907 | | if (!vaultUrl) { |
| | 0 | 908 | | return undefined; |
| | | 909 | | } |
| | 6 | 910 | | try { |
| | 6 | 911 | | return new URL(vaultUrl).origin; |
| | | 912 | | } catch { |
| | 1 | 913 | | 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 | | */ |
| | | 922 | | function resolveSingleSecretMapFile( |
| | | 923 | | mapOption: string | undefined, |
| | | 924 | | mapUpdate: boolean | undefined, |
| | | 925 | | ): string | undefined { |
| | 14 | 926 | | if (mapUpdate === false) { |
| | 2 | 927 | | return undefined; |
| | | 928 | | } |
| | 12 | 929 | | return mapOption === undefined ? DEFAULT_MAP_FILE : resolveMapFile(mapOption); |
| | | 930 | | } |
| | | 931 | | |
| | | 932 | | function resolveMapFile(mapOption: string | undefined): string { |
| | 81 | 933 | | if (mapOption !== undefined) { |
| | 39 | 934 | | const trimmed = mapOption.trim(); |
| | 39 | 935 | | if (trimmed.length === 0) { |
| | 2 | 936 | | throw new InvalidArgumentError( |
| | | 937 | | 'Invalid --map value: path must not be empty.', |
| | | 938 | | ); |
| | | 939 | | } |
| | 37 | 940 | | return trimmed; |
| | | 941 | | } |
| | | 942 | | |
| | 42 | 943 | | const defaultPath = join(process.cwd(), DEFAULT_MAP_FILE); |
| | 42 | 944 | | if (existsSync(defaultPath)) { |
| | 39 | 945 | | return DEFAULT_MAP_FILE; |
| | | 946 | | } |
| | | 947 | | |
| | 3 | 948 | | throw new InvalidArgumentError( |
| | | 949 | | `No map file found. Provide --map or create ${DEFAULT_MAP_FILE} in the current directory.`, |
| | | 950 | | ); |
| | | 951 | | } |
| | | 952 | | |
| | | 953 | | function resolveEnvfile(envfileOption: string | undefined): string { |
| | 22 | 954 | | if (envfileOption === undefined) { |
| | 15 | 955 | | return DEFAULT_ENV_FILE; |
| | | 956 | | } |
| | | 957 | | |
| | 7 | 958 | | const trimmed = envfileOption.trim(); |
| | 7 | 959 | | if (trimmed.length === 0) { |
| | 1 | 960 | | throw new InvalidArgumentError( |
| | | 961 | | 'Invalid --envfile value: path must not be empty.', |
| | | 962 | | ); |
| | | 963 | | } |
| | | 964 | | |
| | 6 | 965 | | return trimmed; |
| | | 966 | | } |
| | | 967 | | |
| | | 968 | | function readPackageVersion(): Promise<string> { |
| | 183 | 969 | | const __filename = fileURLToPath(import.meta.url); |
| | 183 | 970 | | const __dirname = dirname(__filename); |
| | 183 | 971 | | const packageJsonPath = join(__dirname, '../../../../../package.json'); |
| | | 972 | | |
| | 183 | 973 | | return new PackageVersionReader().getVersion(packageJsonPath); |
| | | 974 | | } |