Build a Batch Image Resizer CLI
A command-line tool that walks a folder of images, resizes each to a target width while preserving aspect ratio, skips files already small enough, and reports before and after sizes.
Project goal
Build a CLI that batch-resizes images to a maximum width, preserves aspect ratio and format, skips images already within the target, writes outputs to a separate directory, and prints a summary of space saved.
Final features
- Recursively discover supported image files and report the count before starting.
- Resize each image to a maximum width without upscaling smaller images.
- Write outputs to a chosen directory, preserving the relative folder structure.
- Skip images already within the target width and log the reason.
- Print a per-file and total summary of bytes saved.
Architecture
A discovery module finds candidate files; a planner decides per file whether to resize based on dimensions; an executor performs the resize and write; and a reporter formats the summary. The image library is wrapped behind a thin adapter so the planner and reporter can be tested without decoding real images.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with streams and binary file handling
- Basic understanding of image dimensions and aspect ratio
Dependencies
- node >=20 — File traversal and the CLI entry point
- sharp ^0.33.0 — Fast image decoding, resizing, and encoding
- vitest ^2.1.0 — Planner and reporter unit tests
Directory structure
image-resizer/
├── src/
│ ├── discover.js
│ ├── planner.js
│ ├── executor.js
│ ├── adapter.js
│ └── cli.js
├── test/
│ ├── planner.test.js
│ └── discover.test.js
└── package.json
Phases
1. File discovery
Recursively walk the input directory and collect image files with supported extensions, ignoring everything else.
Files: src/discover.js
import { readdir, stat } from 'node:fs/promises';
import { join, extname, relative } from 'node:path';
const SUPPORTED = new Set(['.jpg', '.jpeg', '.png', '.webp']);
export async function discover(root) {
const found = [];
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (SUPPORTED.has(extname(entry.name).toLowerCase())) {
const info = await stat(full);
found.push({ path: full, rel: relative(root, full), sizeBytes: info.size });
}
}
}
await walk(root);
return found;
}Expected behavior: A temporary tree with nested folders yields exactly the supported files with correct relative paths and byte sizes.
2. Resize planner
Decide per image whether to resize or skip, preserving aspect ratio and never upscaling.
Files: src/planner.js
export function planResize(file, targetWidth) {
if (file.width <= targetWidth) {
return { action: 'skip', reason: 'within target', width: file.width, height: file.height };
}
const ratio = targetWidth / file.width;
return {
action: 'resize',
width: targetWidth,
height: Math.max(1, Math.round(file.height * ratio)),
};
}
export function planAll(files, targetWidth) {
return files.map((f) => ({ file: f, plan: planResize(f, targetWidth) }));
}Expected behavior: A wide image plans a resize to exactly the target width, a narrow one plans a skip, and the height is rounded rather than truncated.
3. Image adapter
Wrap the image library behind a minimal interface so tests can substitute fake metadata and the planner never imports it.
Files: src/adapter.js
import sharp from 'sharp';
export function createAdapter() {
return {
async readMetadata(path) {
const meta = await sharp(path).metadata();
return { width: meta.width, height: meta.height, format: meta.format };
},
async resize(path, outPath, width, height) {
await sharp(path).resize({ width, height, fit: 'fill' }).toFile(outPath);
return outPath;
},
};
}Expected behavior: Reading metadata from a tiny fixture returns the real width and height, and a resize call writes a file readable by the same adapter.
4. Executor and output layout
Apply the plan, create output directories mirroring the input tree, and write resized files without overwriting the source.
Files: src/executor.js
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function execute(planned, { adapter, outDir }) {
const results = [];
for (const { file, plan } of planned) {
if (plan.action === 'skip') { results.push({ rel: file.rel, action: 'skip' }); continue; }
const meta = await adapter.readMetadata(file.path);
const target = join(outDir, file.rel);
await mkdir(dirname(target), { recursive: true });
await adapter.resize(file.path, target, plan.width, plan.height);
results.push({ rel: file.rel, action: 'resize', from: meta, to: plan });
}
return results;
}Expected behavior: Executing against a temp output directory creates mirrored subdirectories and never writes back over any input file.
5. CLI surface
Parse input, output, and width arguments, validate them, and support a dry-run flag that prints the plan only.
Files: src/cli.js
import { discover } from './discover.js';
import { planAll } from './planner.js';
import { execute } from './executor.js';
import { createAdapter } from './adapter.js';
const args = parse(process.argv.slice(2));
if (!args.input || !args.width) {
console.error('usage: resize --input <dir> --width <px> [--out out] [--dry-run]');
process.exit(1);
}
const files = await discover(args.input);
const adapter = createAdapter();
const withMeta = [];
for (const f of files) withMeta.push({ ...f, ...(await adapter.readMetadata(f.path)) });
const planned = planAll(withMeta, Number(args.width));
console.log(files.length + ' images found, ' + planned.filter((p) => p.plan.action === 'resize').length + ' to resize');
if (!args.dryRun) {
await execute(planned, { adapter, outDir: args.out ?? 'resized' });
}Expected behavior: Missing --width exits with code 1 and a usage line, and a dry run prints counts without writing any output.
6. Summary reporting and tests
Print a per-file and total summary of bytes saved, and cover discovery, planning, and execution with tests.
Files: src/report.js
export function summarize(results, before) {
let bytesBefore = 0;
let bytesAfter = 0;
for (const r of results) {
bytesBefore += r.sizeBytes ?? 0;
bytesAfter += r.afterBytes ?? r.sizeBytes ?? 0;
}
const saved = bytesBefore - bytesAfter;
return {
files: results.length,
resized: results.filter((r) => r.action === 'resize').length,
skipped: results.filter((r) => r.action === 'skip').length,
savedPercent: bytesBefore ? Math.round((saved / bytesBefore) * 100) : 0,
};
}Expected behavior: A mixed batch reports the correct resized and skipped counts and a saved percentage that matches the summed byte totals.
Testing
Run npx vitest. Planner tests feed fabricated dimension records and assert skip, resize, and no-upscale decisions including the boundary where width equals the target. Discovery tests run against a temporary folder tree and assert supported extensions are found and unsupported ones ignored. One integration test resizes a tiny fixture and asserts the output file exists with the expected width.
Troubleshooting
sharp fails to install on a slim container
Install the platform build dependencies for sharp or pin a version whose prebuilt binaries exist for the target architecture; the failure is a native build issue, not a code issue.
Resized images look stretched
Compute the height from the aspect ratio and pass it explicitly, or use a fit mode that preserves aspect; passing only a width with a distorting fit is the usual cause of stretching.
Output overwrites the source images
Always write to a separate output directory and mirror the relative path; refusing to write into the input tree makes an accidental overwrite impossible.
Deployment
Distribute as a globally installable CLI or run it in a build step. In a CI pipeline, run with --dry-run first to confirm the plan, then execute to write into the build’s static assets directory.
Known limitations
- Orientations stored in EXIF may need an explicit rotate step before resizing, which this project leaves to the adapter.
- Processing is sequential; very large batches benefit from adding a concurrency pool around the executor.
Project overview
Batch image work is a classic example of a task that is tedious by hand, dangerous to get wrong, and simple once the decisions are written down. This project captures those decisions: what counts as an image, when to skip, where output goes, and how to report what changed.
The tool separates a pure planner from the operator that actually resizes, so the interesting logic can be tested on metadata alone rather than on binary fixtures.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The plan is computed entirely from dimensions, which is what makes it testable without decoding a single pixel:
export function planResize(file, targetWidth) {
const { width, height, sizeBytes } = file;
if (width <= targetWidth) {
return { action: 'skip', reason: 'already within target', width, height };
}
const ratio = targetWidth / width;
return {
action: 'resize',
width: targetWidth,
height: Math.round(height * ratio),
estimatedBytes: Math.round(sizeBytes * ratio * ratio),
};
}
Keeping the estimate in the plan lets the reporter show a projected saving before any bytes are written, which is exactly what a dry run needs.
Setup
sharp ships prebuilt binaries for common platforms, so installation is a single npm command; on unusual architectures it may compile from source, which is why the version is pinned.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | File traversal and the CLI entry point |
| sharp | ^0.33.0 | Fast image decoding, resizing, and encoding |
| vitest | ^2.1.0 | Planner and reporter unit tests |
Create the project and install:
mkdir image-resizer && cd image-resizer
npm init -y
npm install sharp@^0.33.0
npm install -D vitest@^2.1.0
Wire the CLI and the tests in package.json:
{
"name": "image-resizer",
"type": "module",
"scripts": {
"resize": "node src/cli.js",
"test": "vitest run"
}
}
Keep test fixtures tiny (a 40x20 PNG is enough) so the suite stays fast and the repository stays small.
Phase 1: File discovery
Recursively walk the input directory and collect image files with supported extensions, ignoring everything else. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { readdir, stat } from 'node:fs/promises';
import { join, extname, relative } from 'node:path';
const SUPPORTED = new Set(['.jpg', '.jpeg', '.png', '.webp']);
export async function discover(root) {
const found = [];
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (SUPPORTED.has(extname(entry.name).toLowerCase())) {
const info = await stat(full);
found.push({ path: full, rel: relative(root, full), sizeBytes: info.size });
}
}
}
await walk(root);
return found;
}
Acceptance for this phase: A temporary tree with nested folders yields exactly the supported files with correct relative paths and byte sizes.
Phase 2: Resize planner
Decide per image whether to resize or skip, preserving aspect ratio and never upscaling. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function planResize(file, targetWidth) {
if (file.width <= targetWidth) {
return { action: 'skip', reason: 'within target', width: file.width, height: file.height };
}
const ratio = targetWidth / file.width;
return {
action: 'resize',
width: targetWidth,
height: Math.max(1, Math.round(file.height * ratio)),
};
}
export function planAll(files, targetWidth) {
return files.map((f) => ({ file: f, plan: planResize(f, targetWidth) }));
}
Acceptance for this phase: A wide image plans a resize to exactly the target width, a narrow one plans a skip, and the height is rounded rather than truncated.
Phase 3: Image adapter
Wrap the image library behind a minimal interface so tests can substitute fake metadata and the planner never imports it. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
import sharp from 'sharp';
export function createAdapter() {
return {
async readMetadata(path) {
const meta = await sharp(path).metadata();
return { width: meta.width, height: meta.height, format: meta.format };
},
async resize(path, outPath, width, height) {
await sharp(path).resize({ width, height, fit: 'fill' }).toFile(outPath);
return outPath;
},
};
}
Acceptance for this phase: Reading metadata from a tiny fixture returns the real width and height, and a resize call writes a file readable by the same adapter.
Phase 4: Executor and output layout
Apply the plan, create output directories mirroring the input tree, and write resized files without overwriting the source. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function execute(planned, { adapter, outDir }) {
const results = [];
for (const { file, plan } of planned) {
if (plan.action === 'skip') { results.push({ rel: file.rel, action: 'skip' }); continue; }
const meta = await adapter.readMetadata(file.path);
const target = join(outDir, file.rel);
await mkdir(dirname(target), { recursive: true });
await adapter.resize(file.path, target, plan.width, plan.height);
results.push({ rel: file.rel, action: 'resize', from: meta, to: plan });
}
return results;
}
Acceptance for this phase: Executing against a temp output directory creates mirrored subdirectories and never writes back over any input file.
Phase 5: CLI surface
Parse input, output, and width arguments, validate them, and support a dry-run flag that prints the plan only. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { discover } from './discover.js';
import { planAll } from './planner.js';
import { execute } from './executor.js';
import { createAdapter } from './adapter.js';
const args = parse(process.argv.slice(2));
if (!args.input || !args.width) {
console.error('usage: resize --input <dir> --width <px> [--out out] [--dry-run]');
process.exit(1);
}
const files = await discover(args.input);
const adapter = createAdapter();
const withMeta = [];
for (const f of files) withMeta.push({ ...f, ...(await adapter.readMetadata(f.path)) });
const planned = planAll(withMeta, Number(args.width));
console.log(files.length + ' images found, ' + planned.filter((p) => p.plan.action === 'resize').length + ' to resize');
if (!args.dryRun) {
await execute(planned, { adapter, outDir: args.out ?? 'resized' });
}
Acceptance for this phase: Missing –width exits with code 1 and a usage line, and a dry run prints counts without writing any output.
Phase 6: Summary reporting and tests
Print a per-file and total summary of bytes saved, and cover discovery, planning, and execution with tests. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
export function summarize(results, before) {
let bytesBefore = 0;
let bytesAfter = 0;
for (const r of results) {
bytesBefore += r.sizeBytes ?? 0;
bytesAfter += r.afterBytes ?? r.sizeBytes ?? 0;
}
const saved = bytesBefore - bytesAfter;
return {
files: results.length,
resized: results.filter((r) => r.action === 'resize').length,
skipped: results.filter((r) => r.action === 'skip').length,
savedPercent: bytesBefore ? Math.round((saved / bytesBefore) * 100) : 0,
};
}
Acceptance for this phase: A mixed batch reports the correct resized and skipped counts and a saved percentage that matches the summed byte totals.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Planner tests feed fabricated dimension records and assert skip, resize, and no-upscale decisions including the boundary where width equals the target. Discovery tests run against a temporary folder tree and assert supported extensions are found and unsupported ones ignored. One integration test resizes a tiny fixture and asserts the output file exists with the expected width.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The boundary test matters most: an image exactly at the target width must be skipped, because upscaling-free behaviour is where hand-written scripts most often go wrong.
Extending the project
Additional transforms — format conversion, quality tuning, watermarking — are new branches in the planner and new calls in the adapter, without changing discovery or reporting. A concurrency pool wraps the executor when throughput matters, and a manifest file makes reruns incremental.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.