Background
I run a couple of dozen small sites on autopilot. Along the way I accumulated close to fifty small maintenance scripts: decide which site is due for an update today, lint the built HTML for emoji, write results back to a state file, track sitemap indexing, and so on. None of them need an LLM. They are plain deterministic Node scripts.
They lived in a tree under automation/scripts/, each with its own path. That was fine until I started invoking them from an agent running unattended. Every distinct command shape needed its own permission rule, and the moment one was missing the batch stopped at a prompt and sat there until morning.
How it works
Instead of tuning the permission rules, I changed the shape of the commands. All the scripts now sit behind a single entry point.
node automation/run.js <op> [--json '<JSON>'] [--flag value]
run.js looks the op name up in a table and spawns the matching script. Nothing else.
run.js ── OPS table ──> scripts/whats-due.js
└─> scripts/common/emoji-lint.js
└─> scripts/seo/orphan-check.js
... (around 50 ops today)
One command shape means one permission rule: node automation/run.js:*.
Implementation
The core is the lookup table, and the important part is what happens when the lookup fails.
const OPS = {
"whats-due": "whats-due.js",
"update-state": "common/update-state.js",
"emoji-lint": "common/emoji-lint.js",
"seo:orphan": "seo/orphan-check.js",
// ...
};
const rel = OPS[op];
if (!rel) {
process.stderr.write(
`unknown op: ${op}\n利用可能: ${Object.keys(OPS).join(", ")}\n`,
);
process.exit(2);
}
There is no path built from user input. An op that is not in the table exits with code 2 and prints the list of valid ops. So the effective permission surface is the table, which lives in the repository and can be reviewed.
The second piece is expanding a JSON object into flags, so that every call has the same shape no matter how many arguments the target script takes.
function jsonToArgv(obj) {
const out = [];
for (const [k, v] of Object.entries(obj || {})) {
if (k === "_") { // positional arguments pass through
(Array.isArray(v) ? v : [v]).forEach((x) => out.push(String(x)));
continue;
}
if (v === false || v === null || v === undefined) continue;
if (v === true) out.push(`--${k}`); // {"deploy":true} -> --deploy
else if (Array.isArray(v)) out.push(`--${k}`, v.join(",")); // {"episodes":[1,2]} -> --episodes 1,2
else out.push(`--${k}`, String(v));
}
return out;
}
Which makes the call sites look like this:
node automation/run.js emoji-lint --json '{"ext":"html","_":["public"]}'
node automation/run.js update-state --json '{"site":"cve-watch","added-cves":["CVE-2026-1"]}'
Gotchas
I nearly added automatic camelCase to kebab-case conversion, so that addedCves would become --added-cves. I backed that out. The flag names of the target scripts are chosen individually and a mechanical rule does not always match them. When the conversion misses, the flag is silently dropped instead of failing, and a silent drop is the worst failure mode for an unattended batch. Writing the exact flag name is slightly more work and it fails loudly when you get it wrong.
The other trap was on my side rather than the code's. Once the entry point existed, it was tempting to route everything through it. I drew a line: operations that publish, spend money, touch DNS or write secrets do not get an op. A narrow entry point stops meaning anything if you can do everything behind it.
The result
One of the sites this drives: https://cve.autoarticles.net
Its daily update runs entirely through run.js ops, from fetching vulnerability data to the emoji lint and the state write-back.
What I actually built was a table and a thin dispatcher. The payoff was not new capability but a smaller surface: one permission rule instead of many, and the same command shape every time. When something has to run unattended, removing entry points beats adding features.
This article is about my own side project. It was written with AI assistance.
Top comments (0)