< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Tag: 561_41292037308
Line coverage
96%
Covered lines: 175
Uncovered lines: 7
Coverable lines: 182
Total lines: 625
Line coverage: 96.1%
Branch coverage
87%
Covered branches: 94
Total branches: 108
Branch coverage: 87%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/infrastructure/variableStore/FileVariableStore.ts

#LineLine coverage
 1import * as fs from 'node:fs/promises';
 2import { inject, injectable } from 'inversify';
 3import {
 4  invalidEnvironmentVariableNameMessage,
 5  isPortableEnvFileName,
 6  isValidEnvironmentVariableName,
 7  nonPortableEnvFileNamesMessage,
 8} from '../../domain/EnvironmentVariableName.js';
 9import {
 10  DependencyMissingError,
 11  EnvironmentFileError,
 12  InvalidArgumentError,
 13} from '../../domain/errors/DomainErrors.js';
 14import type {
 15  MapFileConfig,
 16  ParsedMapFile,
 17} from '../../domain/MapFileConfig.js';
 18import type { ILogger } from '../../domain/ports/ILogger.js';
 19import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 20import { TYPES } from '../../types.js';
 21import { parseEnvFile } from './EnvFileParser.js';
 22
 23/** The delimiters dotenv recognizes for quoting a `.env` value. */
 1324const ENV_QUOTES = ["'", '"', '`'] as const;
 25type EnvQuote = (typeof ENV_QUOTES)[number];
 26
 27/**
 28 * Follows dotenv's own `LINE` grammar rather than re-deriving it, split into
 29 * capture groups, with the deliberate narrowings noted at the end. The key
 30 * class also admits `/`, as {@link parseEnvFile} does, so an existing
 31 * `Database/Password` line is updated in place instead of duplicated:
 32 *
 33 *     /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'
 34 *       |\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
 35 *
 36 * Approximating that grammar instead of copying it is what repeatedly left old
 37 * secrets on disk: every form dotenv spans and we do not degrades into the
 38 * single-line branch, which rewrites the first physical line of an assignment
 39 * and abandons the rest of the previous value in the file. Cases that cost us a
 40 * round each — a closing delimiter followed by blanks, a colon separator, a
 41 * value that only starts on the next physical line — are all consequences of
 42 * the same divergence, so the grammar is now shared rather than re-derived.
 43 *
 44 * Everything except the value is captured and re-emitted verbatim, so groups
 45 * whose `\s` reaches across line breaks cannot lose structure: whatever they
 46 * consume, they put back.
 47 *
 48 * `(?<!\r)$` is the one place the raw text needs more than dotenv's grammar
 49 * says. dotenv rewrites every CRLF to LF before it matches, so it never sees
 50 * the position between a CR and its LF; we match the file as it is, where `$`
 51 * does match there and would let a separator whose `\s` had just taken the CR
 52 * end the assignment early, leaving the rest of the value behind a bare
 53 * carriage return.
 54 *
 55 * Two places stay deliberately narrower than dotenv, because dotenv only has to
 56 * find where a value ends while we also have to put the surroundings back. The
 57 * trailing padding is horizontal, so a comment on the next line stays outside
 58 * the span and the break before it is still visible as structure; and the
 59 * unquoted run is lazy, so the blanks before an inline comment land in the
 60 * padding we re-emit instead of inside the value we replace. Neither narrows
 61 * the value span itself, which is the part that has to agree with dotenv.
 62 *
 63 * Two more rewrites keep the matching linear without changing what it claims.
 64 * The leading blanks stop at a line break, so a span begins on its key's own
 65 * line: dotenv's `\s*` there lets every blank line start an attempt that scans
 66 * the whole run after it, which turns a file of blank lines quadratic. Nothing
 67 * is lost, because the attempt that begins on the key's line captures the same
 68 * key and value. And the unquoted run only stops after a non-blank character,
 69 * since the earliest place a run of blanks can end a value is always where the
 70 * run begins; a lazy step inside the run would rescan the rest of it each time.
 71 */
 72const ASSIGNMENT_PATTERN =
 1373  /^([^\S\n\r\u2028\u2029]*(?:export\s+)?)([\w./-]+)(\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^
 74
 75/** The line break that closes a file, kept verbatim instead of normalized. */
 1376const TRAILING_NEWLINE_PATTERN = /(?:\r\n|[\r\n])$/;
 77
 78@injectable()
 1379export class FileVariableStore implements IVariableStore {
 80  private logger: ILogger;
 81
 82  constructor(@inject(TYPES.ILogger) logger: ILogger) {
 27883    if (!logger) {
 184      throw new DependencyMissingError('Logger must be specified');
 85    }
 27786    this.logger = logger;
 87  }
 88
 89  async getMapping(source: string): Promise<Record<string, string>> {
 4090    const { mappings } = await this.getParsedMapping(source);
 2091    return mappings;
 92  }
 93
 94  async getParsedMapping(source: string): Promise<ParsedMapFile> {
 4695    const raw = await this.readJsonFile(source);
 4396    const $config = raw.$config;
 97    const config: MapFileConfig =
 4398      $config && typeof $config === 'object' ? $config : {};
 99    // Null-prototype: map-file keys are untrusted, and a plain object literal
 100    // would route `__proto__` through the Object.prototype setter, silently
 101    // dropping that mapping instead of storing it as data.
 46102    const mappings: Record<string, string> = Object.create(null);
 103    // Iterate the parsed object itself rather than an object-rest copy: below
 104    // ES2018 tsc lowers `...rest` to a helper that copies by plain assignment,
 105    // so an own `__proto__` key would hit the same setter and vanish before
 106    // the name check below ever saw it.
 46107    for (const [key, value] of Object.entries(raw)) {
 75108      if (key.startsWith('$')) {
 13109        continue;
 110      }
 111      // Before the non-string filter, so the name rule covers every mapping
 112      // key the file declares. Validating after would accept a name the
 113      // published schema rejects whenever its value happened to be a number
 114      // or an object. Non-string values are still skipped, not an error.
 62115      this.assertValidVariableName(key);
 62116      if (typeof value !== 'string') {
 4117        continue;
 118      }
 40119      mappings[key] = value;
 120    }
 25121    return { config, mappings };
 122  }
 123
 124  private assertValidVariableName(name: string): void {
 208125    if (!isValidEnvironmentVariableName(name)) {
 34126      throw new InvalidArgumentError(
 127        invalidEnvironmentVariableNameMessage(name),
 128      );
 129    }
 130  }
 131
 132  private async readJsonFile(source: string): Promise<Record<string, unknown>> {
 46133    try {
 46134      const content = await fs.readFile(source, 'utf-8');
 44135      try {
 44136        return JSON.parse(content);
 137      } catch (_err: unknown) {
 1138        this.logger.error(`Error parsing JSON from ${source}`);
 1139        throw new EnvironmentFileError(
 140          `Invalid JSON in parameter map file: ${source}`,
 141        );
 142      }
 143    } catch (error) {
 3144      if (error instanceof EnvironmentFileError) {
 1145        throw error;
 146      }
 2147      throw new EnvironmentFileError(`Failed to read map file: ${source}`);
 148    }
 149  }
 150
 151  async readMapFileContent(source: string): Promise<string | null> {
 31152    try {
 31153      return await fs.readFile(source, 'utf-8');
 154    } catch (error) {
 4155      if (isErrnoCode(error, 'ENOENT')) {
 3156        return null;
 157      }
 1158      throw new EnvironmentFileError(`Failed to read map file: ${source}`);
 159    }
 160  }
 161
 162  async writeMapFileContent(
 163    destination: string,
 164    content: string,
 165  ): Promise<void> {
 7166    try {
 7167      await fs.writeFile(destination, content, 'utf-8');
 168    } catch (error) {
 1169      if (isErrnoCode(error, 'ENOENT')) {
 1170        throw new EnvironmentFileError(
 171          `Cannot write map file ${destination}: its directory does not exist`,
 172        );
 173      }
 0174      const reason = error instanceof Error ? error.message : String(error);
 1175      throw new EnvironmentFileError(
 176        `Failed to write map file ${destination}: ${reason}`,
 177      );
 178    }
 179  }
 180
 181  async restoreMapFile(
 182    destination: string,
 183    original: string | null,
 184  ): Promise<void> {
 3185    if (original !== null) {
 1186      await this.writeMapFileContent(destination, original);
 1187      return;
 188    }
 2189    try {
 2190      await fs.unlink(destination);
 191    } catch (error) {
 1192      if (!isErrnoCode(error, 'ENOENT')) {
 0193        throw error;
 194      }
 195    }
 196  }
 197
 198  async getEnvironment(source: string): Promise<Record<string, string>> {
 199    // Null-prototype for the same reason as the parsed mappings: callers
 200    // assign resolved secrets straight into this map, and a plain object
 201    // would route a prototype-shadowing key through an inherited setter.
 10202    const envVariables: Record<string, string> = Object.create(null);
 10203    try {
 10204      await fs.access(source);
 205    } catch {
 4206      return envVariables;
 207    }
 6208    const existingEnvContent = await fs.readFile(source, 'utf-8');
 4209    const parsedEnv = parseEnvFile(existingEnvContent) || {};
 10210    Object.assign(envVariables, parsedEnv);
 211
 10212    return envVariables;
 213  }
 214
 215  async saveEnvironment(
 216    destination: string,
 217    envVariables: Record<string, string>,
 218  ): Promise<void> {
 110219    for (const key of Object.keys(envVariables)) {
 146220      this.assertValidVariableName(key);
 221    }
 94222    this.warnAboutNonPortableNames(Object.keys(envVariables));
 223
 94224    const existingContent = await this.readExistingEnvContent(destination);
 93225    const unmanagedValues = this.collectUnmanagedValues(
 226      existingContent,
 227      envVariables,
 228    );
 93229    const envContent = this.buildEnvContent(existingContent, envVariables);
 93230    this.assertValuesArePreserved(envContent, envVariables, unmanagedValues);
 231
 93232    try {
 93233      await fs.writeFile(destination, envContent);
 234    } catch (error) {
 235      const errorMessage =
 2236        error instanceof Error ? error.message : String(error);
 2237      this.logger.error(`Failed to write environment file: ${errorMessage}`);
 2238      throw new EnvironmentFileError(
 239        `Failed to write environment file: ${errorMessage}`,
 240      );
 241    }
 242  }
 243
 244  private warnAboutNonPortableNames(names: string[]): void {
 130245    const nonPortable = names.filter((name) => !isPortableEnvFileName(name));
 94246    if (nonPortable.length > 0) {
 3247      this.logger.warn(nonPortableEnvFileNamesMessage(nonPortable));
 248    }
 249  }
 250
 251  private async readExistingEnvContent(
 252    destination: string,
 253  ): Promise<string | null> {
 94254    try {
 94255      return await fs.readFile(destination, 'utf-8');
 256    } catch (error) {
 19257      if (
 258        error instanceof Error &&
 259        (error as NodeJS.ErrnoException).code === 'ENOENT'
 260      ) {
 18261        return null;
 262      }
 1263      const message = error instanceof Error ? error.message : String(error);
 19264      this.logger.error(`Failed to read environment file: ${message}`);
 19265      throw new EnvironmentFileError(
 266        `Failed to read environment file: ${message}`,
 267      );
 268    }
 269  }
 270
 271  private buildEnvContent(
 272    existingContent: string | null,
 273    envVariables: Record<string, string>,
 274  ): string {
 93275    const entries = Object.entries(envVariables);
 276    // A file we create ends with a line break, as a POSIX text file should:
 277    // without one, git reports "\ No newline at end of file" and any editor
 278    // configured to add one rewrites the file behind us. An existing file that
 279    // is empty counts as one we create, since there is no shape to preserve;
 280    // one with content keeps the ending it has, missing ending included, because
 281    // that shape belongs to whoever wrote it.
 93282    if (existingContent === null || existingContent === '') {
 20283      const created = this.renderAssignments(entries).join('\n');
 20284      return created === '' ? created : `${created}\n`;
 285    }
 286
 287    // The body keeps its own line breaks: rewriting them would also rewrite the
 288    // line breaks a multiline value carries as payload.
 289    const trailingNewline =
 73290      TRAILING_NEWLINE_PATTERN.exec(existingContent)?.[0] ?? '';
 93291    const body = existingContent.slice(
 292      0,
 293      existingContent.length - trailingNewline.length,
 294    );
 93295    const newline = this.detectStructuralNewline(body, trailingNewline);
 296
 93297    const updatedKeys = new Set<string>();
 93298    const merged = body.replace(
 299      ASSIGNMENT_PATTERN,
 300      (
 301        assignment: string,
 302        prefix: string,
 303        key: string,
 304        separator: string,
 305        rawValue: string | undefined,
 306        padding: string,
 307        comment: string,
 308      ) => {
 169309        if (!Object.hasOwn(envVariables, key)) {
 81310          return assignment;
 311        }
 88312        updatedKeys.add(key);
 313        // An assignment that already says the right thing is left exactly as
 314        // it is. The CLI hands us every key it read, not just the mapped ones,
 315        // so rewriting on sight would reserialize untouched entries and churn
 316        // bytes no reader can see: a CRLF carried inside a value, a span that
 317        // spreads over two physical lines. Leaving them also makes a run with
 318        // nothing to change produce a byte-identical file.
 88319        if (this.parsesBackTo(key, rawValue ?? '', envVariables[key])) {
 5320          return assignment;
 321        }
 322        // dotenv's `=\s*?` is lazy, so the blanks after it belong to the value
 323        // group. Put back the ones that stayed on the line, which is the
 324        // spacing the file chose; a value that only began on a later physical
 325        // line collapses onto the key's line instead, because that is how the
 326        // old one leaves.
 83327        const leading = /^\s*/.exec(rawValue ?? '')?.[0] ?? '';
 169328        const spacing = /[\r\n]/.test(leading) ? '' : leading;
 169329        const value = this.serializeAssignmentValue(
 330          key,
 331          envVariables[key],
 332          rawValue ?? '',
 333        );
 169334        return `${prefix}${key}${separator}${spacing}${value}${padding}${comment}`;
 335      },
 336    );
 337
 93338    this.assertNoManagedAssignmentSurvives(merged, envVariables);
 93339    const appended = this.renderAssignments(
 94340      entries.filter(([key]) => !updatedKeys.has(key)),
 341    );
 93342    const content = [merged, ...appended].join(newline);
 93343    return trailingNewline === '' ? content : content + trailingNewline;
 344  }
 345
 346  /**
 347   * The break that separates two assignments, which is the only kind that is
 348   * structural. Blanking the claimed spans leaves exactly those: a break
 349   * carried inside a value is part of a span and disappears with it, while a
 350   * break that precedes a span is kept, because a span starts on its key's own
 351   * line and never holds the break before it. The terminator that closes the
 352   * file is structural by the same argument and wins when the file has one;
 353   * scanning the raw text for any CRLF is the last resort, and only a guess.
 354   */
 355  private detectStructuralNewline(
 356    body: string,
 357    trailingNewline: string,
 358  ): string {
 73359    if (trailingNewline !== '') {
 34360      return trailingNewline;
 361    }
 362    // Everything a span holds, line breaks included, is payload and goes.
 39363    const betweenSpans = body.replace(
 364      ASSIGNMENT_PATTERN,
 91365      (assignment: string) => ' '.repeat(assignment.length),
 366    );
 39367    return (
 368      /\r\n|[\r\n]/.exec(betweenSpans)?.[0] ??
 369      (body.includes('\r\n') ? '\r\n' : '\n')
 370    );
 371  }
 372
 373  /**
 374   * That the pattern and dotenv agree on where an assignment ends is an
 375   * invariant, so assert it rather than assume it. Whatever the merge pass did
 376   * not claim is read the way dotenv reads it, and a managed key still in there
 377   * is an assignment dotenv can see and we missed. Appending the new value
 378   * would satisfy a reader, because dotenv keeps the last duplicate, while the
 379   * previous secret stayed on disk and `assertValuesArePreserved` saw nothing
 380   * wrong, so refuse the write instead.
 381   *
 382   * No supported syntax reaches this today: the grammar is shared with dotenv
 383   * precisely so that none can, and every form that once did — duplicates, a
 384   * colon separator, a value starting on the next physical line — is claimed
 385   * and replaced. This is the net for the day the two drift apart, which a
 386   * change to either pattern or to {@link parseEnvFile} leaves open.
 387   */
 388  private assertNoManagedAssignmentSurvives(
 389    merged: string,
 390    envVariables: Record<string, string>,
 391  ): void {
 392    // Blank the claimed spans in place: dropping them would join the text
 393    // around them and invent assignments that were never there.
 73394    const remainder = merged.replace(ASSIGNMENT_PATTERN, (assignment) =>
 169395      assignment.replace(/[^\r\n]/g, ' '),
 396    );
 73397    const stale = Object.keys(parseEnvFile(remainder))
 0398      .filter((key) => Object.hasOwn(envVariables, key))
 0399      .map((key) => `"${key}"`);
 73400    if (stale.length === 0) {
 73401      return;
 402    }
 403    // Names only: the values at stake are the secrets this guard protects.
 0404    throw new EnvironmentFileError(
 405      `Cannot locate every existing assignment for ${stale.join(', ')}; the previous value would stay in the file, so no
 406    );
 407  }
 408
 409  private renderAssignments(entries: Array<[string, string]>): string[] {
 93410    return entries.map(
 43411      ([key, value]) => `${key}=${this.serializeAssignmentValue(key, value)}`,
 412    );
 413  }
 414
 415  /**
 416   * Serializes a value for a single `.env` assignment. Candidate
 417   * representations are tried in order of preference and accepted only once
 418   * {@link parseEnvFile} confirms the assignment yields exactly the intended
 419   * key/value, never by inspection alone. An existing delimiter (from
 420   * `originalValue`, when updating an existing assignment) is preferred but
 421   * only kept when it still round-trips for the new value.
 422   */
 423  private serializeAssignmentValue(
 424    key: string,
 425    value: string,
 426    originalValue?: string,
 427  ): string {
 126428    const preferredQuote = this.detectExistingQuote(originalValue);
 126429    for (const candidate of this.buildValueCandidates(value, preferredQuote)) {
 135430      if (this.parsesBackTo(key, candidate, value)) {
 125431        return candidate;
 432      }
 433    }
 1434    throw new EnvironmentFileError(
 435      `Cannot represent the value for "${key}" in a dotenv-compatible format without data loss`,
 436    );
 437  }
 438
 439  private buildValueCandidates(
 440    value: string,
 441    preferredQuote: EnvQuote | undefined,
 442  ): string[] {
 126443    const hasLineBreak = /[\r\n]/.test(value);
 444    // Double quotes come first for multiline values because they are the only
 445    // delimiter whose escapes keep the assignment on one physical line.
 126446    const quotes: readonly EnvQuote[] = hasLineBreak
 447      ? ['"', "'", '`']
 448      : ENV_QUOTES;
 126449    const candidates: string[] = [];
 126450    if (preferredQuote !== undefined) {
 42451      candidates.push(...this.buildQuotedCandidates(value, preferredQuote));
 452    }
 126453    if (!hasLineBreak) {
 120454      candidates.push(value);
 455    }
 126456    for (const quote of quotes) {
 378457      candidates.push(...this.buildQuotedCandidates(value, quote));
 458    }
 126459    return candidates;
 460  }
 461
 462  private buildQuotedCandidates(value: string, quote: EnvQuote): string[] {
 420463    if (!this.isDelimiterSafe(value, quote)) {
 18464      return [];
 465    }
 402466    const raw = `${quote}${value}${quote}`;
 402467    if (quote !== '"') {
 266468      return [raw];
 469    }
 470    // dotenv expands `\n`/`\r` only inside double quotes, so the encoded form
 471    // is the one that survives real line breaks and the raw form is the one
 472    // that survives values holding those sequences literally.
 136473    return [`"${this.encodeLineBreaks(value)}"`, raw];
 474  }
 475
 476  /**
 477   * dotenv consumes `\<delimiter>` as a single unit inside a quoted value, so
 478   * an escaped-looking delimiter stays literal while a bare one would close
 479   * the value early.
 480   */
 481  private isDelimiterSafe(value: string, quote: EnvQuote): boolean {
 420482    for (
 420483      let index = value.indexOf(quote);
 484      index !== -1;
 485      index = value.indexOf(quote, index + 1)
 486    ) {
 20487      if (value[index - 1] !== '\\') {
 18488        return false;
 489      }
 490    }
 402491    return true;
 492  }
 493
 494  private encodeLineBreaks(value: string): string {
 136495    return value.replace(/[\r\n]/g, (match) =>
 6496      match === '\n' ? '\\n' : '\\r',
 497    );
 498  }
 499
 500  private detectExistingQuote(
 501    originalValue: string | undefined,
 502  ): EnvQuote | undefined {
 126503    if (originalValue === undefined) {
 43504      return undefined;
 505    }
 83506    const trimmed = originalValue.trim();
 83507    const quote = trimmed[0];
 508    const isQuoted =
 83509      trimmed.length >= 2 &&
 510      (ENV_QUOTES as readonly string[]).includes(quote) &&
 511      trimmed[trimmed.length - 1] === quote;
 126512    return isQuoted ? (quote as EnvQuote) : undefined;
 513  }
 514
 515  private parsesBackTo(
 516    key: string,
 517    candidate: string,
 518    expected: string,
 519  ): boolean {
 223520    const parsed = parseEnvFile(`${key}=${candidate}\n`);
 223521    const parsedKeys = Object.keys(parsed);
 223522    return (
 523      parsedKeys.length === 1 &&
 524      parsedKeys[0] === key &&
 525      parsed[key] === expected
 526    );
 527  }
 528
 529  /** Keys the file no longer reads back as the value they are meant to hold. */
 530  private keysThatLostTheirValue(
 531    parsed: Record<string, string>,
 532    expected: Record<string, string>,
 533  ): string[] {
 208534    return Object.keys(expected).filter((key) => parsed[key] !== expected[key]);
 535  }
 536
 537  /**
 538   * Keys the file gained. Nobody asked for them, so they can only come from our
 539   * grammar and dotenv's disagreeing about where some value ended.
 540   */
 541  private keysThatAppeared(
 542    parsed: Record<string, string>,
 543    ...accounted: Array<Record<string, string>>
 544  ): string[] {
 92545    return Object.keys(parsed).filter(
 289546      (key) => !accounted.some((group) => Object.hasOwn(group, key)),
 547    );
 548  }
 549
 550  /**
 551   * The values the file already held for keys this run is not writing. Read
 552   * through {@link parseEnvFile} so duplicate keys collapse the same way a consumer
 553   * would see them.
 554   */
 555  private collectUnmanagedValues(
 556    existingContent: string | null,
 557    envVariables: Record<string, string>,
 558  ): Record<string, string> {
 93559    if (existingContent === null) {
 18560      return {};
 561    }
 75562    const unmanaged: Record<string, string> = {};
 75563    for (const [key, value] of Object.entries(parseEnvFile(existingContent))) {
 167564      if (!Object.hasOwn(envVariables, key)) {
 81565        unmanaged[key] = value;
 566      }
 567    }
 75568    return unmanaged;
 569  }
 570
 571  /**
 572   * Reads the result back with dotenv before it reaches disk: every managed key
 573   * must yield the value we were asked to store, every key we did not manage
 574   * must yield the value the file already had, and no third key may appear
 575   * because our grammar and dotenv's disagreed on where a value ended.
 576   */
 577  private assertValuesArePreserved(
 578    content: string,
 579    envVariables: Record<string, string>,
 580    unmanagedValues: Record<string, string>,
 581  ): void {
 92582    const parsed = parseEnvFile(content);
 92583    const affectedKeys = new Set([
 584      ...this.keysThatLostTheirValue(parsed, envVariables),
 585      ...this.keysThatLostTheirValue(parsed, unmanagedValues),
 586      ...this.keysThatAppeared(parsed, envVariables, unmanagedValues),
 587    ]);
 92588    if (affectedKeys.size === 0) {
 92589      return;
 590    }
 591    // Names only: the values at stake are the secrets this guard protects.
 0592    const keys = [...affectedKeys].map((key) => `"${key}"`).join(', ');
 0593    throw new EnvironmentFileError(
 594      `Cannot write the environment file without losing or altering ${keys}; nothing was written`,
 595    );
 596  }
 597}
 598
 599function isErrnoCode(error: unknown, code: string): boolean {
 6600  return (
 601    error instanceof Error && (error as NodeJS.ErrnoException).code === code
 602  );
 603}
 604
 605export async function readMapFileConfig(
 606  mapPath: string,
 607): Promise<MapFileConfig> {
 17608  try {
 17609    const content = await fs.readFile(mapPath, 'utf-8');
 16610    try {
 16611      const raw = JSON.parse(content);
 16612      const config = raw.$config;
 16613      return config && typeof config === 'object' ? config : {};
 614    } catch {
 1615      throw new EnvironmentFileError(
 616        `Invalid JSON in parameter map file: ${mapPath}`,
 617      );
 618    }
 619  } catch (error) {
 2620    if (error instanceof EnvironmentFileError) {
 1621      throw error;
 622    }
 1623    throw new EnvironmentFileError(`Failed to read map file: ${mapPath}`);
 624  }
 625}