DEV Community

takahiro hashito
takahiro hashito

Posted on

Routing 95 automation scripts through one Node.js entry point

Background

I run about 20 small sites as a solo side project. Behind them sits a pile of what I call deterministic scripts: plain Node programs that produce the same output from the same input, with no LLM involved. They fetch CVE data (CVE stands for Common Vulnerabilities and Exposures — the shared identifier assigned to each publicly disclosed software vulnerability), run SEO audits, lint for stray emoji, gate builds before deploy, and wrap the deploy itself.

Once that pile grows, two problems show up on the caller's side.

  1. Nobody remembers the paths. Is it node automation/scripts/common/preflight.js or node automation/scripts/seo/audit.js? Every script sits somewhere slightly different.
  2. Shared setup cannot be applied everywhere. Say every deploy path needs a longer timeout. You have to copy that line into every script that shells out to deploy. Miss one, and that single path breaks quietly.

So I put a single entry point, automation/run.js, in front of all of them. It is 344 lines and dispatches 95 ops. This post is about how it is built.

How it works

Every invocation has the same shape:

node automation/run.js <op> [--json '<JSON>'] [--json-file <path>] [other flags]
Enter fullscreen mode Exit fullscreen mode

Internally:

node automation/run.js preflight --site coffee
        |
        v
  [ run.js ]
    1. resolve op -> script path via the OPS table
    2. expand --json / --json-file into argv
    3. inject run-time env defaults      <-- the real point
    4. spawnSync the child (stdio: inherit)
    5. exit with the child's exit code
        |
        v
  automation/scripts/common/preflight.js
Enter fullscreen mode Exit fullscreen mode

OPS is just an object literal:

const OPS = {
  "whats-due": "whats-due.js",
  "update-state": "common/update-state.js",
  "emoji-lint": "common/emoji-lint.js",
  preflight: "common/preflight.js",
  "site:deploy": "common/site-build-deploy.js",
  // ... 95 entries
};
Enter fullscreen mode Exit fullscreen mode

Names like site:deploy use a colon to namespace groups (seo:, cve:, anime:). The op name is decoupled from the directory layout, so moving a script does not change how it is called.

Implementation

1. The dispatcher

It is a table lookup plus spawnSync, and nothing else.

const rel = OPS[op];
if (!rel) {
  process.stderr.write(
    `unknown op: ${op}\navailable: ${Object.keys(OPS).join(", ")}\n`,
  );
  process.exit(2);
}

const target = path.join(SCRIPTS, rel);
const finalArgs = [target, ...opFlags, ...jsonArgs, ...passthrough];
const res = spawnSync(process.execPath, finalArgs, {
  stdio: "inherit",
  env: process.env,
});
process.exit(res.status == null ? 1 : res.status);
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

It spawns a child process instead of require()-ing the script. Each script must also work standalone (node automation/scripts/common/preflight.js --site coffee) and calls process.exit() itself. Requiring it in-process would let that exit tear down run.js too, destroying the meaning of the exit code.

It uses stdio: "inherit", so the child writes straight to the terminal with no buffering. One op — re-verifying every published CVE against primary sources — takes about 35 minutes for 299 records, and its progress lines stream out live.

The exit code is res.status == null ? 1 : res.status. status is null when the child died from a signal, so that branch exists to avoid reporting a killed process as success.

2. Run-time defaults, applied exactly once

This is the reason the single entry point earns its keep.

/* Deploy defaults go here, once.
 * Five different wrappers shell out to firebase deploy and pass env straight
 * through. Because the entry point is a single file, setting it here reaches
 * every path (no copying the same line into each wrapper). */
applyDeployEnvDefaults(process.env);
applyNetworkEnvDefaults(process.env);
Enter fullscreen mode Exit fullscreen mode

applyNetworkEnvDefaults works around a real failure on my Mac: Node's fetch (undici) picks a broken IPv6 route and every request dies, while curl works fine. If that workaround lived in each fetching script, the one script I forgot would return "zero new items" — indistinguishable from a genuinely quiet day.

This is not the same as putting applyNetworkEnvDefaults() in a shared library and calling it from each script. A library call can be forgotten. An entry point cannot be skipped, because nothing runs without passing through it.

3. When --json meant two different things

Arguments can be passed as JSON, and keys expand into flags:

// {"site":"x"}       -> --site x
// {"days":28}        -> --days 28
// {"deploy":true}    -> --deploy       (false is dropped)
// {"episodes":[1,2]} -> --episodes 1,2
// {"_":["a","b"]}    -> a b            (positional)
Enter fullscreen mode Exit fullscreen mode

That collided with existing usage. --json carried two meanings:

  • (1) pass arguments as JSON: --json '{"site":"x"}' — a run.js feature
  • (2) emit machine-readable output: --json — a flag several scripts already had

run.js always assumed (1), so the six ops documented with (2) (seo:goals, seo:triage, issues:sync, crosspost:verify, crosspost:draft-check, metrics:daily) failed with --json parse failed and exit 2 when invoked exactly as documented. One script had quietly invented a --json-out alias to dodge it, and that workaround is what exposed the bug.

The fix is to look at the next token:

const raw = argv[i + 1];
if (raw === undefined || raw.startsWith("-")) {
  passthrough.push(t);   // meaning (2): hand the flag to the child untouched
  continue;
}
i++;
jsonArgs = jsonToArgv(JSON.parse(raw));  // meaning (1): expand as arguments
Enter fullscreen mode Exit fullscreen mode

JSON always starts with { or [, so it can never be confused with a token starting with -. When you cannot remove ambiguity with types, proving the two value sets do not overlap is the cheapest way out.

In practice:

$ node automation/run.js seo:goals --json
# --json is forwarded to the child, which prints JSON output

$ node automation/run.js update-state --json '{"site":"coffee","count":1}'
# expands to --site coffee --count 1 before reaching the child
Enter fullscreen mode Exit fullscreen mode

Gotchas

Ops that double as subcommands. run:start, run:complete and run:check all call the same script in different modes. A separate OP_FLAGS table holds the flags to prepend per op. Argument order is [target, ...opFlags, ...jsonArgs, ...passthrough]; opFlags go first so a user-supplied duplicate flag wins by coming later.

Growth turns features invisible. At 95 ops, 23 of them appear in no runbook at all. A single entry point makes calling easy, but discoverability does not come for free. node automation/run.js list prints every op and its target path, which tells you the name — not how to use it. That is still an open item on the documentation side.

Assume some ops are slow. The CVE re-verification op takes roughly 35 minutes across 299 records. Because of stdio: "inherit", its progress lines ([cve:recheck] 144/299 done, 1006s elapsed) stream through untouched. A wrapper that swallows child output makes a long-running op indistinguishable from a hung one.

The result

One of the sites this pipeline updates daily: https://cve.autoarticles.net

It aggregates vulnerability information, and every step — fetching, re-verification, gating and deploy — runs through run.js.

Wrap-up

The usual pitch for a single entry point is "you stop memorizing commands." What actually paid off was being able to apply run-time behaviour to every path with certainty: env defaults, network workarounds, deploy timeouts. Put those in a shared library and someone forgets the call; the forgotten path then fails quietly. Put them at the entry point and there is no path that skips them.

The flip side is that consolidation does not solve discovery. Ninety-five ops behind one door still need a document describing what is in the room.


This article is about my own side project. It was written with AI assistance.

Top comments (0)