Back to all posts

How I Taught Voiden's CLI to Read YAML Env Files — My First Open-Source Contribution

My first open-source contribution: Voiden's runner claimed to support YAML env files but silently broke on them. Here's how I traced the bug, fixed it cleanly, and what shipping it taught me about testing and reading code.

Syed Suhail Ahmed

Syed Suhail Ahmed

Aug 3, 20264 min read

How I Taught Voiden's CLI to Read YAML Env Files — My First Open-Source Contribution

Six months ago, opening a pull request against a project I didn't build felt intimidating. Last week, I watched my first one get merged — and the fix turned out to be a perfect little lesson in reading code carefully, trusting tests, and shipping something small but real. Here's the whole story.

What Voiden is

Voiden is a free, offline-first, Git-native API client — think a keyboard-first, zero-bloat alternative to Postman where your requests live as files in your repo. It ships a CLI runner so you can fire requests from the terminal, and that runner takes an --env flag to load environment variables from a file.

The bug: a promise the code didn't keep

The run --env command advertised support for two formats: classic .env files and .yaml files. But if you actually pointed it at a YAML file, it blew up:

Malformed line ... missing '='

That error was the giveaway. The loadEnvFile function was treating every file as dotenv format — reading it line by line and expecting KEY=value. A YAML file like KEY: value has no =, so the parser choked. The docs said one thing; the code did another. That gap is exactly the kind of first issue I love: clearly defined, reproducible, and satisfying to close.

Tracing it

I reproduced it first made a tiny .yaml env file, ran the command, watched it fail. Then I followed the stack into loadEnvFile and saw the root cause immediately: there was no branching on file type at all. Everything funneled through the dotenv path.

The tricky part wasn't the parsing — it was that Voiden's env values aren't always flat. They can be a nested tree of variables and children, where child scopes inherit and override their parents. Any fix had to flatten that structure the same way the rest of the runner expected.

The fix

I pulled the logic out into its own envFile.ts module (small, pure, easy to test) and made it dispatch on the file extension:

// envFile.ts
import { readFileSync } from "fs";
import { extname } from "path";
import YAML from "yaml";

interface YamlEnvNode {
  variables?: Record<string, unknown>;
  children?: YamlEnvNode[];
}

export function loadEnvFile(envPath: string): Record<string, string> {
  const raw = readFileSync(envPath, "utf8");
  const ext = extname(envPath).toLowerCase();

  // .yaml / .yml → parse + flatten;  anything else → dotenv (unchanged)
  return ext === ".yaml" || ext === ".yml"
    ? parseYamlEnv(raw)
    : parseDotenv(raw);
}

// Flatten Voiden's nested { variables, children } tree into a flat map.
// Children are visited after their parent, so child values override inherited ones.
function parseYamlEnv(raw: string): Record<string, string> {
  const env: Record<string, string> = {};

  const collect = (node: YamlEnvNode) => {
    for (const [key, value] of Object.entries(node.variables ?? {})) {
      if (value != null) env[key] = String(value);
    }
    node.children?.forEach(collect);
  };

  collect((YAML.parse(raw) ?? {}) as YamlEnvNode);
  return env;
}

Two things I was deliberate about: .env behavior stayed byte-for-byte the same (never break the working path), and the YAML branch handled both the simple flat case and the nested-tree case with inheritance.

The part that made it trustworthy: tests

Extracting envFile.ts wasn't just tidiness — it made the logic testable in isolation. I added 11 regression tests covering the stuff that breaks in real life:

  • Dotenv parsing, including malformed lines and empty keys

  • Flat YAML maps

  • Nested variables/children hierarchies with child overrides

  • Extension variants — .yml, uppercase .YAML

  • Edge cases: scalar coercion, null values, non-mapping roots, and empty files

Writing those tests changed how I felt about the PR. Instead of "I think this works," I could say "here's proof it works, and here's proof it won't regress."

What it taught me

  • Reproduce before you fix. The failing error message pointed straight at the root cause. Five minutes of reproducing saved an hour of guessing.

  • Don't break the path that works. Adding YAML support meant leaving .env completely untouched. New behavior shouldn't come at the cost of old behavior.

  • Extract to test. Moving logic into its own module turned an untestable function into a well-covered one — and reviewers trust code that comes with tests.

  • Tests are documentation. Those 11 cases now describe exactly how env parsing is supposed to behave, for the next person (maybe future me).

  • Respect the project's conventions. Conventional Commit message (fix: …), the right target branch, a focused diff — small signals that make a maintainer's job easy and your PR easy to say yes to.

Merged

phurpa-tsering merged it on July 30th. A small fix — a handful of files, one new module, eleven tests — but it closed a real gap between what Voiden promised and what it did. More than the diff, it taught me that a good contribution isn't about size. It's about leaving the codebase a little more honest than you found it.

If you're sitting on your first contribution: find the issue where the docs and the code disagree. Reproduce it, fix it narrowly, cover it with tests. That's a great place to start.

Subscribe for new contributions

Get an email when I publish a new open-source write-up — how I approached the issue, the code, and lessons learned. No spam.