< Summary - Envilder CLI

Information
Class: src/envilder/core/application/pushSingle/PushSingleCommandHandler.ts
Assembly: Default
File(s): src/envilder/core/application/pushSingle/PushSingleCommandHandler.ts
Tag: 561_41292037308
Line coverage
98%
Covered lines: 55
Uncovered lines: 1
Coverable lines: 56
Total lines: 203
Line coverage: 98.2%
Branch coverage
95%
Covered branches: 21
Total branches: 22
Branch coverage: 95.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/application/pushSingle/PushSingleCommandHandler.ts

#LineLine coverage
 1import { inject, injectable } from 'inversify';
 2import pc from 'picocolors';
 3import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 4import {
 5  EnvironmentFileError,
 6  InvalidArgumentError,
 7} from '../../domain/errors/DomainErrors.js';
 8import {
 9  decideMapFileUpdate,
 10  type MapFileTarget,
 11} from '../../domain/MapFileUpdate.js';
 12import type { ILogger } from '../../domain/ports/ILogger.js';
 13import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 14import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 15import { TYPES } from '../../types.js';
 16import type { PushSingleCommand } from './PushSingleCommand.js';
 17
 18/** A write to the map file, and what it held before so it can be undone. */
 19type MapFileChange = {
 20  mapFile: string;
 21  content: string;
 22  /** The text the file held before, or `null` when we create it. */
 23  original: string | null;
 24};
 25
 26/** What to do to the map file once the secret is pushed, and what to report. */
 27type MapFilePlan = {
 28  /** Absent when the file needs no change. */
 29  change?: MapFileChange;
 30  summary?: string;
 31  warning?: string;
 32};
 33
 34/** No map file: nothing to write and nothing to report. */
 1335const NO_MAP_FILE: MapFilePlan = {};
 36
 37@injectable()
 1338export class PushSingleCommandHandler {
 1339  private static readonly RULE = pc.magenta('\u2501'.repeat(60));
 40
 41  constructor(
 42    @inject(TYPES.ISecretProvider)
 10043    private readonly secretProvider: ISecretProvider,
 10044    @inject(TYPES.ILogger) private readonly logger: ILogger,
 45    @inject(TYPES.IVariableStore)
 10046    private readonly variableStore: IVariableStore,
 47  ) {}
 48
 49  /**
 50   * Handles the PushSingleCommand which pushes a single environment variable to the secret store.
 51   * When the command names a map file, the mapping is recorded there first and
 52   * restored if the push fails, so the file never claims a secret that was not pushed.
 53   *
 54   * @param command - The PushSingleCommand containing key, value and secretPath
 55   */
 56  async handle(command: PushSingleCommand): Promise<void> {
 2357    try {
 2358      const maskedPath = EnvironmentVariable.maskSecretPath(command.secretPath);
 59
 2360      this.logger.info(`\n${pc.bold(pc.magenta('\u{1F4E4} PUSHING SECRET'))}`);
 2361      this.logger.info(PushSingleCommandHandler.RULE);
 2362      this.logger.info(
 63        `  ${pc.dim('\u2192 ')}${pc.bold(command.key)}${pc.dim(' \u2192 ')}${pc.dim(maskedPath)}`,
 64      );
 65
 2366      const envVariable = EnvironmentVariable.secret(
 67        command.key,
 68        command.value,
 69      );
 70
 2371      const plan = await this.planMapFileUpdate(command);
 2072      try {
 2073        await this.writeMapFile(plan);
 74      } catch (error) {
 75        // A write that fails midway can leave a truncated file behind.
 276        await this.rollBackMapFile(plan, command.key);
 277        throw error;
 78      }
 79
 1880      try {
 1881        await this.secretProvider.setSecret(command.secretPath, command.value);
 82      } catch (error) {
 583        await this.rollBackMapFile(plan, command.key);
 584        throw error;
 85      }
 1386      this.logger.info(
 87        `\n${pc.bold(pc.green('\u2B50 SECRET PUSHED'))}${pc.dim(
 88          '  \u2014  ',
 89        )}${pc.bold(command.key)}${pc.dim('=')}${envVariable.maskedValue}${pc.dim(
 90          ' \u2192 ',
 91        )}${pc.dim(maskedPath)}\n`,
 92      );
 1393      this.reportMapFile(plan);
 94    } catch (error) {
 95      const errorMessage =
 1096        error instanceof Error ? error.message : String(error);
 1097      this.logger.error(
 98        `Failed to push variable to secret store: ${errorMessage}`,
 99      );
 10100      throw error;
 101    }
 102  }
 103
 104  private async planMapFileUpdate(
 105    command: PushSingleCommand,
 106  ): Promise<MapFilePlan> {
 22107    const { mapFile, key, secretPath, target } = command;
 22108    if (mapFile === undefined) {
 6109      return NO_MAP_FILE;
 110    }
 111
 16112    const original = await this.variableStore.readMapFileContent(mapFile);
 16113    const decision = decideMapFileUpdate(
 114      original,
 115      key,
 116      secretPath,
 117      target,
 118      mapFile,
 119    );
 16120    switch (decision.kind) {
 121      case 'create':
 7122        return {
 123          change: { mapFile, original, content: decision.content },
 124          summary: `Created ${mapFile}`,
 125        };
 126      case 'append':
 3127        return {
 128          change: { mapFile, original, content: decision.content },
 129          summary: `Added ${key} to ${mapFile}`,
 130        };
 131      case 'unchanged':
 2132        return {
 133          summary: `${key} already mapped in ${mapFile}, no changes`,
 134        };
 135      case 'targetMismatch':
 2136        return {
 137          warning:
 138            `\u26A0 ${mapFile} targets ${this.describeTarget(decision.fileTarget)}, ` +
 139            `but the secret was pushed to ${this.describeTarget(target)}. ` +
 140            'The mapping was not added. Use --map=<file> to record it in a different map file.',
 141        };
 142      case 'conflict':
 143        // Names the variable only: the existing path is not ours to echo.
 1144        throw new InvalidArgumentError(
 145          `${key} is already mapped to a different secret path in ${mapFile}. ` +
 146            'Nothing was pushed. Update the mapping manually, ' +
 147            'or use --no-map-update to push without recording it.',
 148        );
 149      case 'invalidJson':
 1150        throw new EnvironmentFileError(
 151          `Invalid JSON in parameter map file: ${mapFile}`,
 152        );
 153    }
 154  }
 155
 156  private async writeMapFile({ change }: MapFilePlan): Promise<void> {
 20157    if (change === undefined) {
 10158      return;
 159    }
 10160    await this.variableStore.writeMapFileContent(
 161      change.mapFile,
 162      change.content,
 163    );
 164  }
 165
 166  private async rollBackMapFile(
 167    { change }: MapFilePlan,
 168    key: string,
 169  ): Promise<void> {
 7170    if (change === undefined) {
 2171      return;
 172    }
 5173    try {
 5174      await this.variableStore.restoreMapFile(change.mapFile, change.original);
 175    } catch {
 1176      this.logger.error(
 177        `Could not restore ${change.mapFile}. Remove ${key} from it manually.`,
 178      );
 179    }
 180  }
 181
 182  private reportMapFile({ summary, warning }: MapFilePlan): void {
 13183    if (warning !== undefined) {
 2184      this.logger.warn(pc.yellow(warning));
 2185      return;
 186    }
 11187    if (summary !== undefined) {
 6188      this.logger.info(`${pc.green('\u2713')} ${summary}\n`);
 189    }
 190  }
 191
 192  /** Provider and vault name only: a vault URL may carry credentials. */
 193  private describeTarget(target: MapFileTarget): string {
 4194    if (target.provider !== 'azure' || !target.vaultUrl) {
 2195      return target.provider;
 196    }
 2197    try {
 2198      return `${target.provider} (${new URL(target.vaultUrl).hostname.split('.')[0]})`;
 199    } catch {
 0200      return target.provider;
 201    }
 202  }
 203}