Build a Tagged Bookmark Manager CLI
A command-line bookmark manager that saves URLs with tags, searches across title, URL, and tags, opens the best match, and persists everything to a JSON file.
Project goal
Build a CLI that stores bookmarks with a title, URL, and tags, supports search across all fields, filters by tag, and prints or opens a selected bookmark.
Final features
- Add a bookmark with a title, URL, and one or more tags.
- Search bookmarks case-insensitively across title, URL, and tags.
- Filter the list by tag and sort by most recently added.
- Open a bookmark in the default browser by id or search term.
- Persist to a JSON file with atomic writes.
Architecture
A command parser maps argv to operations; a store persists bookmarks to JSON atomically; a search module implements scoring and filtering as pure functions; and an opener shells out to the platform browser command. Search terms are matched and ranked so the most relevant bookmark is easy to open by phrase.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with JSON persistence and process spawning
- Basic understanding of cross-platform differences
Dependencies
- node >=20 — File system, child_process for opening, and the CLI entry point
- vitest ^2.1.0 — Search ranking and store tests
Directory structure
bookmarks/
├── src/
│ ├── store.js
│ ├── search.js
│ ├── opener.js
│ └── cli.js
├── test/
│ ├── search.test.js
│ └── store.test.js
└── package.json
Phases
1. Atomic store
Persist the bookmark collection to a JSON file using a temp file and rename so a crash cannot corrupt it.
Files: src/store.js
import { readFile, writeFile, rename } from 'node:fs/promises';
export async function loadStore(path) {
try { return JSON.parse(await readFile(path, 'utf8')); }
catch (err) { if (err.code === 'ENOENT') return { bookmarks: [], nextId: 1 }; throw err; }
}
export async function saveStore(path, store) {
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(store, null, 2));
await rename(tmp, path);
}Expected behavior: A missing file loads an empty store, saving then loading round-trips, and the file always parses after a save.
2. Adding bookmarks
Validate and normalise a new bookmark, assign an id, and reject duplicates by URL.
Files: src/add.js
export function addBookmark(store, { title, url, tags = [] }, now = new Date()) {
if (!/^https?:\/\//.test(url)) throw new Error('url must start with http:// or https://');
if (store.bookmarks.some((b) => b.url === url)) throw new Error('bookmark already exists');
const bookmark = {
id: store.nextId,
title: title.trim() || url,
url,
tags: [...new Set(tags)],
createdAt: now.toISOString(),
};
store.bookmarks.push(bookmark);
store.nextId += 1;
return bookmark;
}Expected behavior: A valid bookmark gets an id and deduplicated tags, a duplicate URL throws, and an empty title falls back to the URL.
3. Search and ranking
Rank bookmarks by weighted term matches across title, tags, and URL, with tag and recency tie-breakers.
Files: src/search.js
export function search(bookmarks, query, { tag } = {}) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return bookmarks
.map((b) => {
const title = b.title.toLowerCase();
const url = b.url.toLowerCase();
const tags = b.tags.map((t) => t.toLowerCase());
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 3;
if (tags.some((t) => t.includes(term))) score += 2;
if (url.includes(term)) score += 1;
}
return { ...b, score };
})
.filter((b) => b.score > 0 && (!tag || b.tags.includes(tag)))
.sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt));
}Expected behavior: A title match scores three and outranks a URL-only match at one, and a tag filter removes non-matching bookmarks.
4. Browser opener
Open a URL in the platform default browser without shell interpolation of the URL.
Files: src/opener.js
import { spawn } from 'node:child_process';
export function openUrl(url, { platform = process.platform } = {}) {
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'ignore' });
child.on('error', reject);
child.on('exit', (code) => (code === 0 ? resolve(true) : reject(new Error('open failed: ' + code))));
});
}Expected behavior: The right command is chosen per platform, and the URL is passed as an argument rather than interpolated into a shell string.
5. CLI surface
Dispatch add, list, search, and open subcommands and print numbered results consistently.
Files: src/cli.js
import { loadStore, saveStore } from './store.js';
import { addBookmark } from './add.js';
import { search } from './search.js';
import { openUrl } from './opener.js';
const path = process.env.BM_FILE ?? './bookmarks.json';
const [command, ...rest] = process.argv.slice(2);
const store = await loadStore(path);
if (command === 'add') {
const [url, ...tagArgs] = rest;
const bookmark = addBookmark(store, { url, tags: tagArgs.filter((t) => t.startsWith('#')).map((t) => t.slice(1)) });
await saveStore(path, store);
console.log('added ' + bookmark.id);
} else if (command === 'search') {
for (const bm of search(store.bookmarks, rest.join(' '))) console.log(bm.id + ' ' + bm.title);
} else if (command === 'open') {
const [best] = search(store.bookmarks, rest.join(' '));
if (!best) { console.error('no match'); process.exit(1); }
await openUrl(best.url);
} else {
console.error('usage: bm <add|list|search|open>');
process.exit(1);
}Expected behavior: Add prints the new id, search lists matching bookmarks with ids, and open launches the top match or exits one when nothing matches.
6. Tests and hardening
Cover the store, add validation, and search ranking, then fix the failures the tests expose.
Files: test/search.test.js
import { search } from '../src/search.js';
const data = [
{ id: 1, title: 'Node.js docs', url: 'https://nodejs.org', tags: ['dev'], createdAt: '2026-01-02' },
{ id: 2, title: 'Recipes', url: 'https://nodejs.org/cook', tags: ['food'], createdAt: '2026-01-01' },
];
test('title match outranks url-only match', () => {
const hits = search(data, 'node');
expect(hits[0].id).toBe(1);
});
test('tag filter narrows results', () => {
expect(search(data, 'node', { tag: 'food' }).map((h) => h.id)).toEqual([2]);
});Expected behavior: The search suite passes, tag filtering composes with text, and adding a duplicate URL throws before any write.
Testing
Run npx vitest. Store tests round-trip bookmarks through a temporary file and assert that atomic writes leave a parseable file. Search tests assert that a title match outranks a URL-only match, that tag filters combine with text, and that a query with no matches returns nothing. An opener test stubs the spawn call and asserts the chosen command per platform.
Troubleshooting
Opening a URL with spaces fails
Pass the URL as a single spawn argument rather than interpolating it into a shell command; argument arrays avoid quoting bugs and command injection.
Two people add the same URL
Reject duplicates by URL at add time and offer an update command to change tags on an existing bookmark; silent duplicates make search results confusing.
Search returns nothing for a partial word
Match with substring includes rather than exact equality so prefixes work; bookmarks are remembered by fragments, not full words.
Deployment
Install as a global CLI and keep the JSON file in a synced folder to share bookmarks across machines. Because the file is plain JSON, it is also easy to back up or edit by hand.
Known limitations
- There is no cross-device sync beyond whatever syncs the JSON file itself.
- Search is substring-based and does not stem words, so it will not match synonyms.
Project overview
A bookmark manager is a small tool with an outsized payoff: the search is what makes it worth keeping, so ranking by how well a term matches title, URL, and tags gets the attention it deserves.
Bookmarks persist as one JSON file written atomically, and the open command resolves a free-text query to the single best match so the common case is one keystroke away.
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.
Search scoring is a pure function that weights title matches above URL and tag matches, which is what makes opening by phrase reliable:
export function search(bookmarks, query, { tag } = {}) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return bookmarks
.map((b) => {
const title = b.title.toLowerCase();
const url = b.url.toLowerCase();
const tags = b.tags.map((t) => t.toLowerCase());
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 3;
if (tags.some((t) => t.includes(term))) score += 2;
if (url.includes(term)) score += 1;
}
return { ...b, score };
})
.filter((b) => b.score > 0 && (!tag || b.tags.includes(tag)))
.sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt));
}
Because weights are explicit, a title match always beats a mere URL match, which matches how people remember their bookmarks.
Setup
The tool relies only on the standard library, including a platform-aware open command.
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 system, child_process for opening, and the CLI entry point |
| vitest | ^2.1.0 | Search ranking and store tests |
Create the project and install:
mkdir bookmarks && cd bookmarks
npm init -y
npm install -D vitest@^2.1.0
Configure the CLI and tests in package.json:
{
"name": "bookmarks",
"type": "module",
"scripts": {
"bm": "node src/cli.js",
"test": "vitest run"
}
}
The store path comes from an environment variable so tests write to a temporary file and leave your real bookmarks untouched.
Phase 1: Atomic store
Persist the bookmark collection to a JSON file using a temp file and rename so a crash cannot corrupt it. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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 { readFile, writeFile, rename } from 'node:fs/promises';
export async function loadStore(path) {
try { return JSON.parse(await readFile(path, 'utf8')); }
catch (err) { if (err.code === 'ENOENT') return { bookmarks: [], nextId: 1 }; throw err; }
}
export async function saveStore(path, store) {
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(store, null, 2));
await rename(tmp, path);
}
Acceptance for this phase: A missing file loads an empty store, saving then loading round-trips, and the file always parses after a save.
Phase 2: Adding bookmarks
Validate and normalise a new bookmark, assign an id, and reject duplicates by URL. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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 addBookmark(store, { title, url, tags = [] }, now = new Date()) {
if (!/^https?:\/\//.test(url)) throw new Error('url must start with http:// or https://');
if (store.bookmarks.some((b) => b.url === url)) throw new Error('bookmark already exists');
const bookmark = {
id: store.nextId,
title: title.trim() || url,
url,
tags: [...new Set(tags)],
createdAt: now.toISOString(),
};
store.bookmarks.push(bookmark);
store.nextId += 1;
return bookmark;
}
Acceptance for this phase: A valid bookmark gets an id and deduplicated tags, a duplicate URL throws, and an empty title falls back to the URL.
Phase 3: Search and ranking
Rank bookmarks by weighted term matches across title, tags, and URL, with tag and recency tie-breakers. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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.
export function search(bookmarks, query, { tag } = {}) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return bookmarks
.map((b) => {
const title = b.title.toLowerCase();
const url = b.url.toLowerCase();
const tags = b.tags.map((t) => t.toLowerCase());
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 3;
if (tags.some((t) => t.includes(term))) score += 2;
if (url.includes(term)) score += 1;
}
return { ...b, score };
})
.filter((b) => b.score > 0 && (!tag || b.tags.includes(tag)))
.sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt));
}
Acceptance for this phase: A title match scores three and outranks a URL-only match at one, and a tag filter removes non-matching bookmarks.
Phase 4: Browser opener
Open a URL in the platform default browser without shell interpolation of the URL. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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 { spawn } from 'node:child_process';
export function openUrl(url, { platform = process.platform } = {}) {
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'ignore' });
child.on('error', reject);
child.on('exit', (code) => (code === 0 ? resolve(true) : reject(new Error('open failed: ' + code))));
});
}
Acceptance for this phase: The right command is chosen per platform, and the URL is passed as an argument rather than interpolated into a shell string.
Phase 5: CLI surface
Dispatch add, list, search, and open subcommands and print numbered results consistently. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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 { loadStore, saveStore } from './store.js';
import { addBookmark } from './add.js';
import { search } from './search.js';
import { openUrl } from './opener.js';
const path = process.env.BM_FILE ?? './bookmarks.json';
const [command, ...rest] = process.argv.slice(2);
const store = await loadStore(path);
if (command === 'add') {
const [url, ...tagArgs] = rest;
const bookmark = addBookmark(store, { url, tags: tagArgs.filter((t) => t.startsWith('#')).map((t) => t.slice(1)) });
await saveStore(path, store);
console.log('added ' + bookmark.id);
} else if (command === 'search') {
for (const bm of search(store.bookmarks, rest.join(' '))) console.log(bm.id + ' ' + bm.title);
} else if (command === 'open') {
const [best] = search(store.bookmarks, rest.join(' '));
if (!best) { console.error('no match'); process.exit(1); }
await openUrl(best.url);
} else {
console.error('usage: bm <add|list|search|open>');
process.exit(1);
}
Acceptance for this phase: Add prints the new id, search lists matching bookmarks with ids, and open launches the top match or exits one when nothing matches.
Phase 6: Tests and hardening
Cover the store, add validation, and search ranking, then fix the failures the tests expose. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
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.
import { search } from '../src/search.js';
const data = [
{ id: 1, title: 'Node.js docs', url: 'https://nodejs.org', tags: ['dev'], createdAt: '2026-01-02' },
{ id: 2, title: 'Recipes', url: 'https://nodejs.org/cook', tags: ['food'], createdAt: '2026-01-01' },
];
test('title match outranks url-only match', () => {
const hits = search(data, 'node');
expect(hits[0].id).toBe(1);
});
test('tag filter narrows results', () => {
expect(search(data, 'node', { tag: 'food' }).map((h) => h.id)).toEqual([2]);
});
Acceptance for this phase: The search suite passes, tag filtering composes with text, and adding a duplicate URL throws before any write.
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. Store tests round-trip bookmarks through a temporary file and assert that atomic writes leave a parseable file. Search tests assert that a title match outranks a URL-only match, that tag filters combine with text, and that a query with no matches returns nothing. An opener test stubs the spawn call and asserts the chosen command per platform.
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 ranking test pins the behaviour users notice first: typing words from the title should surface that bookmark above one whose URL merely contains them.
Extending the project
Import from a browser export, duplicate detection by URL, and a tag rename command are all additions around the pure search and the store. A full-text index would replace the search function without touching persistence.
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.