Skip to content

feat: local override map file (envilder.local.json) across CLI, GHA, and SDKs #385

Description

@macalbert

Summary

Introduce a local override map file: a personal, gitignored layer placed on
top of a committed base map file. The same mental model as dotenv's
.env.local, applied to Envilder map files.

Design is recorded in ADR-0011.
This issue is the implementation tracker and the full specification.

Motivation

A committed envilder.json is the shared, PR-reviewed contract. But some values
are per-developer, not per-project. The driving case is the AWS profile:

  • A committed $config.profile forces every developer onto one named profile.
  • Because $config.profile takes precedence over the AWS_PROFILE environment
    variable in
    AwsSecretProviderFactory.ts,
    a hardcoded profile actively defeats each developer's own credentials.

Beyond the profile, developers occasionally need to redirect a single mapping to
a personal secret path, switch provider locally, or add a machine-only variable —
without editing the shared file or polluting a PR.

Existing narrower mechanisms do not cover the general case:

  1. AWS_PROFILE resolves only the profile (once the committed file stops
    hardcoding it).
  2. "One map file per environment + --map" selects a whole file but offers no
    layering.

The golden rule

For any base map file XXXXX.json, if a sibling XXXXX.local.json exists in
the same directory, it is always layered on top.

  • Name is derived from the base by inserting .local before the extension:
    • envilder.jsonenvilder.local.json
    • config/prod.jsonconfig/prod.local.json
  • The override file has no override of its own (XXXXX.local.local.json is
    never sought).
  • When the override file is absent, behavior is unchanged (silent).

Layering semantics

The two sections of a map file merge differently because they have different
shapes:

$config — replaced wholesale

A $config describes one coherent connection to one provider. Merging it
field-by-field produces incoherent mixes (e.g. an Azure vaultUrl next to a
leftover AWS profile).

  • If the override carries a $config → it replaces the base's entirely.
  • If the override has no $config → the base's $config stands.

Variable mappings — merged per key

Mappings are independent VAR → path entries.

  • An override key replaces the same base key.
  • Base-only keys are kept.
  • Override-only keys are added.

Worked example

// envilder.json (committed)
{ "$config": { "provider": "aws", "profile": "mac" },
  "DB_URL": "/app/db", "API_KEY": "/app/api" }

// envilder.local.json (personal, gitignored)
{ "$config": { "provider": "aws", "profile": "laura" },
  "DB_URL": "/app/db-laura" }

// effective result
{ "$config": { "provider": "aws", "profile": "laura" }, // $config replaced wholesale
  "DB_URL": "/app/db-laura",                             // mapping overridden
  "API_KEY": "/app/api" }                                // mapping inherited from base

Precedence

CLI flag  >  local override  >  base

A CLI flag (--profile, --provider, --vault-url) is an explicit one-off and
must beat the per-machine default. This preserves the existing flags > $config
rule and inserts the local layer between flags and base.

An incoherent result (e.g. the override forces Azure while --profile is
passed) is rejected by the existing cross-provider validation, never silently
merged.

Scope: all surfaces

The override applies in the CLI, the GitHub Action, and every runtime
SDK
(.NET, Python, Node.js). One mental model — "if the file exists, it wins" —
regardless of how the code runs. This matches the dotenv .env.local precedent,
which auto-loads across CLIs, frameworks, and running apps alike.

Guardrails (mandatory)

The override can redirect which cloud identity and which secrets load — not
merely override a literal value as dotenv does. Three guardrails are required:

  1. Visible warning on every application. e.g.
    ⚠ local override applied: envilder.local.json. In the SDKs this is a
    deliberate exception to the silent-by-default rule (ADR-0003 / ADR-0010):
    a silent identity redirect is worse than a log line, and the warning is what
    makes an accidental leak detectable.
  2. Cross-language opt-out ENVILDER_NO_LOCAL. Setting it disables override
    resolution deterministically. CI/production use it as a kill switch. A single,
    uniform mechanism across all four surfaces.
  3. .dockerignore documented as a hard requirement for *.local.json
    because .gitignore does not protect built artifacts (a COPY . .
    without .dockerignore would bake a developer's local override into a
    production image).

No environment auto-detection (NODE_ENV / ASPNETCORE_ENVIRONMENT): there
is no uniform cross-language "production" signal, and it would reintroduce the
environment-selection question this feature deliberately avoids. Trigger is file
presence; opt-out is explicit.

Phase 0 — prerequisite quick wins (independent of the override)

These resolve the original profile pain cheaply and should land first (they may
already be partly covered by #384):

  • Remove the hardcoded "profile": "mac" from the committed
    envilder.json so AWS_PROFILE / the local override drive the
    per-developer profile.
  • Surface a missing/non-existent AWS profile as a typed, actionable error
    (ADR-0010 pattern) — never a silent fallback to the default profile.

Implementation plan (vertical slices)

Implement as tracer bullets; CLI first (git-protected), then replicate the shared
convention to each SDK.

  • Slice 1 — CLI: derive XXXXX.local.json from --map, layer it
    ($config wholesale, mappings per key), apply flag > local > base, emit the
    warning, honor ENVILDER_NO_LOCAL. e2e test (LocalStack) proving an overridden
    path from .local wins.
  • Slice 2 — GitHub Action: same resolution wired through the shared
    ContainerConfiguration path.
  • Slice 3 — Node.js SDK: layering in MapFileParser / facade + warning.
  • Slice 4 — Python SDK: same convention.
  • Slice 5 — .NET SDK: same convention.
  • Docs: README mapping section, website i18n keys, .gitignore +
    .dockerignore guidance, changelog entries per surface.

Acceptance criteria

  • With envilder.local.json present, $config is replaced wholesale and
    mappings merge per key, on CLI / GHA / each SDK.
  • Precedence is flag > local > base; cross-provider incoherence fails
    validation with a clear message.
  • Absent override file → unchanged behavior, no output.
  • A warning is emitted whenever an override is applied (including SDKs).
  • ENVILDER_NO_LOCAL=1 fully disables resolution on all surfaces.
  • *.local.json added to .gitignore; .dockerignore requirement documented.
  • Tests follow Should_<Expected>_When_<Condition> + AAA per stack.

Risk accepted (see ADR-0011 Consequences)

If a *.local.json leaks into a built artifact (missing .dockerignore), an SDK
will apply it in production. This is the same risk the dotenv .env.local
ecosystem accepts; mitigated (not eliminated) by the mandatory warning, the
opt-out, and documented .dockerignore guidance.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions