---
title: 'Task Automation'
source: 'https://academia.sh/en/courses/javascript-ecosystem/task-automation'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

```json
{
  "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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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 `pre` and `post` hooks, 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.
