Files, Environment Variables, and Small Automations

Published Updated

By the end of this page you will be able to read and write files from a script, list a folder and pick out the files you want, build paths that survive being run from the wrong place, keep secrets out of your code with environment variables, and run one worked bulk edit safely.

This is where Node stops being a curiosity and starts saving you an afternoon. Every example ran on Node 24.16.0 against real scratch files, and the bulk edit at the end is shown with the files before and after it touched them.

Reading and Writing a File

The promise-based file interface lives at node:fs/promises, and the node: prefix says plainly that this is a built-in rather than something installed. Two functions cover most work:

import { writeFile, readFile } from "node:fs/promises";

await writeFile("notes.txt", "First line\nSecond line\n", "utf8");
const text = await readFile("notes.txt", "utf8");

console.log("characters read:", text.length);
console.log(text);
// node 24.16.0 output:
// characters read: 23
// First line
// Second line

The "utf8" argument is what makes readFile return text. Leave it out and you get a buffer of bytes instead, which is right for images and wrong for anything you meant to read.

writeFile replaces the whole file and creates it if it is not there, so it never appends by accident. Use appendFile when adding to the end is what you want. Both are promises, which is why every line above is awaited, and the promises guide covers what await is doing.

When the File Is Not There

A missing file rejects the promise, so an unhandled one ends the script. Catching it lets you say something useful instead:

import { readFile } from "node:fs/promises";

try {
  await readFile("does-not-exist.txt", "utf8");
} catch (error) {
  console.log("code:", error.code);
  console.log("message:", error.message);
}
// node 24.16.0 output:
// code: ENOENT
// message: ENOENT: no such file or directory, open 'does-not-exist.txt'

That code property is the part to branch on. ENOENT means no such file, and it is a stable identifier, whereas the message wording is for humans and is not a contract.

Knowing the code lets you separate the case you expected from the case you did not. A missing configuration file might reasonably fall back to defaults, while a permission error should stop the script and say so.

Listing a Folder

readdir gives you the names inside a folder as plain strings, which array methods then filter:

import { readdir } from "node:fs/promises";

const entries = await readdir("inbox");
console.log("everything:", entries);
console.log("markdown only:", entries.filter((name) => name.endsWith(".md")));
// node 24.16.0 output:
// everything: [ 'notes.txt', 'one.md', 'two.md' ]
// markdown only: [ 'one.md', 'two.md' ]

Two details matter more than they look. The names are names, not paths, so joining them to the folder is your job before you can open one. And the order is whatever the file system reports, not a guaranteed alphabetical sort, so call sort() when the order is part of what you promise.

readdir also does not descend into subfolders unless you ask, with its recursive option, which was added in Node 18.17 and 20.1. Starting without it is usually the safer choice, because a script that edits files should be explicit about how far it reaches.

Paths and the Folder You Happen to Be In

This is the single most common reason a working script suddenly stops working. A relative path such as "inbox" is resolved against the folder the terminal is in, which is not necessarily the folder the script is in:

import { join, resolve, extname, basename } from "node:path";

console.log("cwd:", process.cwd());
console.log("this file's folder:", import.meta.dirname);
console.log("join:", join("inbox", "one.md"));
console.log("beside this file:", join(import.meta.dirname, "inbox", "one.md"));
console.log("extname:", extname("report.final.md"));
console.log("basename:", basename("inbox/one.md", ".md"));
// node 24.16.0 output, run from inside the script's own folder:
// cwd: /private/tmp/example/fsdemo
// this file's folder: /private/tmp/example/fsdemo
// join: inbox/one.md
// beside this file: /private/tmp/example/fsdemo/inbox/one.md
// extname: .md
// basename: one

Run the identical file from one folder up and only the first line changes, to /private/tmp/example. The script's own folder stays put, which is exactly why it is the reliable anchor.

A script that reads "inbox" relatively fails from that second location, and the failure is the same ENOENT as a genuinely missing folder:

// node 24.16.0 output, running the folder-listing script from one level up
// (working directory /private/tmp/example, script in /private/tmp/example/fsdemo):
// Error: ENOENT: no such file or directory, scandir 'inbox'
// exit code: 1

The rule that avoids all of this: build paths from import.meta.dirname when the target belongs to the script, and take them from an argument when the target belongs to whoever is running it. Use join rather than gluing strings together with slashes, since it handles the separators for you.

One boundary on that anchor. import.meta.dirname exists only in ES modules loaded over the file: protocol, arrived in Node 20.11 and 21.2, and stopped being experimental in 22.16 and 24.0. In CommonJS the equivalent is the long-standing __dirname.

Environment Variables and .env Files

Environment variables are values handed to a program by whatever started it, and they are how a key stays out of your source code. In Node they arrive on process.env:

console.log("API_KEY:", process.env.API_KEY);
console.log("OUTPUT_DIR:", process.env.OUTPUT_DIR ?? "reports");
// node 24.16.0 output, with nothing set:
// API_KEY: undefined
// OUTPUT_DIR: reports

// and set for one run only, as: API_KEY=abc123 node env.js
// API_KEY: abc123
// OUTPUT_DIR: reports

A missing variable is undefined rather than an error, which is why the default in the second line uses ??. Setting one in front of the command applies to that run alone, and the next run sees nothing again.

That prefix form is POSIX shell syntax, so macOS and Linux only. PowerShell has no per-command prefix: set $env:API_KEY = "abc123" first, and remove it afterwards with Remove-Item Env:\API_KEY if it should not linger in the session.

Typing them every time gets old, so Node reads a file of key and value pairs directly. No package is needed for this:

API_KEY=from-the-env-file
OUTPUT_DIR=build/reports
// node 24.16.0 output, run as: node --env-file=.env env.js
// API_KEY: from-the-env-file
// OUTPUT_DIR: build/reports

One behaviour is worth knowing before it confuses you. A value already set in the environment wins over the file: running the same command with API_KEY=from-the-shell in front of it prints the shell value while still taking OUTPUT_DIR from the file.

That precedence is documented, and it lets the launching environment override a file-supplied value. You can also pass --env-file more than once, and later files override earlier ones, which was confirmed here on 24.16.0.

--env-file arrived in Node 20.6. A missing file is fatal rather than ignored; the exit code observed on Node 24.16.0 was 9, which Node documents generically as an invalid argument rather than a guaranteed value for this case.

Use --env-file-if-exists when the file is genuinely optional, which prints a note and continues with the variable unset. It arrived in Node 22.9 and was backported to later Node 20 releases, so run node --help if you are on an older line and need to know. And add .env to your gitignore file before you put anything real in it.

A Complete Bulk Edit Script

Here is everything above in one job worth doing: rewriting insecure links across a folder of Markdown files. It takes the folder as an argument, supports a dry run, and reports what it did:

import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

const folder = process.argv[2];
const dryRun = process.argv.includes("--dry-run");

if (!folder) {
  console.error("Usage: node fix-links.js <folder> [--dry-run]");
  process.exit(1);
}

const names = (await readdir(folder)).filter((name) => name.endsWith(".md"));
let changed = 0;

for (const name of names.sort()) {
  const path = join(folder, name);
  const before = await readFile(path, "utf8");
  const after = before.replaceAll("http://codewalkers.dev", "https://codewalkers.dev");

  if (before === after) {
    console.log("unchanged:", name);
    continue;
  }

  changed += 1;
  if (dryRun) {
    console.log("would update:", name);
  } else {
    await writeFile(path, after, "utf8");
    console.log("updated:", name);
  }
}

console.log(`${changed} of ${names.length} files ${dryRun ? "would change" : "changed"}`);

Run the preview first. Three scratch files went in, two of them containing the old link:

// node 24.16.0 output, run as: node fix-links.js posts --dry-run
// would update: first.md
// would update: second.md
// unchanged: third.md
// 2 of 3 files would change
// exit code: 0

The files were then checked and still held the old links, which is the point of the dry run. Running it without the flag rewrote them:

// node 24.16.0 output, run as: node fix-links.js posts
// updated: first.md
// updated: second.md
// unchanged: third.md
// 2 of 3 files changed

// first.md before:  Read more at http://codewalkers.dev/start.
// first.md after:   Read more at https://codewalkers.dev/start.

Running it a second time reported all three unchanged and zero of three changed, which is worth more than it sounds. A script safe to run twice is a script you can rerun after fixing one file by hand.

Three habits are doing the work here, and they transfer to every automation you write. Fail loudly with a usage message and a non-zero exit code when the input is missing.

Compare before and after rather than writing unconditionally, so unchanged files stay untouched. And say what happened, per file and in total.

Pitfalls and Debugging

ENOENT on a path you can see in your editor. The likeliest cause is the terminal being somewhere else, since relative paths follow the terminal. Print process.cwd() at the top of the script to confirm before changing anything else.

readFile returned something that is not text. The encoding argument was left out, so you have a buffer. Pass "utf8", or call toString("utf8") on what came back.

writeFile emptied the file. It replaces the entire contents, so writing a variable that was never populated leaves nothing. Read, transform, then write, and use appendFile when adding to the end is the intent.

The script ate a file it should not have. The filter was too broad or reached deeper than expected. Run against a copy first, add the dry run before the write, and leave recursive off unless you meant it.

The env file was loaded and the value is still the old one. Something in the shell already set that name, and the existing value wins. Check with the same script that prints it, in a shell where you have not set it.

The script exited without running and blamed an argument. That is a missing --env-file, not your code; the code observed here on 24.16.0 was 9. Check the filename after the flag, or switch to the if-exists form when the file is optional.

Frequently Asked Questions

Why does the script work in the editor but not from another folder?

A relative path is resolved against the folder the terminal is in, not the folder the file lives in. Running the same script from one level up therefore looks for the data somewhere it is not. Build paths from import.meta.dirname when the target sits beside the script, which ES modules have had since Node 20.11.

Should the env file be committed?

No. It exists precisely to hold the values you do not want in the repository, so add it to your gitignore file before you put anything real in it. Commit an example file listing the names with placeholder values instead, so the next person knows what to supply.

Do you still need a package for env files?

Not for the ordinary case. Node reads a file of key and value pairs itself when you pass the env-file flag, and repeating the flag layers several files, with later ones winning. A package is still worth it if you need variable expansion inside the file, validation of what was loaded, or different loading semantics.

Is a dry run worth the extra code?

For anything that rewrites files, yes, and it is usually a handful of lines. It turns an irreversible step into a preview you can read before committing to it. The habit matters most on the runs where you were confident, because those are the ones nobody checks first.

Sources

  1. [1]
    File System
    (nodejs.org)
  2. [2]
    Path
    (nodejs.org)
  3. [3]
    Command-Line API
    (nodejs.org)