Lesson 14 / 14
Task Automation
Storing script definitions in the manifest file, what a script runner does, lifecycle hooks, the exit-code contract, and a reproducible command set.
Contents
Every tool set up over the course is a program invoked from the command line: the bundler, the analyzer, the formatter, the test runner. Each has its own options, its own input paths.
Keeping these commands in the developer’s memory causes two problems. Commands differ from one person to the next, and the command that runs on the build server ends up different from the one the developer ran. This lesson covers writing the commands into the project itself and binding their execution to a single contract.
Script Definitions
The manifest file’s scripts field stores the project’s commands under names:
{ "name": "text-measurer", "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.mjs" }, "scripts": { "pretest": "node --check src/index.mjs", "test": "node --test", "measure": "node tools/measure.mjs", "broken": "node --check src/broken.mjs" } }
Keeping definitions in the manifest has three consequences. Commands enter version control and their changes can be reviewed. A developer cloning the repository learns which commands exist by reading it. The same names are invoked both locally and in the build pipeline; the difference between the two environments disappears.
The naming convention matters too. A name should describe the work being done, not
the tool. The test command carries the name test; which test runner is used sits on
the definition’s right side. When the tool changes, the callers do not — command names
are the project’s interface, tool choice is the implementation.
What a Script Runner Does
The tool that runs scripts is called a script runner, and its job fits into four items. A small implementation of one makes all four visible:
// file: run.mjs import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { delimiter, resolve } from 'node:path'; const manifest = JSON.parse(readFileSync('package.json', 'utf8')); const scripts = manifest.scripts ?? {}; const [requested, ...extraArgs] = process.argv.slice(2); function runScript(name, args = []) { const command = [scripts[name], ...args].join(' '); console.log(`> ${name}: ${command}`); const result = spawnSync(command, { shell: true, stdio: 'inherit', env: { ...process.env, PATH: resolve('node_modules/.bin') + delimiter + process.env.PATH }, }); return result.status ?? 1; } if (scripts[requested] === undefined) { console.error(`no such script: ${requested}`); process.exit(1); } for (const name of [`pre${requested}`, requested, `post${requested}`]) { if (scripts[name] === undefined) continue; const code = runScript(name, name === requested ? extraArgs : []); if (code !== 0) { console.error(`stopped: script ${name} exited with code ${code}`); process.exit(code); } }
It finds the command. The name is read from the table in the manifest; if it is missing, an error is raised.
It prepares the environment. Installed packages’ executables sit in the
node_modules/.bin directory, and that directory is added to the front of the search
path. The result is that calling a tool by its command name does not require a
system-wide install; the project’s own copy is found. The lookup order introduced in
the command path lesson of the Introduction to Linux course becomes a deliberate tool
here.
It calls the hooks. Before the requested script, a same-named script with a pre
prefix runs if it exists; after it, one with a post prefix runs if it exists.
It carries the exit code. The subprocess’s code becomes the runner’s own code.
When the test script runs, three of the four show up in a single output:
$ node ../run.mjs test 2>&1 | grep -vE 'duration_ms|ms\)|^$' > pretest: node --check src/index.mjs > test: node --test ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0
The hook ran on its own, then the actual script ran. The filter drops the duration measurements from the output; durations change on every run. The test file uses the runtime’s built-in test interface:
// file: test/measurement.test.mjs import assert from 'node:assert/strict'; import test from 'node:test'; import { measure } from '../src/index.mjs'; test('word and sentence count', () => { const measurement = measure('One sentence. Two sentence.'); assert.equal(measurement.wordCount, 4); assert.equal(measurement.sentenceCount, 2); }); test('empty text returns zero', () => { assert.equal(measure('').wordCount, 0); });
Passing Arguments
Some scripts take variable input. The runner appends the arguments given after the script name to the end of the command:
// file: tools/measure.mjs import { measure } from '../src/index.mjs'; const text = process.argv.slice(2).join(' '); if (text === '') { console.error('usage: node tools/measure.mjs <text>'); process.exit(2); } const measurement = measure(text); console.log(`${measurement.wordCount} words, ${measurement.sentenceCount} sentences, longest: ${measurement.longest}`);
$ node ../run.mjs measure "A module carries its own scope. A script runs in the global scope." > measure: node tools/measure.mjs A module carries its own scope. A script runs in the global scope. 13 words, 2 sentences, longest: carries
Notice that the arguments get reinterpreted by the shell: because the command runs inside a shell, spaces and special characters are subject to shell rules. The quoting rules from the Shell Programming course apply here; arguments carrying variable content must be quoted.
The Failure Contract
All of automation rests on a single contract: when a command fails, it returns a nonzero exit code. When the contract holds, the chain stops on its own:
// file: src/broken.mjs export function missing(text) { return text.split(/\s+/.length; }
$ node ../run.mjs broken 2>&1 | grep -E '^>|^stopped' > broken: node --check src/broken.mjs stopped: script broken exited with code 1 $ node ../run.mjs broken > /dev/null 2>&1; echo "exit code: $?" exit code: 1
The syntax check failed, the runner stopped, and set its own exit code to 1. This code lets a layer above it — the build pipeline — that calls it also stop.
Two common mistakes break the contract. The first is a script that returns zero on failure; if a pipeline’s last command succeeds, the shell returns zero and the real failure is hidden. The second is a script that only prints an error to the screen without setting the exit code; automation does not read the screen, only the code.
Reproducibility
The same command giving the same result depends on four conditions.
Dependencies are installed from the lock. If a lock file exists, installation is reproduction, not a search. The build pipeline should use the install form that conforms to the lock rather than updating it; if the lock conflicts with the manifest, the process should stop.
Tools are installed in the project. The analyzer, the formatter, and the build tools are declared as development dependencies. Relying on a system-wide tool leaves its version unchecked.
Commands are defined in one place. The build pipeline’s configuration does not rewrite commands; it calls script names. This way, when a command’s content changes, there are not two places to update.
Outputs are determined by inputs. If a build’s output depends on something other than the source and the configuration — time, machine name, network access — it cannot be reproduced.
Once these four conditions hold, the build pipeline turns into nothing more than an environment that runs the same commands the developer runs locally. If an error shows up in the build pipeline but not locally, one of the conditions is not holding; the condition that broke should be looked for before the error itself.
Summary
- Script definitions are kept in the manifest; they enter version control, serve as documentation, and remove the command difference between local and the build pipeline.
- Script names describe the work done, not the tool; when the tool changes, the callers do not.
- The runner finds the command, adds the installed tools’ directory to the front of
the search path, calls the
preandposthooks, and carries the exit code. - Automation rests on the exit-code contract: if a failure is not reported with a nonzero code, the chain keeps going incorrectly.
- Reproducibility depends on four conditions: installation from the lock, tools installed in the project, commands defined in one place, and outputs determined by inputs.
Course Wrap-Up
The course began with a single-file measurement script and moved forward by turning that script into a package. Along the way, four questions were answered.
How is code split? Module scope blocks names from leaking out at the parsing level. The export surface is a design decision: static syntax settles binding before evaluation, the call-based system trades that guarantee away for flexibility, dynamic import opens a path between the two.
How are dependencies managed? A bare name is resolved by a deterministic rule walking the directory tree. Version ranges are a convenience, a lock file is a guarantee. Duplicate copies in the tree produce silent failures; the transitive closure is the measure of the code mass being trusted.
How does the source reach the target? The bundler reduces the module graph to a single output, transpilation adapts syntax and capabilities to the target, a source map carries a generated position back to its source. Every step has a measurable cost.
How is quality kept? Static analysis catches defect patterns without running the code, formatting removes the argument, task automation makes these tools run the same way for everyone.
The principle the course carries from end to end is the same in all four: write the information into the code. The dependency lives in the manifest, the version contract in the number, the installed tree in the lock, the source mapping in the map, the commands in the script table. Any information left to memory or habit gets lost on someone else’s machine.
One kind of information still lives outside the code: what type of value a function expects and what it returns. The measurement library’s functions expect text, but that expectation is only tested at run time — a function called with a number only reports the error once execution reaches it. The next course covers writing this information into the source and checking it at build time: type inference, narrowing, generalized types, and the deliberate choice of how strict the compiler should be.
To keep your progress and take notes, Log in
My notes
Log in to take notes.